diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle
index 9d9b1154ba..21395df3e2 100644
--- a/apps/android/app/build.gradle
+++ b/apps/android/app/build.gradle
@@ -81,6 +81,7 @@ dependencies {
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
+ implementation 'androidx.webkit:webkit:1.4.0'
def work_version = "2.7.1"
implementation "androidx.work:work-runtime-ktx:$work_version"
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index b7e2e930af..3c2b3d9451 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
diff --git a/apps/android/app/src/main/assets/www/README.md b/apps/android/app/src/main/assets/www/README.md
new file mode 100644
index 0000000000..cf012e4d58
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/README.md
@@ -0,0 +1,3 @@
+# WebView for WebRTC calls in SimpleX Chat
+
+Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc
diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html
new file mode 100644
index 0000000000..ebe63e1a3b
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/call.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js
new file mode 100644
index 0000000000..94ef42c09f
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/call.js
@@ -0,0 +1,454 @@
+"use strict";
+// Inspired by
+// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
+var CallMediaType;
+(function (CallMediaType) {
+ CallMediaType["Audio"] = "audio";
+ CallMediaType["Video"] = "video";
+})(CallMediaType || (CallMediaType = {}));
+const keyAlgorithm = {
+ name: "AES-GCM",
+ length: 256,
+};
+const keyUsages = ["encrypt", "decrypt"];
+let pc;
+const IV_LENGTH = 12;
+const initialPlainTextRequired = {
+ key: 10,
+ delta: 3,
+ undefined: 1,
+};
+function defaultCallConfig(encodedInsertableStreams) {
+ return {
+ peerConnectionConfig: {
+ iceServers: [{ urls: ["stun:stun.l.google.com:19302"] }],
+ iceCandidatePoolSize: 10,
+ encodedInsertableStreams,
+ },
+ iceCandidates: {
+ delay: 2000,
+ extrasInterval: 2000,
+ extrasTimeout: 8000,
+ },
+ };
+}
+async function initializeCall(config, mediaType, aesKey) {
+ const conn = new RTCPeerConnection(config.peerConnectionConfig);
+ const remoteStream = new MediaStream();
+ const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType));
+ await setUpMediaStreams(conn, localStream, remoteStream, aesKey);
+ conn.addEventListener("connectionstatechange", connectionStateChange);
+ const iceCandidates = new Promise((resolve, _) => {
+ let candidates = [];
+ let resolved = false;
+ let extrasInterval;
+ let extrasTimeout;
+ const delay = setTimeout(() => {
+ if (!resolved) {
+ resolveIceCandidates();
+ extrasInterval = setInterval(() => {
+ sendIceCandidates();
+ }, config.iceCandidates.extrasInterval);
+ extrasTimeout = setTimeout(() => {
+ clearInterval(extrasInterval);
+ sendIceCandidates();
+ }, config.iceCandidates.extrasTimeout);
+ }
+ }, config.iceCandidates.delay);
+ conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c);
+ conn.onicegatheringstatechange = () => {
+ if (conn.iceGatheringState == "complete") {
+ if (resolved) {
+ if (extrasInterval)
+ clearInterval(extrasInterval);
+ if (extrasTimeout)
+ clearTimeout(extrasTimeout);
+ sendIceCandidates();
+ }
+ else {
+ resolveIceCandidates();
+ }
+ }
+ };
+ function resolveIceCandidates() {
+ if (delay)
+ clearTimeout(delay);
+ resolved = true;
+ const iceCandidates = candidates.map((c) => JSON.stringify(c));
+ candidates = [];
+ resolve(iceCandidates);
+ }
+ function sendIceCandidates() {
+ if (candidates.length === 0)
+ return;
+ const iceCandidates = candidates.map((c) => JSON.stringify(c));
+ candidates = [];
+ sendMessageToNative({ resp: { type: "ice", iceCandidates } });
+ }
+ });
+ return { connection: conn, iceCandidates };
+ function connectionStateChange() {
+ sendMessageToNative({
+ resp: {
+ type: "connection",
+ state: {
+ connectionState: conn.connectionState,
+ iceConnectionState: conn.iceConnectionState,
+ iceGatheringState: conn.iceGatheringState,
+ signalingState: conn.signalingState,
+ },
+ },
+ });
+ if (conn.connectionState == "disconnected" || conn.connectionState == "failed") {
+ conn.removeEventListener("connectionstatechange", connectionStateChange);
+ sendMessageToNative({ resp: { type: "ended" } });
+ conn.close();
+ pc = undefined;
+ resetVideoElements();
+ }
+ }
+}
+function sendMessageToNative(msg) {
+ console.log(JSON.stringify(msg));
+}
+// TODO remove WCallCommand from result type
+async function processCommand(body) {
+ const { command, corrId } = body;
+ let resp;
+ try {
+ switch (command.type) {
+ case "capabilities":
+ const encryption = supportsInsertableStreams();
+ resp = { type: "capabilities", capabilities: { encryption } };
+ break;
+ case "start":
+ console.log("starting call");
+ if (pc) {
+ resp = { type: "error", message: "start: call already started" };
+ }
+ else if (!supportsInsertableStreams() && command.aesKey) {
+ resp = { type: "error", message: "start: encryption is not supported" };
+ }
+ else {
+ const { media, aesKey } = command;
+ const call = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey);
+ const { connection, iceCandidates } = call;
+ pc = connection;
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ // for debugging, returning the command for callee to use
+ resp = { type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey };
+ // resp = {type: "offer", offer, iceCandidates: await iceCandidates}
+ }
+ break;
+ case "accept":
+ if (pc) {
+ resp = { type: "error", message: "accept: call already started" };
+ }
+ else if (!supportsInsertableStreams() && command.aesKey) {
+ resp = { type: "error", message: "accept: encryption is not supported" };
+ }
+ else {
+ const offer = JSON.parse(command.offer);
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c));
+ const call = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey);
+ const { connection, iceCandidates } = call;
+ pc = connection;
+ await pc.setRemoteDescription(new RTCSessionDescription(offer));
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ addIceCandidates(pc, remoteIceCandidates);
+ // same as command for caller to use
+ resp = { type: "answer", answer: JSON.stringify(answer), iceCandidates: await iceCandidates };
+ }
+ break;
+ case "answer":
+ if (!pc) {
+ resp = { type: "error", message: "answer: call not started" };
+ }
+ else if (!pc.localDescription) {
+ resp = { type: "error", message: "answer: local description is not set" };
+ }
+ else if (pc.currentRemoteDescription) {
+ resp = { type: "error", message: "answer: remote description already set" };
+ }
+ else {
+ const answer = JSON.parse(command.answer);
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c));
+ await pc.setRemoteDescription(new RTCSessionDescription(answer));
+ addIceCandidates(pc, remoteIceCandidates);
+ resp = { type: "ok" };
+ }
+ break;
+ case "ice":
+ if (pc) {
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c));
+ addIceCandidates(pc, remoteIceCandidates);
+ resp = { type: "ok" };
+ }
+ else {
+ resp = { type: "error", message: "ice: call not started" };
+ }
+ break;
+ case "end":
+ if (pc) {
+ pc.close();
+ pc = undefined;
+ resetVideoElements();
+ resp = { type: "ok" };
+ }
+ else {
+ resp = { type: "error", message: "end: call not started" };
+ }
+ break;
+ default:
+ resp = { type: "error", message: "unknown command" };
+ break;
+ }
+ }
+ catch (e) {
+ resp = { type: "error", message: e.message };
+ }
+ const apiResp = { resp, corrId };
+ sendMessageToNative(apiResp);
+ return apiResp;
+}
+function addIceCandidates(conn, iceCandidates) {
+ for (const c of iceCandidates) {
+ conn.addIceCandidate(new RTCIceCandidate(c));
+ }
+}
+async function setUpMediaStreams(pc, localStream, remoteStream, aesKey) {
+ var _a;
+ const videos = getVideoElements();
+ if (!videos)
+ throw Error("no video elements");
+ let key;
+ if (aesKey) {
+ const keyData = decodeBase64(encodeAscii(aesKey));
+ if (keyData)
+ key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages);
+ }
+ for (const track of localStream.getTracks()) {
+ pc.addTrack(track, localStream);
+ }
+ if (key) {
+ console.log("set up encryption for sending");
+ for (const sender of pc.getSenders()) {
+ setupPeerTransform(sender, encodeFunction(key));
+ }
+ }
+ // Pull tracks from remote stream as they arrive add them to remoteStream video
+ pc.ontrack = (event) => {
+ if (key) {
+ console.log("set up decryption for receiving");
+ setupPeerTransform(event.receiver, decodeFunction(key));
+ }
+ for (const track of event.streams[0].getTracks()) {
+ remoteStream.addTrack(track);
+ }
+ };
+ // We assume VP8 encoding in the decode/encode stages to get the initial
+ // bytes to pass as plaintext so we enforce that here.
+ // VP8 is supported by all supports of webrtc.
+ // Use of VP8 by default may also reduce depacketisation issues.
+ // We do not encrypt the first couple of bytes of the payload so that the
+ // video elements can work by determining video keyframes and the opus mode
+ // being used. This appears to be necessary for any video feed at all.
+ // For VP8 this is the content described in
+ // https://tools.ietf.org/html/rfc6386#section-9.1
+ // which is 10 bytes for key frames and 3 bytes for delta frames.
+ // For opus (where encodedFrame.type is not set) this is the TOC byte from
+ // https://tools.ietf.org/html/rfc6716#section-3.1
+ const capabilities = RTCRtpSender.getCapabilities("video");
+ if (capabilities) {
+ const { codecs } = capabilities;
+ const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8");
+ const selectedCodec = codecs[selectedCodecIndex];
+ codecs.splice(selectedCodecIndex, 1);
+ codecs.unshift(selectedCodec);
+ for (const t of pc.getTransceivers()) {
+ if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
+ t.setCodecPreferences(codecs);
+ }
+ }
+ }
+ // setupVideoElement(videos.local)
+ // setupVideoElement(videos.remote)
+ videos.local.srcObject = localStream;
+ videos.remote.srcObject = remoteStream;
+}
+function callMediaConstraints(mediaType) {
+ switch (mediaType) {
+ case CallMediaType.Audio:
+ return { audio: true, video: false };
+ case CallMediaType.Video:
+ return {
+ audio: true,
+ video: {
+ frameRate: 24,
+ width: {
+ min: 480,
+ ideal: 720,
+ max: 1280,
+ },
+ aspectRatio: 1.33,
+ },
+ };
+ }
+}
+function supportsInsertableStreams() {
+ return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype;
+}
+function resetVideoElements() {
+ const videos = getVideoElements();
+ if (!videos)
+ return;
+ videos.local.srcObject = null;
+ videos.remote.srcObject = null;
+}
+function getVideoElements() {
+ const local = document.getElementById("local-video-stream");
+ const remote = document.getElementById("remote-video-stream");
+ if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement))
+ return;
+ return { local, remote };
+}
+// function setupVideoElement(video: HTMLElement) {
+// // TODO use display: none
+// video.style.opacity = "0"
+// video.onplaying = () => {
+// video.style.opacity = "1"
+// }
+// }
+// what does it do?
+// function toggleVideo(b) {
+// if (b == "true") {
+// localStream.getVideoTracks()[0].enabled = true
+// } else {
+// localStream.getVideoTracks()[0].enabled = false
+// }
+// }
+/* Stream Transforms */
+function setupPeerTransform(peer, transform) {
+ const streams = peer.createEncodedStreams();
+ streams.readable.pipeThrough(new TransformStream({ transform })).pipeTo(streams.writable);
+}
+/* Cryptography */
+function encodeFunction(key) {
+ return async (frame, controller) => {
+ const data = new Uint8Array(frame.data);
+ const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0;
+ const iv = randomIV();
+ const initial = data.subarray(0, n);
+ const plaintext = data.subarray(n, data.byteLength);
+ try {
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext);
+ frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer;
+ controller.enqueue(frame);
+ }
+ catch (e) {
+ console.log(`encryption error ${e}`);
+ throw e;
+ }
+ };
+}
+function decodeFunction(key) {
+ return async (frame, controller) => {
+ const data = new Uint8Array(frame.data);
+ const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0;
+ const initial = data.subarray(0, n);
+ const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
+ const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
+ try {
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
+ frame.data = concatN(initial, new Uint8Array(plaintext)).buffer;
+ controller.enqueue(frame);
+ }
+ catch (e) {
+ console.log(`decryption error ${e}`);
+ throw e;
+ }
+ };
+}
+class RTCEncodedVideoFrame {
+ constructor(type, data) {
+ this.type = type;
+ this.data = data;
+ }
+}
+function randomIV() {
+ return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
+}
+const char_equal = "=".charCodeAt(0);
+function concatN(...bs) {
+ const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
+ bs.reduce((offset, b) => {
+ a.set(b, offset);
+ return offset + b.byteLength;
+ }, 0);
+ return a;
+}
+function encodeAscii(s) {
+ const a = new Uint8Array(s.length);
+ let i = s.length;
+ while (i--)
+ a[i] = s.charCodeAt(i);
+ return a;
+}
+function decodeAscii(a) {
+ let s = "";
+ for (let i = 0; i < a.length; i++)
+ s += String.fromCharCode(a[i]);
+ return s;
+}
+const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)));
+const base64lookup = new Array(256);
+base64chars.forEach((c, i) => (base64lookup[c] = i));
+function encodeBase64(a) {
+ const len = a.length;
+ const b64len = Math.ceil(len / 3) * 4;
+ const b64 = new Uint8Array(b64len);
+ let j = 0;
+ for (let i = 0; i < len; i += 3) {
+ b64[j++] = base64chars[a[i] >> 2];
+ b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
+ b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
+ b64[j++] = base64chars[a[i + 2] & 63];
+ }
+ if (len % 3)
+ b64[b64len - 1] = char_equal;
+ if (len % 3 === 1)
+ b64[b64len - 2] = char_equal;
+ return b64;
+}
+function decodeBase64(b64) {
+ let len = b64.length;
+ if (len % 4)
+ return;
+ let bLen = (len * 3) / 4;
+ if (b64[len - 1] === char_equal) {
+ len--;
+ bLen--;
+ if (b64[len - 1] === char_equal) {
+ len--;
+ bLen--;
+ }
+ }
+ const bytes = new Uint8Array(bLen);
+ let i = 0;
+ let pos = 0;
+ while (i < len) {
+ const enc1 = base64lookup[b64[i++]];
+ const enc2 = i < len ? base64lookup[b64[i++]] : 0;
+ const enc3 = i < len ? base64lookup[b64[i++]] : 0;
+ const enc4 = i < len ? base64lookup[b64[i++]] : 0;
+ if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
+ return;
+ bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
+ bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2);
+ bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63);
+ }
+ return bytes;
+}
+//# sourceMappingURL=call.js.map
\ No newline at end of file
diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css
new file mode 100644
index 0000000000..624e96dfe7
--- /dev/null
+++ b/apps/android/app/src/main/assets/www/style.css
@@ -0,0 +1,25 @@
+video::-webkit-media-controls {
+ display: none;
+}
+html,
+body {
+ padding: 0;
+ margin: 0;
+}
+#remote-video-stream {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+#local-video-stream {
+ position: absolute;
+ width: 30%;
+ max-width: 30%;
+ object-fit: cover;
+ margin: 16px;
+ border-radius: 16px;
+ bottom: 0;
+ right: 0;
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
new file mode 100644
index 0000000000..8895c44e40
--- /dev/null
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt
@@ -0,0 +1,165 @@
+package chat.simplex.app.views.call
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.util.Log
+import android.view.ViewGroup
+import android.webkit.*
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.webkit.WebViewAssetLoader
+import androidx.webkit.WebViewClientCompat
+import chat.simplex.app.TAG
+import chat.simplex.app.views.helpers.TextEditor
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+
+//@SuppressLint("JavascriptInterface")
+@Composable
+fun VideoCallView(close: () -> Unit) {
+ BackHandler(onBack = close)
+ lateinit var wv: WebView
+ val context = LocalContext.current
+ val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java)
+ val permissionsState = rememberMultiplePermissionsState(
+ permissions = listOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO,
+ Manifest.permission.MODIFY_AUDIO_SETTINGS,
+ Manifest.permission.INTERNET
+ )
+ )
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) {
+ permissionsState.launchMultiplePermissionRequest()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+
+ onDispose {
+ wv.evaluateJavascript("endCall()", null)
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+ val localContext = LocalContext.current
+ val iceCandidateCommand = remember { mutableStateOf("") }
+ val commandToShow = remember { mutableStateOf("processCommand({type: 'start', media: 'video', aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") }
+ val assetLoader = WebViewAssetLoader.Builder()
+ .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext))
+ .build()
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier
+ .background(MaterialTheme.colors.background)
+ .fillMaxSize()
+ ) {
+ if (permissionsState.allPermissionsGranted) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(ratio = 1F)
+ ) {
+ AndroidView(
+ factory = { AndroidViewContext ->
+ WebView(AndroidViewContext).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ this.webChromeClient = object: WebChromeClient() {
+ override fun onPermissionRequest(request: PermissionRequest) {
+ if (request.origin.toString().startsWith("file:/")) {
+ request.grant(request.resources)
+ } else {
+ Log.d(TAG, "Permission request from webview denied.")
+ request.deny()
+ }
+ }
+
+ override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
+ val rtnValue = super.onConsoleMessage(consoleMessage)
+ val msg = consoleMessage?.message() as String
+ if (msg.startsWith("{\"action\":\"processIceCandidates\"")) {
+ iceCandidateCommand.value = "processCommand($msg)"
+ } else if (msg.startsWith("{")) {
+ commandToShow.value = "processCommand($msg)"
+ }
+ return rtnValue
+ }
+ }
+ this.webViewClient = LocalContentWebViewClient(assetLoader)
+ this.clearHistory()
+ this.clearCache(true)
+// this.addJavascriptInterface(JavascriptInterface(), "Android")
+ val webViewSettings = this.settings
+ webViewSettings.allowFileAccess = true
+ webViewSettings.allowContentAccess = true
+ webViewSettings.javaScriptEnabled = true
+ webViewSettings.mediaPlaybackRequiresUserGesture = false
+ webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
+ this.loadUrl("file:android_asset/www/call.html")
+ }
+ }
+ ) {
+ wv = it
+ }
+ }
+ } else {
+ Text("NEED PERMISSIONS")
+ }
+
+ TextEditor(Modifier.height(180.dp), text = commandToShow)
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Button( onClick = {
+ val clip: ClipData = ClipData.newPlainText("js command", commandToShow.value)
+ clipboard?.setPrimaryClip(clip)
+ }) {Text("Copy")}
+ Button( onClick = {
+ println("sending: ${commandToShow.value}")
+ wv.evaluateJavascript(commandToShow.value, null)
+ commandToShow.value = ""
+ }) {Text("Send")}
+ Button( onClick = {
+ commandToShow.value = iceCandidateCommand.value
+ }) {Text("ICE")}
+ Button( onClick = {
+ commandToShow.value = ""
+ }) {Text("Clear")}
+ Button( onClick = {
+ wv.evaluateJavascript("endCall()", null)
+ }) {Text("End Call")}
+ }
+ }
+}
+
+private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest
+ ): WebResourceResponse? {
+ return assetLoader.shouldInterceptRequest(request.url)
+ }
+}
diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
index cd3cd897f0..5eb906cea8 100644
--- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
+++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt
@@ -24,6 +24,7 @@ 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.*
@Composable
@@ -39,7 +40,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) } }
+ showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
+ showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } },
)
}
}
@@ -54,7 +56,8 @@ fun SettingsLayout(
setRunServiceInBackground: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
- showTerminal: () -> Unit
+ showTerminal: () -> Unit,
+ showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
@@ -159,9 +162,9 @@ fun SettingsLayout(
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.private_notifications), Modifier
- .padding(end = 24.dp)
- .fillMaxWidth()
- .weight(1F))
+ .padding(end = 24.dp)
+ .fillMaxWidth()
+ .weight(1F))
Switch(
checked = runServiceInBackground.value,
onCheckedChange = { setRunServiceInBackground(it) },
@@ -191,7 +194,7 @@ fun SettingsLayout(
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
}
Divider(Modifier.padding(horizontal = 8.dp))
- SettingsSectionView() {
+ SettingsSectionView(showVideoChatPrototype) {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@@ -227,7 +230,8 @@ fun PreviewSettingsLayout() {
setRunServiceInBackground = {},
showModal = {{}},
showCustomModal = {{}},
- showTerminal = {}
+ showTerminal = {},
+ showVideoChatPrototype = {}
)
}
}
diff --git a/apps/ios/Shared/Views/Call/CallView.swift b/apps/ios/Shared/Views/Call/CallView.swift
new file mode 100644
index 0000000000..569f481429
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/CallView.swift
@@ -0,0 +1,187 @@
+//
+// CallView.swift
+// SimpleX (iOS)
+//
+// Created by Ian Davies on 29/04/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import SwiftUI
+import WebKit
+
+class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
+ var webView: WKWebView!
+
+ var corrId = 0
+ var pendingCommands: Dictionary> = [:]
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ webView.allowsBackForwardNavigationGestures = false
+ self.webView = webView
+ }
+
+ // receive message from WKWebView
+ func userContentController(
+ _ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ logger.debug("WebRTCCoordinator.userContentController")
+ if let data = (message.body as? String)?.data(using: .utf8),
+ let msg = try? jsonDecoder.decode(WVAPIMessage.self, from: data) {
+ if let corrId = msg.corrId, let cont = pendingCommands.removeValue(forKey: corrId) {
+ cont.resume(returning: msg.resp)
+ } else {
+ // TODO pass messages to call view via binding
+ // print(msg.resp)
+ }
+ } else {
+ logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))")
+ }
+ }
+
+ func messageToWebview(msg: String) {
+ logger.debug("WebRTCCoordinator.messageToWebview")
+ self.webView.evaluateJavaScript("webkit.messageHandlers.logHandler.postMessage('\(msg)')")
+ }
+
+ func processCommand(command: WCallCommand) async -> WCallResponse {
+ await withCheckedContinuation { cont in
+ logger.debug("WebRTCCoordinator.processCommand")
+ let corrId_ = corrId
+ corrId = corrId + 1
+ pendingCommands[corrId_] = cont
+ do {
+ let apiData = try jsonEncoder.encode(WVAPICall(corrId: corrId_, command: command))
+ DispatchQueue.main.async {
+ logger.debug("WebRTCCoordinator.processCommand DispatchQueue.main.async")
+ let js = "processCommand(\(String(decoding: apiData, as: UTF8.self)))"
+ self.webView.evaluateJavaScript(js)
+ }
+ } catch {
+ logger.error("WebRTCCoordinator.processCommand: error encoding command \(error.localizedDescription)")
+ }
+ }
+ }
+}
+
+struct WebRTCView: UIViewRepresentable {
+ @Binding var coordinator: WebRTCCoordinator?
+
+ func makeCoordinator() -> WebRTCCoordinator {
+ WebRTCCoordinator()
+ }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let _coordinator = makeCoordinator()
+ DispatchQueue.main.async {
+ coordinator = _coordinator
+ }
+
+ let userContentController = WKUserContentController()
+
+ let cfg = WKWebViewConfiguration()
+ cfg.userContentController = userContentController
+ cfg.mediaTypesRequiringUserActionForPlayback = []
+ cfg.allowsInlineMediaPlayback = true
+
+ // Enable us to capture calls to console.log in the xcode logs
+ let source = "console.log = (msg) => webkit.messageHandlers.logHandler.postMessage(msg)"
+ let script = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ cfg.userContentController.addUserScript(script)
+ cfg.userContentController.add(_coordinator, name: "logHandler")
+
+ let _wkwebview = WKWebView(frame: .zero, configuration: cfg)
+ _wkwebview.navigationDelegate = _coordinator
+ guard let path: String = Bundle.main.path(forResource: "call", ofType: "html", inDirectory: "www") else {
+ logger.error("WebRTCView.makeUIView call.html not found")
+ return _wkwebview
+ }
+ let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false)
+ _wkwebview.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl)
+ return _wkwebview
+ }
+
+ func updateUIView(_ webView: WKWebView, context: Context) {
+ logger.debug("WebRTCView.updateUIView")
+ }
+}
+
+
+
+struct CallView: View {
+ @State var coordinator: WebRTCCoordinator? = nil
+ @State var commandStr = ""
+ @FocusState private var keyboardVisible: Bool
+
+ var body: some View {
+ VStack(spacing: 30) {
+ WebRTCView(coordinator: $coordinator).frame(maxHeight: 260)
+ TextEditor(text: $commandStr)
+ .focused($keyboardVisible)
+ .disableAutocorrection(true)
+ .textInputAutocapitalization(.never)
+ .padding(.horizontal, 5)
+ .padding(.top, 2)
+ .frame(height: 112)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
+ )
+ HStack(spacing: 20) {
+ Button("Copy") {
+ UIPasteboard.general.string = commandStr
+ }
+ Button("Paste") {
+ commandStr = UIPasteboard.general.string ?? ""
+ }
+ Button("Clear") {
+ commandStr = ""
+ }
+ Button("Send") {
+ do {
+ let command = try jsonDecoder.decode(WCallCommand.self, from: commandStr.data(using: .utf8)!)
+ if let c = coordinator {
+ Task {
+ let resp = await c.processCommand(command: command)
+ print(String(decoding: try! jsonEncoder.encode(resp), as: UTF8.self))
+ }
+ }
+ } catch {
+ print(error)
+ }
+ }
+ }
+ HStack(spacing: 20) {
+ Button("Capabilities") {
+
+ }
+ Button("Start") {
+ if let c = coordinator {
+ Task {
+ let resp = await c.processCommand(command: .start(media: .video))
+ print(String(decoding: try! jsonEncoder.encode(resp), as: UTF8.self))
+ }
+ }
+ }
+ Button("Accept") {
+
+ }
+ Button("Answer") {
+
+ }
+ Button("ICE") {
+
+ }
+ Button("End") {
+
+ }
+ }
+ }
+ }
+}
+
+struct CallView_Previews: PreviewProvider {
+ static var previews: some View {
+ CallView()
+ }
+}
diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift
new file mode 100644
index 0000000000..7af5b90cca
--- /dev/null
+++ b/apps/ios/Shared/Views/Call/WebRTC.swift
@@ -0,0 +1,229 @@
+//
+// WebRTC.swift
+// SimpleX (iOS)
+//
+// Created by Evgeny on 03/05/2022.
+// Copyright © 2022 SimpleX Chat. All rights reserved.
+//
+
+import Foundation
+
+struct WVAPICall: Encodable {
+ var corrId: Int
+ var command: WCallCommand
+}
+
+struct WVAPIMessage: Decodable {
+ var corrId: Int?
+ var resp: WCallResponse
+}
+
+enum WCallCommand {
+ case capabilities
+ case start(media: CallMediaType, aesKey: String? = nil)
+ case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
+ case answer(answer: String, iceCandidates: [String])
+ case ice(iceCandidates: [String])
+ case end
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case media
+ case aesKey
+ case offer
+ case answer
+ case iceCandidates
+ }
+}
+
+extension WCallCommand: Encodable {
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .capabilities:
+ try container.encode("capabilities", forKey: .type)
+ case let .start(media, aesKey):
+ try container.encode("start", forKey: .type)
+ try container.encode(media, forKey: .media)
+ try container.encode(aesKey, forKey: .aesKey)
+ case let .accept(offer, iceCandidates, media, aesKey):
+ try container.encode("accept", forKey: .type)
+ try container.encode(offer, forKey: .offer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ try container.encode(media, forKey: .media)
+ try container.encode(aesKey, forKey: .aesKey)
+ case let .answer(answer, iceCandidates):
+ try container.encode("answer", forKey: .type)
+ try container.encode(answer, forKey: .answer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case let .ice(iceCandidates):
+ try container.encode("ice", forKey: .type)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case .end:
+ try container.encode("end", forKey: .type)
+ }
+ }
+}
+
+// This protocol is only needed for debugging
+extension WCallCommand: Decodable {
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: CodingKeys.type)
+ switch type {
+ case "capabilities":
+ self = .capabilities
+ case "start":
+ let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
+ self = .start(media: media, aesKey: aesKey)
+ case "accept":
+ let offer = try container.decode(String.self, forKey: CodingKeys.offer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
+ self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey)
+ case "answer":
+ let answer = try container.decode(String.self, forKey: CodingKeys.answer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .answer(answer: answer, iceCandidates: iceCandidates)
+ case "ice":
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .ice(iceCandidates: iceCandidates)
+ case "end":
+ self = .end
+ default:
+ throw DecodingError.typeMismatch(WCallCommand.self, DecodingError.Context(codingPath: [CodingKeys.type], debugDescription: "cannot decode WCallCommand, unknown type \(type)"))
+ }
+ }
+}
+
+
+enum WCallResponse {
+ case capabilities(capabilities: CallCapabilities)
+ case offer(offer: String, iceCandidates: [String])
+ // TODO remove accept, it is needed for debugging
+ case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil)
+ case answer(answer: String, iceCandidates: [String])
+ case ice(iceCandidates: [String])
+ case connection(state: ConnectionState)
+ case ended
+ case ok
+ case error(message: String)
+ case invalid(type: String)
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case capabilities
+ case offer
+ case answer
+ case iceCandidates
+ case state
+ case message
+ // TODO remove media, aesKey
+ case media
+ case aesKey
+ }
+}
+
+extension WCallResponse: Decodable {
+ init(from decoder: Decoder) throws {
+ do {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: CodingKeys.type)
+ switch type {
+ case "capabilities":
+ let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities)
+ self = .capabilities(capabilities: capabilities)
+ case "offer":
+ let offer = try container.decode(String.self, forKey: CodingKeys.offer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .offer(offer: offer, iceCandidates: iceCandidates)
+ // TODO remove accept
+ case "accept":
+ let offer = try container.decode(String.self, forKey: CodingKeys.offer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media)
+ let aesKey = try? container.decode(String.self, forKey: CodingKeys.aesKey)
+ self = .accept(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey)
+ case "answer":
+ let answer = try container.decode(String.self, forKey: CodingKeys.answer)
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .answer(answer: answer, iceCandidates: iceCandidates)
+ case "ice":
+ let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates)
+ self = .ice(iceCandidates: iceCandidates)
+ case "connection":
+ let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state)
+ self = .connection(state: state)
+ case "ended":
+ self = .ended
+ case "ok":
+ self = .ok
+ case "error":
+ let message = try container.decode(String.self, forKey: CodingKeys.message)
+ self = .error(message: message)
+ default:
+ self = .invalid(type: type)
+ }
+ } catch {
+ self = .invalid(type: "unknown")
+ }
+ }
+}
+
+// This protocol is only needed for debugging
+extension WCallResponse: Encodable {
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .capabilities:
+ try container.encode("capabilities", forKey: .type)
+ case let .offer(offer, iceCandidates):
+ try container.encode("offer", forKey: .type)
+ try container.encode(offer, forKey: .offer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case let .accept(offer, iceCandidates, media, aesKey):
+ try container.encode("accept", forKey: .type)
+ try container.encode(offer, forKey: .offer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ try container.encode(media, forKey: .media)
+ try container.encode(aesKey, forKey: .aesKey)
+ case let .answer(answer, iceCandidates):
+ try container.encode("answer", forKey: .type)
+ try container.encode(answer, forKey: .answer)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case let .ice(iceCandidates):
+ try container.encode("ice", forKey: .type)
+ try container.encode(iceCandidates, forKey: .iceCandidates)
+ case let .connection(state):
+ try container.encode("connection", forKey: .type)
+ try container.encode(state, forKey: .state)
+ case .ended:
+ try container.encode("ended", forKey: .type)
+ case .ok:
+ try container.encode("ok", forKey: .type)
+ case let .error(message):
+ try container.encode("error", forKey: .type)
+ try container.encode(message, forKey: .message)
+ case let .invalid(type):
+ try container.encode(type, forKey: .type)
+ }
+ }
+}
+
+enum CallMediaType: String, Codable {
+ case video = "video"
+ case audio = "audio"
+}
+
+struct CallCapabilities: Codable {
+ var encryption: Bool
+}
+
+struct ConnectionState: Codable {
+ var connectionState: String
+ var iceConnectionState: String
+ var iceGatheringState: String
+ var signalingState: String
+}
diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
index c2d329d102..3e5bdf0d5b 100644
--- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift
+++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift
@@ -143,7 +143,12 @@ struct SettingsView: View {
notificationsToggle(token)
}
}
- Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
+ NavigationLink {
+ CallView()
+ .frame(maxHeight: .infinity, alignment: .top)
+ } label: {
+ Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
+ }
}
}
.navigationTitle("Your settings")
diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj
index 24c3cc472c..db4127fcf2 100644
--- a/apps/ios/SimpleX.xcodeproj/project.pbxproj
+++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
+ 3C714777281C081000CB4D4B /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* CallView.swift */; };
+ 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; };
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
@@ -43,6 +45,7 @@
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; };
+ 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; };
5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; };
5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; };
5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; };
@@ -130,6 +133,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 3C714776281C081000CB4D4B /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; };
+ 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; };
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; };
@@ -168,6 +173,7 @@
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; };
+ 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; };
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; };
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; };
5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = ""; };
@@ -257,9 +263,19 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
+ 3C714775281C080100CB4D4B /* Call */ = {
+ isa = PBXGroup;
+ children = (
+ 3C714776281C081000CB4D4B /* CallView.swift */,
+ 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */,
+ );
+ path = Call;
+ sourceTree = "";
+ };
5C2E260D27A30E2400F70299 /* Views */ = {
isa = PBXGroup;
children = (
+ 3C714775281C080100CB4D4B /* Call */,
5C971E1F27AEBF7000C8A3CE /* Helpers */,
5C5F4AC227A5E9AF00B51EF1 /* Chat */,
5CB9250B27A942F300ACCCDD /* ChatList */,
@@ -341,6 +357,7 @@
5CA059BD279559F40002BEB4 = {
isa = PBXGroup;
children = (
+ 3C714779281C0F6800CB4D4B /* www */,
5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */,
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
@@ -589,6 +606,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 3C71477A281C0F6800CB4D4B /* www in Resources */,
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */,
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */,
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */,
@@ -639,6 +657,7 @@
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */,
+ 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */,
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */,
5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */,
@@ -670,6 +689,7 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */,
+ 3C714777281C081000CB4D4B /* CallView.swift in Sources */,
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
@@ -867,8 +887,9 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
- INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users";
- INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Access to Photo Library is needed for saving captured and received media";
+ INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
+ INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -909,8 +930,9 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
- INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other app users";
- INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Access to Photo Library is needed for saving captured and received media";
+ INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
+ INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -983,7 +1005,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 39;
+ CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1004,7 +1026,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
- MARKETING_VERSION = 1.6;
+ MARKETING_VERSION = 1.7;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1023,7 +1045,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 39;
+ CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1044,7 +1066,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
- MARKETING_VERSION = 1.6;
+ MARKETING_VERSION = 1.7;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
diff --git a/docs/rfcs/webrtc.mmd b/docs/rfcs/webrtc.mmd
new file mode 100644
index 0000000000..4da2841e33
--- /dev/null
+++ b/docs/rfcs/webrtc.mmd
@@ -0,0 +1,67 @@
+sequenceDiagram
+ participant AW as Alice's
web view
+ participant AN as Alice's
app native
+ participant AC as Alice's
chat core
+ participant BC as Bob's
chat core
+ participant BN as Bob's
app native
+ participant BW as Bob's
web view
+
+ note over AW, AC: Alice's app
+ note over BC, BW: Bob's app
+
+ note over AW, BW: 1. Establishing call
+
+ note over AN: user: start call
+ AN ->> AW: WCCapabilities
+ AW ->> AN: WRCapabilities (e2e?)
+
+ AN ->> AC: APISendCallInvitation
+ AC -->> BC: XCallInv
+ AC ->> AN: CRCmdOk
+
+ BC ->> BN: CRCallInvitation
+ note over BN: show: accept call?
+
+ alt user accepted?
+ BN ->> BC: no: APIRejectCall
(sender not notified)
+ BC ->> BN: CRCmdOk
+ else
+ BN ->> BW: yes: WCStartCall
+ BW ->> BN: WCallOffer
+ end
+
+ BN ->> BC: APISendCallOffer
+ BC -->> AC: XCallOffer
+ BC ->> BN: CRCmdOk
+ AC ->> AN: CRCallOffer
+ note over AN: show if no e2e: continue call?
+
+ AN ->> AW: WCallOffer
+ AW ->> AN: WCallAnswer
+ AN ->> AC: APISendCallAnswer
+ AC -->> BC: XCallAnswer
+ AC ->> AN: CRCmdOk
+ BC ->> BN: CRCallAnswer
+ BN ->> BW: WCallAnswer
+
+ note over AW, BW: call can be established at this point
+
+ note over AW, BW: 2. Sending additional ice candidates
(optional, same for another side):
+
+ BW ->> BN: WCallICE
+ BN ->> BC: APISendCallExtraInfo
+ BC -->> AC: XCallExtra
+ BC ->> BN: CRCmdOk
+ AC ->> AN: CRCallExtraInfo
+ AN ->> AW: WCallICE
+
+ note over AW, BW: 3. Call termination (same for another party):
+
+ note over AN: user: end call
+ AN ->> AW: WEndCall
+ AN ->> AC: APIEndCall
+ AC -->> BC: XCallEnd
+ AC ->> AN: CRCmdOk
+ BC ->> BN: CRCallEnded
+ note over BN: show: call ended
+ BN ->> BW: WEndCall
diff --git a/packages/simplex-chat-webrtc/.gitignore b/packages/simplex-chat-webrtc/.gitignore
new file mode 100644
index 0000000000..87807d97c7
--- /dev/null
+++ b/packages/simplex-chat-webrtc/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+package-lock.json
+dist
diff --git a/packages/simplex-chat-webrtc/.prettierignore b/packages/simplex-chat-webrtc/.prettierignore
new file mode 100644
index 0000000000..1521c8b765
--- /dev/null
+++ b/packages/simplex-chat-webrtc/.prettierignore
@@ -0,0 +1 @@
+dist
diff --git a/packages/simplex-chat-webrtc/.prettierrc.json b/packages/simplex-chat-webrtc/.prettierrc.json
new file mode 100644
index 0000000000..cadbab25e3
--- /dev/null
+++ b/packages/simplex-chat-webrtc/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "bracketSpacing": false,
+ "semi": false,
+ "printWidth": 140
+}
diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md
new file mode 100644
index 0000000000..b8db78dbb1
--- /dev/null
+++ b/packages/simplex-chat-webrtc/README.md
@@ -0,0 +1,6 @@
+# WebView for WebRTC calls in SimpleX Chat
+
+```
+npm i
+npm run build
+```
diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy
new file mode 100755
index 0000000000..9175480911
--- /dev/null
+++ b/packages/simplex-chat-webrtc/copy
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+# it can be tested in the browser from dist folder
+cp ./src/call.html ./dist/call.html
+cp ./src/style.css ./dist/style.css
+
+# copy to android app
+cp ./src/call.html ../../apps/android/app/src/main/assets/www/call.html
+cp ./src/style.css ../../apps/android/app/src/main/assets/www/style.css
+cp ./dist/call.js ../../apps/android/app/src/main/assets/www/call.js
diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json
new file mode 100644
index 0000000000..222c0c0a56
--- /dev/null
+++ b/packages/simplex-chat-webrtc/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "simplex-chat-webrtc",
+ "version": "0.0.1",
+ "description": "WebRTC call in browser and webview",
+ "main": "dist/call.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build": "prettier --write --ignore-unknown . && tsc && ./copy"
+ },
+ "keywords": [
+ "SimpleX",
+ "WebRTC"
+ ],
+ "author": "",
+ "license": "AGPL-3.0-or-later",
+ "devDependencies": {
+ "husky": "^7.0.4",
+ "lint-staged": "^12.4.1",
+ "prettier": "^2.6.2",
+ "typescript": "^4.6.4"
+ },
+ "lint-staged": {
+ "**/*": "prettier --write --ignore-unknown"
+ }
+}
diff --git a/packages/simplex-chat-webrtc/src/call.html b/packages/simplex-chat-webrtc/src/call.html
new file mode 100644
index 0000000000..ebe63e1a3b
--- /dev/null
+++ b/packages/simplex-chat-webrtc/src/call.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts
new file mode 100644
index 0000000000..9dee8a612b
--- /dev/null
+++ b/packages/simplex-chat-webrtc/src/call.ts
@@ -0,0 +1,604 @@
+// Inspired by
+// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
+
+// type WCallMessage = WCallCommand | WCallResponse
+
+interface WebViewAPICall {
+ corrId: number
+ command: WCallCommand
+}
+
+// TODO remove WCallCommand from resp type
+interface WebViewMessage {
+ corrId?: number
+ resp: WCallResponse | WCallCommand
+}
+
+type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCallAnswer | WCallIceCandidates | WCEndCall
+
+type WCallResponse = WRCapabilities | WCallOffer | WCallAnswer | WCallIceCandidates | WRConnection | WRCallEnded | WROk | WRError
+
+type WCallCommandTag = "capabilities" | "start" | "accept" | "answer" | "ice" | "end"
+
+type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "ended" | "ok" | "error"
+
+enum CallMediaType {
+ Audio = "audio",
+ Video = "video",
+}
+
+interface IWCallCommand {
+ type: WCallCommandTag
+}
+
+interface IWCallResponse {
+ type: WCallResponseTag
+}
+
+interface WCCapabilities extends IWCallCommand {
+ type: "capabilities"
+}
+
+interface WCStartCall extends IWCallCommand {
+ type: "start"
+ media: CallMediaType
+ aesKey?: string
+}
+
+interface WCEndCall extends IWCallCommand {
+ type: "end"
+}
+
+interface WCAcceptOffer extends IWCallCommand {
+ type: "accept"
+ offer: string // JSON string for RTCSessionDescriptionInit
+ iceCandidates: string[] // JSON strings for RTCIceCandidateInit
+ media: CallMediaType
+ aesKey?: string
+}
+
+interface WCallOffer extends IWCallResponse {
+ type: "offer"
+ offer: string // JSON string for RTCSessionDescriptionInit
+ iceCandidates: string[] // JSON strings for RTCIceCandidateInit
+}
+
+interface WCallAnswer extends IWCallCommand, IWCallResponse {
+ type: "answer"
+ answer: string // JSON string for RTCSessionDescriptionInit
+ iceCandidates: string[] // JSON strings for RTCIceCandidateInit
+}
+
+interface WCallIceCandidates extends IWCallCommand, IWCallResponse {
+ type: "ice"
+ iceCandidates: string[] // JSON strings for RTCIceCandidateInit
+}
+
+interface WRCapabilities extends IWCallResponse {
+ type: "capabilities"
+ capabilities: CallCapabilities
+}
+
+interface CallCapabilities {
+ encryption: boolean
+}
+
+interface WRConnection extends IWCallResponse {
+ type: "connection"
+ state: {
+ connectionState: string
+ iceConnectionState: string
+ iceGatheringState: string
+ signalingState: string
+ }
+}
+
+interface WRCallEnded extends IWCallResponse {
+ type: "ended"
+}
+
+interface WROk extends IWCallResponse {
+ type: "ok"
+}
+
+interface WRError extends IWCallResponse {
+ type: "error"
+ message: string
+}
+
+type RTCRtpSenderWithEncryption = RTCRtpSender & {
+ createEncodedStreams: () => TransformStream
+}
+
+type RTCRtpReceiverWithEncryption = RTCRtpReceiver & {
+ createEncodedStreams: () => TransformStream
+}
+
+type RTCConfigurationWithEncryption = RTCConfiguration & {
+ encodedInsertableStreams: boolean
+}
+
+const keyAlgorithm: AesKeyAlgorithm = {
+ name: "AES-GCM",
+ length: 256,
+}
+
+const keyUsages: KeyUsage[] = ["encrypt", "decrypt"]
+
+let pc: RTCPeerConnection | undefined
+
+const IV_LENGTH = 12
+
+const initialPlainTextRequired = {
+ key: 10,
+ delta: 3,
+ undefined: 1,
+}
+
+interface Call {
+ connection: RTCPeerConnection
+ iceCandidates: Promise // JSON strings for RTCIceCandidate
+}
+
+interface CallConfig {
+ peerConnectionConfig: RTCConfigurationWithEncryption
+ iceCandidates: {
+ delay: number
+ extrasInterval: number
+ extrasTimeout: number
+ }
+}
+
+function defaultCallConfig(encodedInsertableStreams: boolean): CallConfig {
+ return {
+ peerConnectionConfig: {
+ iceServers: [{urls: ["stun:stun.l.google.com:19302"]}],
+ iceCandidatePoolSize: 10,
+ encodedInsertableStreams,
+ },
+ iceCandidates: {
+ delay: 2000,
+ extrasInterval: 2000,
+ extrasTimeout: 8000,
+ },
+ }
+}
+
+async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise {
+ const conn = new RTCPeerConnection(config.peerConnectionConfig)
+ const remoteStream = new MediaStream()
+ const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType))
+ await setUpMediaStreams(conn, localStream, remoteStream, aesKey)
+ conn.addEventListener("connectionstatechange", connectionStateChange)
+ const iceCandidates = new Promise((resolve, _) => {
+ let candidates: RTCIceCandidate[] = []
+ let resolved = false
+ let extrasInterval: number | undefined
+ let extrasTimeout: number | undefined
+ const delay = setTimeout(() => {
+ if (!resolved) {
+ resolveIceCandidates()
+ extrasInterval = setInterval(() => {
+ sendIceCandidates()
+ }, config.iceCandidates.extrasInterval)
+ extrasTimeout = setTimeout(() => {
+ clearInterval(extrasInterval)
+ sendIceCandidates()
+ }, config.iceCandidates.extrasTimeout)
+ }
+ }, config.iceCandidates.delay)
+
+ conn.onicecandidate = ({candidate: c}) => c && candidates.push(c)
+ conn.onicegatheringstatechange = () => {
+ if (conn.iceGatheringState == "complete") {
+ if (resolved) {
+ if (extrasInterval) clearInterval(extrasInterval)
+ if (extrasTimeout) clearTimeout(extrasTimeout)
+ sendIceCandidates()
+ } else {
+ resolveIceCandidates()
+ }
+ }
+ }
+
+ function resolveIceCandidates() {
+ if (delay) clearTimeout(delay)
+ resolved = true
+ const iceCandidates = candidates.map((c) => JSON.stringify(c))
+ candidates = []
+ resolve(iceCandidates)
+ }
+
+ function sendIceCandidates() {
+ if (candidates.length === 0) return
+ const iceCandidates = candidates.map((c) => JSON.stringify(c))
+ candidates = []
+ sendMessageToNative({resp: {type: "ice", iceCandidates}})
+ }
+ })
+
+ return {connection: conn, iceCandidates}
+
+ function connectionStateChange() {
+ sendMessageToNative({
+ resp: {
+ type: "connection",
+ state: {
+ connectionState: conn.connectionState,
+ iceConnectionState: conn.iceConnectionState,
+ iceGatheringState: conn.iceGatheringState,
+ signalingState: conn.signalingState,
+ },
+ },
+ })
+ if (conn.connectionState == "disconnected" || conn.connectionState == "failed") {
+ conn.removeEventListener("connectionstatechange", connectionStateChange)
+ sendMessageToNative({resp: {type: "ended"}})
+ conn.close()
+ pc = undefined
+ resetVideoElements()
+ }
+ }
+}
+
+function sendMessageToNative(msg: WebViewMessage) {
+ console.log(JSON.stringify(msg))
+}
+
+// TODO remove WCallCommand from result type
+async function processCommand(body: WebViewAPICall): Promise {
+ const {command, corrId} = body
+ let resp: WCallResponse | WCallCommand
+ try {
+ switch (command.type) {
+ case "capabilities":
+ const encryption = supportsInsertableStreams()
+ resp = {type: "capabilities", capabilities: {encryption}}
+ break
+ case "start":
+ console.log("starting call")
+ if (pc) {
+ resp = {type: "error", message: "start: call already started"}
+ } else if (!supportsInsertableStreams() && command.aesKey) {
+ resp = {type: "error", message: "start: encryption is not supported"}
+ } else {
+ const {media, aesKey} = command
+ const call = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey)
+ const {connection, iceCandidates} = call
+ pc = connection
+ const offer = await pc.createOffer()
+ await pc.setLocalDescription(offer)
+ // for debugging, returning the command for callee to use
+ resp = {type: "accept", offer: JSON.stringify(offer), iceCandidates: await iceCandidates, media, aesKey}
+ // resp = {type: "offer", offer, iceCandidates: await iceCandidates}
+ }
+ break
+ case "accept":
+ if (pc) {
+ resp = {type: "error", message: "accept: call already started"}
+ } else if (!supportsInsertableStreams() && command.aesKey) {
+ resp = {type: "error", message: "accept: encryption is not supported"}
+ } else {
+ const offer = JSON.parse(command.offer)
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c))
+ const call = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey)
+ const {connection, iceCandidates} = call
+ pc = connection
+ await pc.setRemoteDescription(new RTCSessionDescription(offer))
+ const answer = await pc.createAnswer()
+ await pc.setLocalDescription(answer)
+ addIceCandidates(pc, remoteIceCandidates)
+ // same as command for caller to use
+ resp = {type: "answer", answer: JSON.stringify(answer), iceCandidates: await iceCandidates}
+ }
+ break
+ case "answer":
+ if (!pc) {
+ resp = {type: "error", message: "answer: call not started"}
+ } else if (!pc.localDescription) {
+ resp = {type: "error", message: "answer: local description is not set"}
+ } else if (pc.currentRemoteDescription) {
+ resp = {type: "error", message: "answer: remote description already set"}
+ } else {
+ const answer = JSON.parse(command.answer)
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c))
+ await pc.setRemoteDescription(new RTCSessionDescription(answer))
+ addIceCandidates(pc, remoteIceCandidates)
+ resp = {type: "ok"}
+ }
+ break
+ case "ice":
+ if (pc) {
+ const remoteIceCandidates = command.iceCandidates.map((c) => JSON.parse(c))
+ addIceCandidates(pc, remoteIceCandidates)
+ resp = {type: "ok"}
+ } else {
+ resp = {type: "error", message: "ice: call not started"}
+ }
+ break
+ case "end":
+ if (pc) {
+ pc.close()
+ pc = undefined
+ resetVideoElements()
+ resp = {type: "ok"}
+ } else {
+ resp = {type: "error", message: "end: call not started"}
+ }
+ break
+ default:
+ resp = {type: "error", message: "unknown command"}
+ break
+ }
+ } catch (e) {
+ resp = {type: "error", message: (e as Error).message}
+ }
+ const apiResp = {resp, corrId}
+ sendMessageToNative(apiResp)
+ return apiResp
+}
+
+function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) {
+ for (const c of iceCandidates) {
+ conn.addIceCandidate(new RTCIceCandidate(c))
+ }
+}
+
+async function setUpMediaStreams(
+ pc: RTCPeerConnection,
+ localStream: MediaStream,
+ remoteStream: MediaStream,
+ aesKey?: string
+): Promise {
+ const videos = getVideoElements()
+ if (!videos) throw Error("no video elements")
+
+ let key: CryptoKey | undefined
+ if (aesKey) {
+ const keyData = decodeBase64(encodeAscii(aesKey))
+ if (keyData) key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages)
+ }
+ for (const track of localStream.getTracks()) {
+ pc.addTrack(track, localStream)
+ }
+ if (key) {
+ console.log("set up encryption for sending")
+ for (const sender of pc.getSenders() as RTCRtpSenderWithEncryption[]) {
+ setupPeerTransform(sender, encodeFunction(key))
+ }
+ }
+ // Pull tracks from remote stream as they arrive add them to remoteStream video
+ pc.ontrack = (event) => {
+ if (key) {
+ console.log("set up decryption for receiving")
+ setupPeerTransform(event.receiver as RTCRtpReceiverWithEncryption, decodeFunction(key))
+ }
+ for (const track of event.streams[0].getTracks()) {
+ remoteStream.addTrack(track)
+ }
+ }
+ // We assume VP8 encoding in the decode/encode stages to get the initial
+ // bytes to pass as plaintext so we enforce that here.
+ // VP8 is supported by all supports of webrtc.
+ // Use of VP8 by default may also reduce depacketisation issues.
+ // We do not encrypt the first couple of bytes of the payload so that the
+ // video elements can work by determining video keyframes and the opus mode
+ // being used. This appears to be necessary for any video feed at all.
+ // For VP8 this is the content described in
+ // https://tools.ietf.org/html/rfc6386#section-9.1
+ // which is 10 bytes for key frames and 3 bytes for delta frames.
+ // For opus (where encodedFrame.type is not set) this is the TOC byte from
+ // https://tools.ietf.org/html/rfc6716#section-3.1
+
+ const capabilities = RTCRtpSender.getCapabilities("video")
+ if (capabilities) {
+ const {codecs} = capabilities
+ const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8")
+ const selectedCodec = codecs[selectedCodecIndex]
+ codecs.splice(selectedCodecIndex, 1)
+ codecs.unshift(selectedCodec)
+ for (const t of pc.getTransceivers()) {
+ if (t.sender.track?.kind === "video") {
+ t.setCodecPreferences(codecs)
+ }
+ }
+ }
+ // setupVideoElement(videos.local)
+ // setupVideoElement(videos.remote)
+ videos.local.srcObject = localStream
+ videos.remote.srcObject = remoteStream
+}
+
+function callMediaConstraints(mediaType: CallMediaType): MediaStreamConstraints {
+ switch (mediaType) {
+ case CallMediaType.Audio:
+ return {audio: true, video: false}
+ case CallMediaType.Video:
+ return {
+ audio: true,
+ video: {
+ frameRate: 24,
+ width: {
+ min: 480,
+ ideal: 720,
+ max: 1280,
+ },
+ aspectRatio: 1.33,
+ },
+ }
+ }
+}
+
+function supportsInsertableStreams(): boolean {
+ return "createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype
+}
+
+interface VideoElements {
+ local: HTMLMediaElement
+ remote: HTMLMediaElement
+}
+
+function resetVideoElements() {
+ const videos = getVideoElements()
+ if (!videos) return
+ videos.local.srcObject = null
+ videos.remote.srcObject = null
+}
+
+function getVideoElements(): VideoElements | undefined {
+ const local = document.getElementById("local-video-stream")
+ const remote = document.getElementById("remote-video-stream")
+ if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement)) return
+ return {local, remote}
+}
+
+// function setupVideoElement(video: HTMLElement) {
+// // TODO use display: none
+// video.style.opacity = "0"
+// video.onplaying = () => {
+// video.style.opacity = "1"
+// }
+// }
+
+// what does it do?
+// function toggleVideo(b) {
+// if (b == "true") {
+// localStream.getVideoTracks()[0].enabled = true
+// } else {
+// localStream.getVideoTracks()[0].enabled = false
+// }
+// }
+
+/* Stream Transforms */
+function setupPeerTransform(
+ peer: RTCRtpSenderWithEncryption | RTCRtpReceiverWithEncryption,
+ transform: (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void
+) {
+ const streams = peer.createEncodedStreams()
+ streams.readable.pipeThrough(new TransformStream({transform})).pipeTo(streams.writable)
+}
+
+/* Cryptography */
+function encodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void {
+ return async (frame, controller) => {
+ const data = new Uint8Array(frame.data)
+ const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0
+ const iv = randomIV()
+ const initial = data.subarray(0, n)
+ const plaintext = data.subarray(n, data.byteLength)
+ try {
+ const ciphertext = await crypto.subtle.encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext)
+ frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer
+ controller.enqueue(frame)
+ } catch (e) {
+ console.log(`encryption error ${e}`)
+ throw e
+ }
+ }
+}
+
+function decodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise {
+ return async (frame, controller) => {
+ const data = new Uint8Array(frame.data)
+ const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0
+ const initial = data.subarray(0, n)
+ const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH)
+ const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength)
+ try {
+ const plaintext = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ciphertext)
+ frame.data = concatN(initial, new Uint8Array(plaintext)).buffer
+ controller.enqueue(frame)
+ } catch (e) {
+ console.log(`decryption error ${e}`)
+ throw e
+ }
+ }
+}
+
+class RTCEncodedVideoFrame {
+ constructor(public type: "key" | "delta", public data: ArrayBuffer) {}
+}
+
+function randomIV() {
+ return crypto.getRandomValues(new Uint8Array(IV_LENGTH))
+}
+
+const char_equal = "=".charCodeAt(0)
+
+function concatN(...bs: Uint8Array[]): Uint8Array {
+ const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0))
+ bs.reduce((offset, b: Uint8Array) => {
+ a.set(b, offset)
+ return offset + b.byteLength
+ }, 0)
+ return a
+}
+
+function encodeAscii(s: string): Uint8Array {
+ const a = new Uint8Array(s.length)
+ let i = s.length
+ while (i--) a[i] = s.charCodeAt(i)
+ return a
+}
+
+function decodeAscii(a: Uint8Array): string {
+ let s = ""
+ for (let i = 0; i < a.length; i++) s += String.fromCharCode(a[i])
+ return s
+}
+
+const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)))
+
+const base64lookup = new Array(256) as (number | undefined)[]
+base64chars.forEach((c, i) => (base64lookup[c] = i))
+
+function encodeBase64(a: Uint8Array): Uint8Array {
+ const len = a.length
+ const b64len = Math.ceil(len / 3) * 4
+ const b64 = new Uint8Array(b64len)
+
+ let j = 0
+ for (let i = 0; i < len; i += 3) {
+ b64[j++] = base64chars[a[i] >> 2]
+ b64[j++] = base64chars[((a[i] & 3) << 4) | (a[i + 1] >> 4)]
+ b64[j++] = base64chars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)]
+ b64[j++] = base64chars[a[i + 2] & 63]
+ }
+
+ if (len % 3) b64[b64len - 1] = char_equal
+ if (len % 3 === 1) b64[b64len - 2] = char_equal
+
+ return b64
+}
+
+function decodeBase64(b64: Uint8Array): Uint8Array | undefined {
+ let len = b64.length
+ if (len % 4) return
+ let bLen = (len * 3) / 4
+
+ if (b64[len - 1] === char_equal) {
+ len--
+ bLen--
+ if (b64[len - 1] === char_equal) {
+ len--
+ bLen--
+ }
+ }
+
+ const bytes = new Uint8Array(bLen)
+
+ let i = 0
+ let pos = 0
+ while (i < len) {
+ const enc1 = base64lookup[b64[i++]]
+ const enc2 = i < len ? base64lookup[b64[i++]] : 0
+ const enc3 = i < len ? base64lookup[b64[i++]] : 0
+ const enc4 = i < len ? base64lookup[b64[i++]] : 0
+ if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) return
+ bytes[pos++] = (enc1 << 2) | (enc2 >> 4)
+ bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2)
+ bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63)
+ }
+
+ return bytes
+}
diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css
new file mode 100644
index 0000000000..624e96dfe7
--- /dev/null
+++ b/packages/simplex-chat-webrtc/src/style.css
@@ -0,0 +1,25 @@
+video::-webkit-media-controls {
+ display: none;
+}
+html,
+body {
+ padding: 0;
+ margin: 0;
+}
+#remote-video-stream {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+#local-video-stream {
+ position: absolute;
+ width: 30%;
+ max-width: 30%;
+ object-fit: cover;
+ margin: 16px;
+ border-radius: 16px;
+ bottom: 0;
+ right: 0;
+}
diff --git a/packages/simplex-chat-webrtc/tsconfig.json b/packages/simplex-chat-webrtc/tsconfig.json
new file mode 100644
index 0000000000..d4d0acb3d0
--- /dev/null
+++ b/packages/simplex-chat-webrtc/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "declaration": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2018", "DOM"],
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitAny": true,
+ "noImplicitReturns": true,
+ "noImplicitThis": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "outDir": "dist",
+ "sourceMap": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "target": "ES2018"
+ }
+}
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 92baa1eb24..87d37efeff 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -21,6 +21,7 @@ library
exposed-modules:
Simplex.Chat
Simplex.Chat.Bot
+ Simplex.Chat.Call
Simplex.Chat.Controller
Simplex.Chat.Core
Simplex.Chat.Help
diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs
index 49963a22de..c9b5e843bf 100644
--- a/src/Simplex/Chat.hs
+++ b/src/Simplex/Chat.hs
@@ -27,6 +27,7 @@ import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace)
+import Data.Fixed (div')
import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (find)
@@ -37,9 +38,10 @@ import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
-import Data.Time.Clock (UTCTime, getCurrentTime)
+import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds)
import Data.Time.LocalTime (getCurrentTimeZone, getZonedTime)
import Data.Word (Word32)
+import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
@@ -59,6 +61,7 @@ import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), PushProvider
import Simplex.Messaging.Parsers (base64P, parseAll)
import Simplex.Messaging.Protocol (ErrorType (..), MsgBody)
import qualified Simplex.Messaging.Protocol as SMP
+import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Util (tryError, (<$?>))
import System.Exit (exitFailure, exitSuccess)
import System.FilePath (combine, splitExtensions, takeFileName)
@@ -123,8 +126,9 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch
chatLock <- newTMVarIO ()
sndFiles <- newTVarIO M.empty
rcvFiles <- newTVarIO M.empty
+ currentCalls <- atomically TM.empty
filesFolder <- newTVarIO Nothing
- pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification, filesFolder}
+ pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder}
where
resolveServers :: IO (NonEmpty SMPServer)
resolveServers = case user of
@@ -288,7 +292,7 @@ processChatCommand = \case
case (ciContent, itemSharedMsgId) of
(CISndMsgContent _, Just itemSharedMId) -> do
SndMessage {msgId} <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc)
- updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) msgId
+ updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) $ Just msgId
setActive $ ActiveC c
pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi
_ -> throwChatError CEInvalidChatItemUpdate
@@ -388,6 +392,69 @@ 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
+ -- 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
+ let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair}
+ callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair}
+ msg@SndMessage {msgId} <- sendDirectContactMessage ct (XCallInv callId invitation)
+ ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndCall CISCallPending 0) Nothing Nothing
+ let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState}
+ call_ <- atomically $ TM.lookupInsert contactId call' calls
+ forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
+ toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
+ pure CRCmdOk
+ APIRejectCall contactId ->
+ -- party accepting call
+ withCurrentCall contactId $ \userId ct Call {chatItemId, callState} -> case callState of
+ CallInvitationReceived {} ->
+ let aciContent = ACIContent SMDRcv $ CIRcvCall CISCallRejected 0
+ in updateDirectChatItemView userId ct chatItemId aciContent Nothing $> Nothing
+ _ -> throwChatError . CECallState $ callStateTag callState
+ APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} ->
+ -- 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}
+ callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey}
+ aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0
+ SndMessage {msgId} <- sendDirectContactMessage ct (XCallOffer callId offer)
+ updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId
+ pure $ Just call {callState = callState'}
+ _ -> throwChatError . CECallState $ callStateTag callState
+ APISendCallAnswer contactId rtcSession ->
+ -- party initiating call
+ withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of
+ CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do
+ let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey}
+ aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0
+ SndMessage {msgId} <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession})
+ updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId
+ pure $ Just call {callState = callState'}
+ _ -> throwChatError . CECallState $ callStateTag callState
+ APISendCallExtraInfo contactId rtcExtraInfo ->
+ -- any call party
+ withCurrentCall contactId $ \_ ct call@Call {callId, callState} -> case callState of
+ CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do
+ -- TODO update the list of ice servers
+ _ <- sendDirectContactMessage ct (XCallExtra callId CallExtraInfo {rtcExtraInfo})
+ let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey}
+ pure $ Just call {callState = callState'}
+ _ -> throwChatError . CECallState $ callStateTag callState
+ APIEndCall contactId ->
+ -- any call party
+ withCurrentCall contactId $ \userId ct call@Call {callId} -> do
+ SndMessage {msgId} <- sendDirectContactMessage ct (XCallEnd callId)
+ updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
+ pure Nothing
+ APICallStatus contactId receivedStatus ->
+ withCurrentCall contactId $ \userId ct call ->
+ updateCallItemStatus userId ct call receivedStatus Nothing $> Just call
APIUpdateProfile profile -> withUser (`updateProfile` profile)
APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text
APIRegisterToken token -> CRNtfTokenStatus <$> withUser (\_ -> withAgent (`registerNtfToken` token))
@@ -686,6 +753,58 @@ processChatCommand = \case
withStore $ \st -> do
updateFileCancelled st userId fileId
updateCIFileStatus st userId fileId ciFileStatus
+ withCurrentCall :: ContactId -> (UserId -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse
+ withCurrentCall ctId action = withUser $ \User {userId} -> do
+ ct <- withStore $ \st -> getContact st userId ctId
+ calls <- asks currentCalls
+ withChatLock $
+ atomically (TM.lookup ctId calls) >>= \case
+ Nothing -> throwChatError CENoCurrentCall
+ Just call@Call {contactId}
+ | ctId == contactId -> do
+ call_ <- action userId ct call
+ atomically $ case call_ of
+ Just call' -> TM.insert ctId call' calls
+ _ -> TM.delete ctId calls
+ pure CRCmdOk
+ | otherwise -> throwChatError $ CECallContact contactId
+
+updateCallItemStatus :: ChatMonad m => UserId -> Contact -> Call -> WebRTCCallStatus -> Maybe MessageId -> m ()
+updateCallItemStatus userId ct Call {chatItemId} receivedStatus msgId_ = do
+ aciContent_ <- callStatusItemContent userId ct chatItemId receivedStatus
+ forM_ aciContent_ $ \aciContent -> updateDirectChatItemView userId ct chatItemId aciContent msgId_
+
+updateDirectChatItemView :: ChatMonad m => UserId -> Contact -> ChatItemId -> ACIContent -> Maybe MessageId -> m ()
+updateDirectChatItemView userId ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) msgId_ = do
+ updCi <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId ciContent msgId_
+ toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) updCi
+
+callStatusItemContent :: ChatMonad m => UserId -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent)
+callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do
+ CChatItem msgDir ChatItem {meta = CIMeta {updatedAt}, content} <-
+ withStore $ \st -> getDirectChatItem st userId contactId chatItemId
+ ts <- liftIO getCurrentTime
+ let callDuration :: Int = nominalDiffTimeToSeconds (ts `diffUTCTime` updatedAt) `div'` 1
+ callStatus = case content of
+ CISndCall st _ -> Just st
+ CIRcvCall st _ -> Just st
+ _ -> Nothing
+ newState_ = case (callStatus, receivedStatus) of
+ (Just CISCallProgress, WCSConnected) -> Nothing -- if call in-progress received connected -> no change
+ (Just CISCallProgress, WCSDisconnected) -> Just (CISCallEnded, callDuration) -- calculate in-progress duration
+ (Just CISCallProgress, WCSFailed) -> Just (CISCallEnded, callDuration) -- whether call disconnected or failed
+ (Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change
+ (Just CISCallError, _) -> Nothing
+ (Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0
+ (Just _, WCSDisconnected) -> Just (CISCallEnded, 0)
+ (Just _, WCSFailed) -> Just (CISCallError, 0)
+ (Nothing, _) -> Nothing -- some other content - we should never get here, but no exception is thrown
+ pure $ aciContent msgDir <$> newState_
+ where
+ aciContent :: forall d. SMsgDirection d -> (CICallStatus, Int) -> ACIContent
+ aciContent msgDir (callStatus', duration) = case msgDir of
+ SMDSnd -> ACIContent SMDSnd $ CISndCall callStatus' duration
+ SMDRcv -> ACIContent SMDRcv $ CIRcvCall callStatus' duration
-- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates),
-- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path
@@ -940,6 +1059,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
XInfoProbe probe -> xInfoProbe ct probe
XInfoProbeCheck probeHash -> xInfoProbeCheck ct probeHash
XInfoProbeOk probe -> xInfoProbeOk ct probe
+ XCallInv callId invitation -> xCallInv ct callId invitation msg msgMeta
+ XCallOffer callId offer -> xCallOffer ct callId offer msg msgMeta
+ XCallAnswer callId answer -> xCallAnswer ct callId answer msg msgMeta
+ XCallExtra callId extraInfo -> xCallExtra ct callId extraInfo msg msgMeta
+ XCallEnd callId -> xCallEnd ct callId msg msgMeta
_ -> pure ()
ackMsgDeliveryEvent conn msgMeta
CONF confId connInfo -> do
@@ -975,12 +1099,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
SENT msgId -> do
sentMsgDeliveryEvent conn msgId
- chatItemId_ <- withStore $ \st -> getChatItemIdByAgentMsgId st connId msgId
- case chatItemId_ of
- Nothing -> pure ()
- Just chatItemId -> do
- chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId CISSndSent
+ withStore (\st -> getDirectChatItemByAgentMsgId st userId contactId connId msgId) >>= \case
+ Just (CChatItem SMDSnd ci) -> do
+ chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId (chatItemId' ci) CISSndSent
toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
+ _ -> pure ()
END -> do
toView $ CRContactAnotherClient ct
showToast (c <> "> ") "connected to another client"
@@ -1253,12 +1376,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc
ciFile_ <- processFileInvitation fileInvitation_ $
\fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize
ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
showMsgToast (c <> "> ") content formattedText
setActive $ ActiveC c
@@ -1273,28 +1396,22 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m ()
messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
case msgDir of
- SMDRcv -> do
- updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CIRcvMsgContent mc) msgId
- toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
- SMDSnd -> do
- messageError "x.msg.update: contact attempted invalid message update"
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ SMDRcv -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId
+ SMDSnd -> messageError "x.msg.update: contact attempted invalid message update"
messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m ()
messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId
case msgDir of
SMDRcv -> do
-- TODO allow to locally delete items that were broadcast deleted by sender
toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId
toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
- SMDSnd -> do
- messageError "x.msg.del: contact attempted invalid message delete"
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete"
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do
@@ -1334,13 +1451,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
-- TODO remove once XFile is discontinued
processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
-- TODO chunk size has to be sent as part of invitation
chSize <- asks $ fileChunkSize . config
RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize
let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation}
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCFile "") ciFile
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
- checkIntegrity msgMeta $ toView . CRMsgIntegrityError
showToast (c <> "> ") "wants to send a file"
setActive $ ActiveC c
@@ -1394,8 +1511,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m ()
groupMsgToView gInfo ci msgMeta = do
- toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci
processGroupInvitation :: Contact -> GroupInvitation -> m ()
processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) = do
@@ -1436,6 +1553,99 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
r <- withStore $ \st -> matchSentProbe st userId c1 probe
forM_ r $ \c2 -> mergeContacts c1 c2
+ -- to party accepting call
+ 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
+ ci <- saveCallItem CISCallPending
+ let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair))
+ callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey}
+ call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState}
+ calls <- asks currentCalls
+ -- theoretically, the new call invitation for the current contant can mark the in-progress call as ended
+ -- (and replace it in ChatController)
+ -- practically, this should not happen
+ call_ <- atomically (TM.lookupInsert contactId call' calls)
+ forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected $ Just msgId
+ toView $ CRCallInvitation ct callType sharedKey
+ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
+ where
+ saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) Nothing
+
+ -- to party initiating call
+ xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> MsgMeta -> m ()
+ xCallOffer ct callId CallOffer {callType, rtcSession, callDhPubKey} msg msgMeta = do
+ msgCurrentCall ct callId "x.call.offer" msg msgMeta $
+ \call -> case callState call of
+ 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}
+ pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0)
+ _ -> do
+ msgCallStateError "x.call.offer" call
+ pure (Just call, Nothing)
+
+ -- to party accepting call
+ xCallAnswer :: Contact -> CallId -> CallAnswer -> RcvMessage -> MsgMeta -> m ()
+ xCallAnswer ct callId CallAnswer {rtcSession} msg msgMeta = do
+ msgCurrentCall ct callId "x.call.answer" msg msgMeta $
+ \call -> case callState call of
+ CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do
+ let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey}
+ toView $ CRCallAnswer ct rtcSession
+ pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0)
+ _ -> do
+ msgCallStateError "x.call.answer" call
+ pure (Just call, Nothing)
+
+ -- to any call party
+ xCallExtra :: Contact -> CallId -> CallExtraInfo -> RcvMessage -> MsgMeta -> m ()
+ xCallExtra ct callId CallExtraInfo {rtcExtraInfo} msg msgMeta = do
+ msgCurrentCall ct callId "x.call.extra" msg msgMeta $
+ \call -> case callState call of
+ CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do
+ -- TODO update the list of ice servers
+ let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey}
+ toView $ CRCallExtraInfo ct rtcExtraInfo
+ pure (Just call {callState = callState'}, Nothing)
+ _ -> do
+ msgCallStateError "x.call.extra" call
+ pure (Just call, Nothing)
+
+ -- to any call party
+ xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m ()
+ xCallEnd ct@Contact {contactId} callId msg msgMeta =
+ msgCurrentCall ct callId "x.call.end" msg msgMeta $ \Call {chatItemId} -> do
+ toView $ CRCallEnded ct
+ CChatItem msgDir _ <- withStore $ \st -> getDirectChatItem st userId contactId chatItemId
+ pure $ case msgDir of
+ SMDSnd -> (Nothing, Just . ACIContent SMDSnd $ CISndCall CISCallEnded 0)
+ SMDRcv -> (Nothing, Just . ACIContent SMDRcv $ CIRcvCall CISCallEnded 0)
+
+ msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m ()
+ msgCurrentCall ct@Contact {contactId = ctId'} callId' eventName RcvMessage {msgId} msgMeta action = do
+ checkIntegrity msgMeta $ toView . CRMsgIntegrityError
+ calls <- asks currentCalls
+ atomically (TM.lookup ctId' calls) >>= \case
+ Nothing -> messageError $ eventName <> ": no current call"
+ Just call@Call {contactId, callId, chatItemId}
+ | contactId /= ctId' || callId /= callId' -> messageError $ eventName <> ": wrong contact or callId"
+ | otherwise -> do
+ (call_, aciContent_) <- action call
+ atomically $ case call_ of
+ Just call' -> TM.insert ctId' call' calls
+ _ -> TM.delete ctId' calls
+ forM_ aciContent_ $ \aciContent ->
+ updateDirectChatItemView userId ct chatItemId aciContent $ Just msgId
+
+ msgCallStateError :: Text -> Call -> m ()
+ msgCallStateError eventName Call {callState} =
+ messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState)
+
mergeContacts :: Contact -> Contact -> m ()
mergeContacts to from = do
withStore $ \st -> mergeContactRecords st userId to from
@@ -1749,11 +1959,10 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brok
liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt
mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d)
-mkChatItem cd ciId content file quotedItem sharedMsgId itemTs createdAt = do
+mkChatItem cd ciId content file quotedItem sharedMsgId itemTs currentTs = do
tz <- getCurrentTimeZone
- currentTs <- liftIO getCurrentTime
let itemText = ciContentToText content
- meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt
+ meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs currentTs currentTs
pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file}
allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m ()
@@ -1881,6 +2090,13 @@ chatCommandP =
<|> "/_delete " *> (APIDeleteChat <$> chatRefP)
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
+ <|> "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP)
+ <|> "/_call reject @" *> (APIRejectCall <$> A.decimal)
+ <|> "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP)
+ <|> "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP)
+ <|> "/_call extra @" *> (APISendCallExtraInfo <$> A.decimal <* A.space <*> jsonP)
+ <|> "/_call end @" *> (APIEndCall <$> A.decimal)
+ <|> "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP)
<|> "/_profile " *> (APIUpdateProfile <$> jsonP)
<|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString)
<|> "/_ntf register " *> (APIRegisterToken <$> tokenP)
diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs
new file mode 100644
index 0000000000..a0f3707dd4
--- /dev/null
+++ b/src/Simplex/Chat/Call.hs
@@ -0,0 +1,211 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Simplex.Chat.Call where
+
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.Aeson as J
+import qualified Data.Attoparsec.ByteString.Char8 as A
+import Data.ByteString.Char8 (ByteString)
+import Data.Int (Int64)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import qualified Simplex.Messaging.Crypto as C
+import Simplex.Messaging.Encoding.String
+import Simplex.Messaging.Parsers (dropPrefix, enumJSON)
+
+data Call = Call
+ { contactId :: Int64,
+ callId :: CallId,
+ chatItemId :: Int64,
+ callState :: CallState
+ }
+
+data CallStateTag
+ = CSTCallInvitationSent
+ | CSTCallInvitationReceived
+ | CSTCallOfferSent
+ | CSTCallOfferReceived
+ | CSTCallNegotiated
+ deriving (Show, Generic)
+
+instance ToJSON CallStateTag where
+ toJSON = J.genericToJSON . enumJSON $ dropPrefix "CSTCall"
+ toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CSTCall"
+
+callStateTag :: CallState -> CallStateTag
+callStateTag = \case
+ CallInvitationSent {} -> CSTCallInvitationSent
+ CallInvitationReceived {} -> CSTCallInvitationReceived
+ CallOfferSent {} -> CSTCallOfferSent
+ CallOfferReceived {} -> CSTCallOfferReceived
+ CallNegotiated {} -> CSTCallNegotiated
+
+data CallState
+ = CallInvitationSent
+ { localCallType :: CallType,
+ localDhPrivKey :: Maybe C.PrivateKeyX25519
+ }
+ | CallInvitationReceived
+ { peerCallType :: CallType,
+ localDhPubKey :: Maybe C.PublicKeyX25519,
+ sharedKey :: Maybe C.Key
+ }
+ | CallOfferSent
+ { localCallType :: CallType,
+ peerCallType :: CallType,
+ localCallSession :: WebRTCSession,
+ sharedKey :: Maybe C.Key
+ }
+ | CallOfferReceived
+ { localCallType :: CallType,
+ peerCallType :: CallType,
+ peerCallSession :: WebRTCSession,
+ sharedKey :: Maybe C.Key
+ }
+ | CallNegotiated
+ { localCallType :: CallType,
+ peerCallType :: CallType,
+ localCallSession :: WebRTCSession,
+ peerCallSession :: WebRTCSession,
+ sharedKey :: Maybe C.Key
+ }
+
+newtype CallId = CallId ByteString
+ deriving (Eq, Show)
+
+instance StrEncoding CallId where
+ strEncode (CallId m) = strEncode m
+ strDecode s = CallId <$> strDecode s
+ strP = CallId <$> strP
+
+instance FromJSON CallId where
+ parseJSON = strParseJSON "CallId"
+
+instance ToJSON CallId where
+ toJSON = strToJSON
+ toEncoding = strToJEncoding
+
+data CallType = CallType
+ { media :: CallMedia,
+ capabilities :: CallCapabilities
+ }
+ deriving (Eq, Show, Generic, FromJSON)
+
+instance ToJSON CallType where toEncoding = J.genericToEncoding J.defaultOptions
+
+-- | * Types for chat protocol
+data CallInvitation = CallInvitation
+ { callType :: CallType,
+ callDhPubKey :: Maybe C.PublicKeyX25519
+ }
+ deriving (Eq, Show, Generic)
+
+instance FromJSON CallInvitation where
+ parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
+
+instance ToJSON CallInvitation where
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+
+data CallMedia = CMAudio | CMVideo
+ deriving (Eq, Show, Generic)
+
+instance FromJSON CallMedia where
+ parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CM"
+
+instance ToJSON CallMedia where
+ toJSON = J.genericToJSON . enumJSON $ dropPrefix "CM"
+ toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CM"
+
+data CallCapabilities = CallCapabilities
+ { encryption :: Bool
+ }
+ deriving (Eq, Show, Generic, FromJSON)
+
+instance ToJSON CallCapabilities where
+ toJSON = J.genericToJSON J.defaultOptions
+ toEncoding = J.genericToEncoding J.defaultOptions
+
+data CallOffer = CallOffer
+ { callType :: CallType,
+ rtcSession :: WebRTCSession,
+ callDhPubKey :: Maybe C.PublicKeyX25519
+ }
+ deriving (Eq, Show, Generic)
+
+instance FromJSON CallOffer where
+ parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
+
+instance ToJSON CallOffer where
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+
+data WebRTCCallOffer = WebRTCCallOffer
+ { callType :: CallType,
+ rtcSession :: WebRTCSession
+ }
+ deriving (Eq, Show, Generic)
+
+instance FromJSON WebRTCCallOffer where
+ parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
+
+instance ToJSON WebRTCCallOffer where
+ toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
+ toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
+
+data CallAnswer = CallAnswer
+ { rtcSession :: WebRTCSession
+ }
+ deriving (Eq, Show, Generic, FromJSON)
+
+instance ToJSON CallAnswer where
+ toJSON = J.genericToJSON J.defaultOptions
+ toEncoding = J.genericToEncoding J.defaultOptions
+
+data CallExtraInfo = CallExtraInfo
+ { rtcExtraInfo :: WebRTCExtraInfo
+ }
+ deriving (Eq, Show, Generic, FromJSON)
+
+instance ToJSON CallExtraInfo where
+ toJSON = J.genericToJSON J.defaultOptions
+ toEncoding = J.genericToEncoding J.defaultOptions
+
+data WebRTCSession = WebRTCSession
+ { rtcSession :: Text,
+ rtcIceCandidates :: [Text]
+ }
+ deriving (Eq, Show, Generic, FromJSON)
+
+instance ToJSON WebRTCSession where
+ toJSON = J.genericToJSON J.defaultOptions
+ toEncoding = J.genericToEncoding J.defaultOptions
+
+data WebRTCExtraInfo = WebRTCExtraInfo
+ { rtcIceCandidates :: [Text]
+ }
+ deriving (Eq, Show, Generic, FromJSON)
+
+instance ToJSON WebRTCExtraInfo where
+ toJSON = J.genericToJSON J.defaultOptions
+ toEncoding = J.genericToEncoding J.defaultOptions
+
+data WebRTCCallStatus = WCSConnected | WCSDisconnected | WCSFailed
+ deriving (Show)
+
+instance StrEncoding WebRTCCallStatus where
+ strEncode = \case
+ WCSConnected -> "connected"
+ WCSDisconnected -> "disconnected"
+ WCSFailed -> "failed"
+ strP =
+ A.takeTill (== ' ') >>= \case
+ "connected" -> pure WCSConnected
+ "disconnected" -> pure WCSDisconnected
+ "failed" -> pure WCSFailed
+ _ -> fail "bad WebRTCCallStatus"
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index 4c0c667c02..bf6e815ef8 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -26,6 +26,7 @@ import Data.Word (Word16)
import GHC.Generics (Generic)
import Numeric.Natural
import qualified Paths_simplex_chat as SC
+import Simplex.Chat.Call
import Simplex.Chat.Markdown (MarkdownList)
import Simplex.Chat.Messages
import Simplex.Chat.Protocol
@@ -39,6 +40,7 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus)
import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON)
import Simplex.Messaging.Protocol (CorrId)
+import Simplex.Messaging.TMap (TMap)
import System.IO (Handle)
import UnliftIO.STM
@@ -80,6 +82,7 @@ data ChatController = ChatController
chatLock :: TMVar (),
sndFiles :: TVar (Map Int64 Handle),
rcvFiles :: TVar (Map Int64 Handle),
+ currentCalls :: TMap ContactId Call,
config :: ChatConfig,
filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps
}
@@ -108,6 +111,13 @@ data ChatCommand
| APIDeleteChat ChatRef
| APIAcceptContact Int64
| APIRejectContact Int64
+ | APISendCallInvitation ContactId CallType
+ | APIRejectCall ContactId
+ | APISendCallOffer ContactId WebRTCCallOffer
+ | APISendCallAnswer ContactId WebRTCSession
+ | APISendCallExtraInfo ContactId WebRTCExtraInfo
+ | APIEndCall ContactId
+ | APICallStatus ContactId WebRTCCallStatus
| APIUpdateProfile Profile
| APIParseMarkdown Text
| APIRegisterToken DeviceToken
@@ -240,6 +250,11 @@ data ChatResponse
| CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]}
| CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError}
| CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError}
+ | CRCallInvitation {contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key}
+ | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool}
+ | CRCallAnswer {contact :: Contact, answer :: WebRTCSession}
+ | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo}
+ | CRCallEnded {contact :: Contact}
| CRUserContactLinkSubscribed
| CRUserContactLinkSubError {chatError :: ChatError}
| CRNtfTokenStatus {status :: NtfTknStatus}
@@ -323,6 +338,10 @@ data ChatErrorType
| CEInvalidQuote
| CEInvalidChatItemUpdate
| CEInvalidChatItemDelete
+ | CEHasCurrentCall
+ | CENoCurrentCall
+ | CECallContact {contactId :: Int64}
+ | CECallState {currentCallState :: CallStateTag}
| CEAgentVersion
| CECommandError {message :: String}
deriving (Show, Exception, Generic)
diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs
index a54e779fb2..6a1097d065 100644
--- a/src/Simplex/Chat/Messages.hs
+++ b/src/Simplex/Chat/Messages.hs
@@ -20,6 +20,7 @@ import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.Text (Text)
+import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay)
import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime)
@@ -223,17 +224,18 @@ data CIMeta (d :: MsgDirection) = CIMeta
itemEdited :: Bool,
editable :: Bool,
localItemTs :: ZonedTime,
- createdAt :: UTCTime
+ createdAt :: UTCTime,
+ updatedAt :: UTCTime
}
deriving (Show, Generic)
-mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d
-mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt =
+mkCIMeta :: ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> UTCTime -> CIMeta d
+mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt updatedAt =
let localItemTs = utcToZonedTime tz itemTs
editable = case itemContent of
CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay
_ -> False
- in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt}
+ in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt, updatedAt}
instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions
@@ -439,6 +441,8 @@ data CIContent (d :: MsgDirection) where
CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv
CISndDeleted :: CIDeleteMode -> CIContent 'MDSnd
CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv
+ CISndCall :: CICallStatus -> Int -> CIContent 'MDSnd
+ CIRcvCall :: CICallStatus -> Int -> CIContent 'MDRcv
deriving instance Show (CIContent d)
@@ -448,6 +452,8 @@ ciContentToText = \case
CIRcvMsgContent mc -> msgContentText mc
CISndDeleted cidm -> ciDeleteModeToText cidm
CIRcvDeleted cidm -> ciDeleteModeToText cidm
+ CISndCall status duration -> "outgoing call: " <> ciCallInfoText status duration
+ CIRcvCall status duration -> "incoming call: " <> ciCallInfoText status duration
msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d
msgDirToDeletedContent_ msgDir mode = case msgDir of
@@ -463,7 +469,7 @@ instance ToJSON (CIContent d) where
toJSON = J.toJSON . jsonCIContent
toEncoding = J.toEncoding . jsonCIContent
-data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d)
+data ACIContent = forall d. MsgDirectionI d => ACIContent (SMsgDirection d) (CIContent d)
deriving instance Show ACIContent
@@ -480,6 +486,8 @@ data JSONCIContent
| JCIRcvMsgContent {msgContent :: MsgContent}
| JCISndDeleted {deleteMode :: CIDeleteMode}
| JCIRcvDeleted {deleteMode :: CIDeleteMode}
+ | JCISndCall {status :: CICallStatus, duration :: Int} -- duration in seconds
+ | JCIRcvCall {status :: CICallStatus, duration :: Int}
deriving (Generic)
instance FromJSON JSONCIContent where
@@ -495,6 +503,8 @@ jsonCIContent = \case
CIRcvMsgContent mc -> JCIRcvMsgContent mc
CISndDeleted cidm -> JCISndDeleted cidm
CIRcvDeleted cidm -> JCIRcvDeleted cidm
+ CISndCall status duration -> JCISndCall {status, duration}
+ CIRcvCall status duration -> JCIRcvCall {status, duration}
aciContentJSON :: JSONCIContent -> ACIContent
aciContentJSON = \case
@@ -502,6 +512,8 @@ aciContentJSON = \case
JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
JCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm
JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm
+ JCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration
+ JCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration
-- platform independent
data DBJSONCIContent
@@ -509,6 +521,8 @@ data DBJSONCIContent
| DBJCIRcvMsgContent {msgContent :: MsgContent}
| DBJCISndDeleted {deleteMode :: CIDeleteMode}
| DBJCIRcvDeleted {deleteMode :: CIDeleteMode}
+ | DBJCISndCall {status :: CICallStatus, duration :: Int}
+ | DBJCIRcvCall {status :: CICallStatus, duration :: Int}
deriving (Generic)
instance FromJSON DBJSONCIContent where
@@ -524,6 +538,8 @@ dbJsonCIContent = \case
CIRcvMsgContent mc -> DBJCIRcvMsgContent mc
CISndDeleted cidm -> DBJCISndDeleted cidm
CIRcvDeleted cidm -> DBJCIRcvDeleted cidm
+ CISndCall status duration -> DBJCISndCall {status, duration}
+ CIRcvCall status duration -> DBJCIRcvCall {status, duration}
aciContentDBJSON :: DBJSONCIContent -> ACIContent
aciContentDBJSON = \case
@@ -531,6 +547,42 @@ aciContentDBJSON = \case
DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc
DBJCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm
DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm
+ DBJCISndCall {status, duration} -> ACIContent SMDSnd $ CISndCall status duration
+ DBJCIRcvCall {status, duration} -> ACIContent SMDRcv $ CIRcvCall status duration
+
+data CICallStatus
+ = CISCallPending
+ | CISCallMissed
+ | CISCallRejected -- only possible for received calls, not on type level
+ | CISCallAccepted
+ | CISCallNegotiated
+ | CISCallProgress
+ | CISCallEnded
+ | CISCallError
+ deriving (Show, Generic)
+
+instance FromJSON CICallStatus where
+ parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CISCall"
+
+instance ToJSON CICallStatus where
+ toJSON = J.genericToJSON . enumJSON $ dropPrefix "CISCall"
+ toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CISCall"
+
+ciCallInfoText :: CICallStatus -> Int -> Text
+ciCallInfoText status duration = case status of
+ CISCallPending -> "calling..."
+ CISCallMissed -> "missed"
+ CISCallRejected -> "rejected"
+ CISCallAccepted -> "accepted"
+ CISCallNegotiated -> "connecting..."
+ CISCallProgress -> "in progress " <> d
+ CISCallEnded -> "ended " <> d
+ CISCallError -> "error"
+ where
+ d = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")"
+ with0 n
+ | n < 9 = '0' : show n
+ | otherwise = show n
data SChatType (c :: ChatType) where
SCTDirect :: SChatType 'CTDirect
@@ -548,11 +600,11 @@ instance TestEquality SChatType where
testEquality _ _ = Nothing
class ChatTypeI (c :: ChatType) where
- chatType :: SChatType c
+ chatTypeI :: SChatType c
-instance ChatTypeI 'CTDirect where chatType = SCTDirect
+instance ChatTypeI 'CTDirect where chatTypeI = SCTDirect
-instance ChatTypeI 'CTGroup where chatType = SCTGroup
+instance ChatTypeI 'CTGroup where chatTypeI = SCTGroup
data NewMessage = NewMessage
{ chatMsgEvent :: ChatMsgEvent,
diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs
index 928e4dca98..6a61f96691 100644
--- a/src/Simplex/Chat/Protocol.hs
+++ b/src/Simplex/Chat/Protocol.hs
@@ -29,6 +29,7 @@ import Data.Time.Clock (UTCTime)
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics (Generic)
+import Simplex.Chat.Call
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8)
import Simplex.Messaging.Encoding.String
@@ -132,6 +133,11 @@ data ChatMsgEvent
| XInfoProbe Probe
| XInfoProbeCheck ProbeHash
| XInfoProbeOk Probe
+ | XCallInv CallId CallInvitation
+ | XCallOffer CallId CallOffer
+ | XCallAnswer CallId CallAnswer
+ | XCallExtra CallId CallExtraInfo
+ | XCallEnd CallId
| XOk
| XUnknown {event :: Text, params :: J.Object}
deriving (Eq, Show)
@@ -306,6 +312,11 @@ data CMEventTag
| XInfoProbe_
| XInfoProbeCheck_
| XInfoProbeOk_
+ | XCallInv_
+ | XCallOffer_
+ | XCallAnswer_
+ | XCallExtra_
+ | XCallEnd_
| XOk_
| XUnknown_ Text
deriving (Eq, Show)
@@ -336,6 +347,11 @@ instance StrEncoding CMEventTag where
XInfoProbe_ -> "x.info.probe"
XInfoProbeCheck_ -> "x.info.probe.check"
XInfoProbeOk_ -> "x.info.probe.ok"
+ XCallInv_ -> "x.call.inv"
+ XCallOffer_ -> "x.call.offer"
+ XCallAnswer_ -> "x.call.answer"
+ XCallExtra_ -> "x.call.extra"
+ XCallEnd_ -> "x.call.end"
XOk_ -> "x.ok"
XUnknown_ t -> encodeUtf8 t
strDecode = \case
@@ -363,6 +379,11 @@ instance StrEncoding CMEventTag where
"x.info.probe" -> Right XInfoProbe_
"x.info.probe.check" -> Right XInfoProbeCheck_
"x.info.probe.ok" -> Right XInfoProbeOk_
+ "x.call.inv" -> Right XCallInv_
+ "x.call.offer" -> Right XCallOffer_
+ "x.call.answer" -> Right XCallAnswer_
+ "x.call.extra" -> Right XCallExtra_
+ "x.call.end" -> Right XCallEnd_
"x.ok" -> Right XOk_
t -> Right . XUnknown_ $ safeDecodeUtf8 t
strP = strDecode <$?> A.takeTill (== ' ')
@@ -393,6 +414,11 @@ toCMEventTag = \case
XInfoProbe _ -> XInfoProbe_
XInfoProbeCheck _ -> XInfoProbeCheck_
XInfoProbeOk _ -> XInfoProbeOk_
+ XCallInv _ _ -> XCallInv_
+ XCallOffer _ _ -> XCallOffer_
+ XCallAnswer _ _ -> XCallAnswer_
+ XCallExtra _ _ -> XCallExtra_
+ XCallEnd _ -> XCallEnd_
XOk -> XOk_
XUnknown t _ -> XUnknown_ t
@@ -441,6 +467,11 @@ appToChatMessage AppMessage {msgId, event, params} = do
XInfoProbe_ -> XInfoProbe <$> p "probe"
XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash"
XInfoProbeOk_ -> XInfoProbeOk <$> p "probe"
+ XCallInv_ -> XCallInv <$> p "callId" <*> p "invitation"
+ XCallOffer_ -> XCallOffer <$> p "callId" <*> p "offer"
+ XCallAnswer_ -> XCallAnswer <$> p "callId" <*> p "answer"
+ XCallExtra_ -> XCallExtra <$> p "callId" <*> p "extra"
+ XCallEnd_ -> XCallEnd <$> p "callId"
XOk_ -> pure XOk
XUnknown_ t -> pure $ XUnknown t params
@@ -476,6 +507,11 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p
XInfoProbe probe -> o ["probe" .= probe]
XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash]
XInfoProbeOk probe -> o ["probe" .= probe]
+ XCallInv callId inv -> o ["callId" .= callId, "invitation" .= inv]
+ XCallOffer callId offer -> o ["callId" .= callId, "offer" .= offer]
+ XCallAnswer callId answer -> o ["callId" .= callId, "answer" .= answer]
+ XCallExtra callId extra -> o ["callId" .= callId, "extra" .= extra]
+ XCallEnd callId -> o ["callId" .= callId]
XOk -> JM.empty
XUnknown _ ps -> ps
diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs
index d1835490ed..00db6f444e 100644
--- a/src/Simplex/Chat/Store.hs
+++ b/src/Simplex/Chat/Store.hs
@@ -135,6 +135,7 @@ module Simplex.Chat.Store
getChatItemIdByAgentMsgId,
getDirectChatItem,
getDirectChatItemBySharedMsgId,
+ getDirectChatItemByAgentMsgId,
getGroupChatItem,
getGroupChatItemBySharedMsgId,
getDirectChatItemIdByText,
@@ -170,7 +171,7 @@ import qualified Data.Aeson as J
import Data.Bifunctor (first)
import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
-import Data.Either (rights)
+import Data.Either (isRight, rights)
import Data.Function (on)
import Data.Functor (($>))
import Data.Int (Int64)
@@ -1286,7 +1287,7 @@ createNewGroup st gVar user groupProfile =
"INSERT INTO groups (local_display_name, user_id, group_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, uId, profileId, currentTs, currentTs)
groupId <- insertedRowId db
- memberId <- randomBytes gVar 12
+ memberId <- encodedRandomBytes gVar 12
membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs
pure $ Right GroupInfo {groupId, localDisplayName = displayName, groupProfile, membership, createdAt = currentTs}
@@ -2575,7 +2576,7 @@ getDirectChatPreviews_ db User {userId} = do
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- DirectQuote
@@ -2640,7 +2641,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- Maybe GroupMember - sender
@@ -2793,7 +2794,7 @@ getDirectChatLast_ db User {userId} contactId count = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- DirectQuote
@@ -2824,7 +2825,7 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- DirectQuote
@@ -2855,7 +2856,7 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- DirectQuote
@@ -2958,7 +2959,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- GroupMember
@@ -3001,7 +3002,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- GroupMember
@@ -3044,7 +3045,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- GroupMember
@@ -3149,24 +3150,27 @@ getGroupIdByName_ db User {userId} gName =
getChatItemIdByAgentMsgId :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> m (Maybe ChatItemId)
getChatItemIdByAgentMsgId st connId msgId =
- liftIO . withTransaction st $ \db ->
- join . listToMaybe . map fromOnly
- <$> DB.query
- db
- [sql|
- SELECT chat_item_id
- FROM chat_item_messages
- WHERE message_id = (
- SELECT message_id
- FROM msg_deliveries
- WHERE connection_id = ? AND agent_msg_id = ?
- LIMIT 1
- )
- |]
- (connId, msgId)
+ liftIO . withTransaction st $ \db -> getChatItemIdByAgentMsgId_ db connId msgId
+
+getChatItemIdByAgentMsgId_ :: DB.Connection -> Int64 -> AgentMsgId -> IO (Maybe ChatItemId)
+getChatItemIdByAgentMsgId_ db connId msgId =
+ join . listToMaybe . map fromOnly
+ <$> DB.query
+ db
+ [sql|
+ SELECT chat_item_id
+ FROM chat_item_messages
+ WHERE message_id = (
+ SELECT message_id
+ FROM msg_deliveries
+ WHERE connection_id = ? AND agent_msg_id = ?
+ LIMIT 1
+ )
+ |]
+ (connId, msgId)
updateDirectChatItemStatus :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d)
-updateDirectChatItemStatus st userId contactId itemId itemStatus =
+updateDirectChatItemStatus st userId contactId itemId itemStatus = do
liftIOEither . withTransaction st $ \db -> runExceptT $ do
ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId
currentTs <- liftIO getCurrentTime
@@ -3176,14 +3180,17 @@ updateDirectChatItemStatus st userId contactId itemId itemStatus =
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci
-updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d)
-updateDirectChatItem st userId contactId itemId newContent msgId =
- liftIOEither . withTransaction st $ \db -> updateDirectChatItem_ db userId contactId itemId newContent msgId
+updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> Maybe MessageId -> m (ChatItem 'CTDirect d)
+updateDirectChatItem st userId contactId itemId newContent msgId_ =
+ liftIOEither . withTransaction st $ \db -> do
+ currentTs <- liftIO getCurrentTime
+ ci <- updateDirectChatItem_ db userId contactId itemId newContent currentTs
+ when (isRight ci) . forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId currentTs
+ pure ci
-updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTDirect d))
-updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ do
+updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> UTCTime -> IO (Either StoreError (ChatItem 'CTDirect d))
+updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExceptT $ do
ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId
- currentTs <- liftIO getCurrentTime
let newText = ciContentToText newContent
liftIO $ do
DB.execute
@@ -3194,7 +3201,6 @@ updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $
WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?
|]
(newContent, newText, currentTs, userId, contactId, itemId)
- insertChatItemMessage_ db itemId msgId currentTs
pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText}
where
correctDir :: CChatItem c -> Either StoreError (ChatItem c d)
@@ -3271,16 +3277,22 @@ deleteQuote_ db itemId =
|]
(Only itemId)
-getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect)
+getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> ChatItemId -> m (CChatItem 'CTDirect)
getDirectChatItem st userId contactId itemId =
liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId
-getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> m (CChatItem 'CTDirect)
+getDirectChatItemBySharedMsgId :: StoreMonad m => SQLiteStore -> UserId -> ContactId -> SharedMsgId -> m (CChatItem 'CTDirect)
getDirectChatItemBySharedMsgId st userId contactId sharedMsgId =
liftIOEither . withTransaction st $ \db -> runExceptT $ do
itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId
liftIOEither $ getDirectChatItem_ db userId contactId itemId
+getDirectChatItemByAgentMsgId :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactId -> Int64 -> AgentMsgId -> m (Maybe (CChatItem 'CTDirect))
+getDirectChatItemByAgentMsgId st userId contactId connId msgId =
+ liftIO . withTransaction st $ \db -> do
+ itemId_ <- getChatItemIdByAgentMsgId_ db connId msgId
+ maybe (pure Nothing) (fmap eitherToMaybe . getDirectChatItem_ db userId contactId) itemId_
+
getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64)
getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId =
firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $
@@ -3307,7 +3319,7 @@ getDirectChatItem_ db userId contactId itemId = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- DirectQuote
@@ -3437,7 +3449,7 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do
[sql|
SELECT
-- ChatItem
- i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at,
+ i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at,
-- CIFile
f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status,
-- GroupMember
@@ -3579,9 +3591,9 @@ toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadIt
type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus)
-type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) :. MaybeCIFIleRow
+type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime, UTCTime) :. MaybeCIFIleRow
-type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) :. MaybeCIFIleRow
+type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. MaybeCIFIleRow
type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool)
@@ -3595,7 +3607,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir
CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent)
toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
-toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) =
+toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) =
case (itemContent, itemStatus, fileStatus_) of
(ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Just (AFS SMDSnd fileStatus)) ->
Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (maybeCIFile fileStatus)
@@ -3617,11 +3629,11 @@ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStat
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file}
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta d
- ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
+ ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt updatedAt
toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect]
-toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. quoteRow) =
- either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. quoteRow)
+toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt, Just updatedAt) :. fileRow) :. quoteRow) =
+ either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. quoteRow)
toDirectChatItemList _ _ _ = []
type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow
@@ -3637,7 +3649,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction
direction _ _ = Nothing
toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup)
-toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do
+toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do
let member_ = toMaybeGroupMember userContactId memberRow_
let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_
case (itemContent, itemStatus, member_, fileStatus_) of
@@ -3661,11 +3673,11 @@ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemT
CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file}
badItem = Left $ SEBadChatItem itemId
ciMeta :: CIContent d -> CIStatus d -> CIMeta d
- ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt
+ ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt updatedAt
toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup]
-toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) =
- either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_)
+toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt, Just updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) =
+ either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt, updatedAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_)
toGroupChatItemList _ _ _ _ = []
getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer]
@@ -3747,15 +3759,18 @@ createWithRandomBytes size gVar create = tryCreate 3
tryCreate :: Int -> IO (Either StoreError a)
tryCreate 0 = pure $ Left SEUniqueID
tryCreate n = do
- id' <- randomBytes gVar size
+ id' <- encodedRandomBytes gVar size
E.try (create id') >>= \case
Right x -> pure $ Right x
Left e
| DB.sqlError e == DB.ErrorConstraint -> tryCreate (n - 1)
| otherwise -> pure . Left . SEInternalError $ show e
+encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString
+encodedRandomBytes gVar = fmap B64.encode . randomBytes gVar
+
randomBytes :: TVar ChaChaDRG -> Int -> IO ByteString
-randomBytes gVar n = B64.encode <$> (atomically . stateTVar gVar $ randomBytesGenerate n)
+randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate
listToEither :: e -> [a] -> Either e a
listToEither _ (x : _) = Right x
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index fcb11f7f5f..8c200b067e 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -37,7 +37,7 @@ import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON)
import Simplex.Messaging.Util ((<$?>))
class IsContact a where
- contactId' :: a -> Int64
+ contactId' :: a -> ContactId
profile' :: a -> Profile
localDisplayName' :: a -> ContactName
@@ -53,7 +53,7 @@ instance IsContact Contact where
data User = User
{ userId :: UserId,
- userContactId :: Int64,
+ userContactId :: ContactId,
localDisplayName :: ContactName,
profile :: Profile,
activeUser :: Bool
@@ -62,10 +62,12 @@ data User = User
instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions
-type UserId = Int64
+type UserId = ContactId
+
+type ContactId = Int64
data Contact = Contact
- { contactId :: Int64,
+ { contactId :: ContactId,
localDisplayName :: ContactName,
profile :: Profile,
activeConn :: Connection,
@@ -85,7 +87,7 @@ contactConnId :: Contact -> ConnId
contactConnId Contact {activeConn} = aConnId activeConn
data ContactRef = ContactRef
- { contactId :: Int64,
+ { contactId :: ContactId,
localDisplayName :: ContactName
}
deriving (Eq, Show, Generic)
diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs
index fe489e2c72..7a1a3dd257 100644
--- a/src/Simplex/Chat/View.hs
+++ b/src/Simplex/Chat/View.hs
@@ -139,6 +139,11 @@ responseToView testView = \case
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e ->
["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
+ CRCallInvitation {contact} -> ["call invitation from " <> ttyContact' contact]
+ CRCallOffer {contact} -> ["call offer from " <> ttyContact' contact]
+ CRCallAnswer {contact} -> ["call answer from " <> ttyContact' contact]
+ CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact]
+ CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"]
CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"]
CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"]
CRNewContactConnection _ -> []
@@ -185,11 +190,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha
CIDirectSnd -> case content of
CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc
CISndDeleted _ -> []
+ CISndCall {} -> []
where
to = ttyToContact' c
CIDirectRcv -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvDeleted _ -> []
+ CIRcvCall {} -> []
where
from = ttyFromContact' c
where
@@ -198,11 +205,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha
CIGroupSnd -> case content of
CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc
CISndDeleted _ -> []
+ CISndCall {} -> []
where
to = ttyToGroup g
CIGroupRcv m -> case content of
CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc
CIRcvDeleted _ -> []
+ CIRcvCall {} -> []
where
from = ttyFromGroup' g m
where
@@ -652,6 +661,10 @@ viewChatError = \case
CEInvalidQuote -> ["cannot reply to this message"]
CEInvalidChatItemUpdate -> ["cannot update this item"]
CEInvalidChatItemDelete -> ["cannot delete this item"]
+ CEHasCurrentCall -> ["call already in progress"]
+ CENoCurrentCall -> ["no call in progress"]
+ CECallContact _ -> []
+ CECallState _ -> []
CEAgentVersion -> ["unsupported agent version"]
CECommandError e -> ["bad chat command: " <> plain e]
-- e -> ["chat error: " <> sShow e]
diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs
index d4a419623e..688ccabf77 100644
--- a/tests/ChatClient.hs
+++ b/tests/ChatClient.hs
@@ -30,7 +30,7 @@ import Simplex.Messaging.Agent.RetryInterval
import Simplex.Messaging.Server (runSMPServerBlocking)
import Simplex.Messaging.Server.Env.STM
import Simplex.Messaging.Transport
-import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)
+import System.Directory (createDirectoryIfMissing, removePathForcibly)
import qualified System.Terminal as C
import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal)
import System.Timeout (timeout)
@@ -144,7 +144,7 @@ withTmpFiles :: IO () -> IO ()
withTmpFiles =
bracket_
(createDirectoryIfMissing False "tests/tmp")
- (removeDirectoryRecursive "tests/tmp")
+ (removePathForcibly "tests/tmp")
testChatN :: [Profile] -> ([TestCC] -> IO ()) -> IO ()
testChatN ps test = withTmpFiles $ do
diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs
index 47689ab190..068b8551ec 100644
--- a/tests/ChatTests.hs
+++ b/tests/ChatTests.hs
@@ -9,9 +9,13 @@ import ChatClient
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM
-import qualified Data.ByteString as B
+import Data.Aeson (ToJSON)
+import qualified Data.Aeson as J
+import qualified Data.ByteString.Char8 as B
+import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (isDigit)
import qualified Data.Text as T
+import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatController (..))
import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..))
import Simplex.Chat.Util (unlessM)
@@ -82,6 +86,8 @@ chatTests = do
xdescribe "async sending and receiving files" $ do
it "send and receive file, fully asynchronous" testAsyncFileTransfer
it "send and receive file to group, fully asynchronous" testAsyncGroupFileTransfer
+ describe "webrtc calls api" $ do
+ it "negotiate call" testNegotiateCall
testAddContact :: IO ()
testAddContact =
@@ -1762,6 +1768,66 @@ testAsyncGroupFileTransfer = withTmpFiles $ do
dest2 <- B.readFile "./tests/tmp/test_1.jpg"
dest2 `shouldBe` src
+testCallType :: CallType
+testCallType = CallType {media = CMVideo, capabilities = CallCapabilities {encryption = True}}
+
+testWebRTCSession :: WebRTCSession
+testWebRTCSession =
+ WebRTCSession
+ { rtcSession = "{}",
+ rtcIceCandidates = [""]
+ }
+
+testWebRTCCallOffer :: WebRTCCallOffer
+testWebRTCCallOffer =
+ WebRTCCallOffer
+ { callType = testCallType,
+ rtcSession = testWebRTCSession
+ }
+
+serialize :: ToJSON a => a -> String
+serialize = B.unpack . LB.toStrict . J.encode
+
+testNegotiateCall :: IO ()
+testNegotiateCall =
+ testChat2 aliceProfile bobProfile $ \alice bob -> do
+ connectUsers alice bob
+ -- alice invite bob to call
+ alice ##> ("/_call invite @2 " <> serialize testCallType)
+ alice <## "ok"
+ alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: calling...")])
+ bob <## "call invitation from alice"
+ bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: calling...")])
+ -- bob accepts call by sending WebRTC offer
+ bob ##> ("/_call offer @2 " <> serialize testWebRTCCallOffer)
+ bob <## "ok"
+ bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: accepted")])
+ alice <## "call offer from bob"
+ alice <## "message updated" -- call chat item updated
+ alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: accepted")])
+ -- alice confirms call by sending WebRTC answer
+ alice ##> ("/_call answer @2 " <> serialize testWebRTCSession)
+ alice <## "ok"
+ alice <## "message updated"
+ alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: connecting...")])
+ bob <## "call answer from alice"
+ bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: connecting...")])
+ -- participants can update calls as connected
+ alice ##> "/_call status @2 connected"
+ alice <## "ok"
+ alice <## "message updated"
+ alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: in progress (00:00)")])
+ bob ##> "/_call status @2 connected"
+ bob <## "ok"
+ bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: in progress (00:00)")])
+ -- either party can end the call
+ bob ##> "/_call end @2"
+ bob <## "ok"
+ bob #$> ("/_get chat @2 count=100", chat, [(0, "incoming call: ended (00:00)")])
+ alice <## "call with bob ended"
+ alice <## "message updated"
+ alice #$> ("/_get chat @2 count=100", chat, [(1, "outgoing call: ended (00:00)")])
+
withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a
withTestChatContactConnected dbPrefix action =
withTestChat dbPrefix $ \cc -> do
diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs
index 9e73060f9e..0ca78fa798 100644
--- a/tests/MobileTests.hs
+++ b/tests/MobileTests.hs
@@ -53,10 +53,10 @@ testChatApiNoUser = withTmpFiles $ do
testChatApi :: IO ()
testChatApi = withTmpFiles $ do
- let f = chatStoreFile $ testDBPrefix <> "1"
+ let f = chatStoreFile testDBPrefix
st <- createStore f 1 True
Right _ <- runExceptT $ createUser st aliceProfile True
- cc <- chatInit $ testDBPrefix <> "1"
+ cc <- chatInit testDBPrefix
chatSendCmd cc "/u" `shouldReturn` activeUser
chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` chatStarted