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