Merge pull request #591 from simplex-chat/webrtc-calls

WebRTC calls
This commit is contained in:
Evgeny Poberezkin
2022-05-05 08:31:33 +01:00
committed by GitHub
35 changed files with 2630 additions and 108 deletions
+1
View File
@@ -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"
@@ -5,6 +5,9 @@
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIDEO_CAPTURE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -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
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<link href="./style.css" rel="stylesheet" />
</head>
<body>
<video id="remote-video-stream" autoplay></video>
<video id="local-video-stream" muted autoplay></video>
</body>
<footer>
<script src="./call.js"></script>
</footer>
</html>
@@ -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
@@ -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;
}
@@ -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)
}
}
@@ -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 = {}
)
}
}
+187
View File
@@ -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<Int, CheckedContinuation<WCallResponse, Never>> = [:]
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()
}
}
+229
View File
@@ -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
}
@@ -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")
+30 -8
View File
@@ -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 = "<group>"; };
3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = "<group>"; };
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
@@ -168,6 +173,7 @@
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = "<group>"; };
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = "<group>"; };
5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = "<group>"; };
@@ -257,9 +263,19 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
3C714775281C080100CB4D4B /* Call */ = {
isa = PBXGroup;
children = (
3C714776281C081000CB4D4B /* CallView.swift */,
5C9D13A2282187BB00AB8B43 /* WebRTC.swift */,
);
path = Call;
sourceTree = "<group>";
};
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;
+67
View File
@@ -0,0 +1,67 @@
sequenceDiagram
participant AW as Alice's<br>web view
participant AN as Alice's<br>app native
participant AC as Alice's<br>chat core
participant BC as Bob's<br>chat core
participant BN as Bob's<br>app native
participant BW as Bob's<br>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<br>(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<br>(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
+3
View File
@@ -0,0 +1,3 @@
node_modules
package-lock.json
dist
@@ -0,0 +1 @@
dist
@@ -0,0 +1,5 @@
{
"bracketSpacing": false,
"semi": false,
"printWidth": 140
}
+6
View File
@@ -0,0 +1,6 @@
# WebView for WebRTC calls in SimpleX Chat
```
npm i
npm run build
```
+10
View File
@@ -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
+25
View File
@@ -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"
}
}
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<link href="./style.css" rel="stylesheet" />
</head>
<body>
<video id="remote-video-stream" autoplay></video>
<video id="local-video-stream" muted autoplay></video>
</body>
<footer>
<script src="./call.js"></script>
</footer>
</html>
+604
View File
@@ -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<string[]> // 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<Call> {
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<string[]>((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<WebViewMessage> {
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<void> {
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<void> {
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
}
@@ -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;
}
@@ -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"
}
}
+1
View File
@@ -21,6 +21,7 @@ library
exposed-modules:
Simplex.Chat
Simplex.Chat.Bot
Simplex.Chat.Call
Simplex.Chat.Controller
Simplex.Chat.Core
Simplex.Chat.Help
+241 -25
View File
@@ -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)
+211
View File
@@ -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"
+19
View File
@@ -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)
+60 -8
View File
@@ -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,
+36
View File
@@ -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
+64 -49
View File
@@ -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
+7 -5
View File
@@ -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)
+13
View File
@@ -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]
+2 -2
View File
@@ -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
+67 -1
View File
@@ -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
+2 -2
View File
@@ -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