From c000a1b9248236a4cc7dedf118d043d51c4e5ed9 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 14 May 2022 21:00:54 +0400 Subject: [PATCH 01/32] docs: fix links in CLI.md (#655) --- docs/CLI.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 1c49c8a1f5..0d243a57de 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -146,7 +146,7 @@ Once you have started the chat, you will be prompted to specify your "display na The diagram below shows how to connect and message a contact:
- +
Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel. @@ -165,7 +165,7 @@ Use `/help` in chat to see the list of available commands. To create a group use `/g `, then add contacts to it with `/a `. You can then send messages to the group by entering `# `. Use `/help groups` for other commands. -![simplex-chat](./images/groups.gif) +![simplex-chat](../images/groups.gif) > **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent. @@ -173,7 +173,7 @@ To create a group use `/g `, then add contacts to it with `/a ` - the recipient will have to accept it before it is sent. Use `/help files` for other commands. -![simplex-chat](./images/files.gif) +![simplex-chat](../images/files.gif) You can send files to a group with `/f # `. @@ -187,7 +187,7 @@ User address is "long-term" in a sense that it is a multiple-use connection link Use `/help address` for other commands. -![simplex-chat](./images/user-addresses.gif) +![simplex-chat](../images/user-addresses.gif) ### Access chat history From 36ef6df9fbbd3a102a0295dd66d0de3030f55237 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 16 May 2022 07:59:41 +0100 Subject: [PATCH 02/32] android: notification service and battery optimization on Android 12 (#656) * Managing battery optimization on Android 12+ via in-app UI - in case of battery optimization is enabled a user will be asked to disable it if he wants to have a background service - when the service is enabled but the user don't want to disable the battery optimization, the service will be disabled with an alert for the user * update service notice conditions * android: update notification service logic * update translations Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/android/app/src/main/AndroidManifest.xml | 1 + .../main/java/chat/simplex/app/SimplexApp.kt | 6 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 177 ++++++++++++++---- .../chat/simplex/app/views/WelcomeView.kt | 3 +- .../app/views/usersettings/SettingsView.kt | 4 + .../app/src/main/res/values-ru/strings.xml | 3 + .../app/src/main/res/values/strings.xml | 3 + 7 files changed, 162 insertions(+), 35 deletions(-) diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 5062b90ed6..9d3d94390d 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ + SimplexService.start(applicationContext) + Lifecycle.Event.ON_RESUME -> + if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + chatController.showBackgroundServiceNoticeIfNeeded() + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 9f2e2835e8..daa0e62041 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1,9 +1,14 @@ package chat.simplex.app.model +import android.annotation.SuppressLint import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo -import android.content.Context -import android.content.SharedPreferences +import android.app.Application +import android.content.* +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -13,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat.startActivityForResult import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.views.call.* @@ -526,38 +532,118 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err)) } - fun showBackgroundServiceNotice() { + fun showBackgroundServiceNoticeIfNeeded() { + Log.d(TAG, "showBackgroundServiceNoticeIfNeeded") if (!getBackgroundServiceNoticeShown()) { - AlertManager.shared.showAlert { - AlertDialog( - onDismissRequest = AlertManager.shared::hideAlert, - title = { - Row { - Icon( - Icons.Outlined.Bolt, - contentDescription = stringResource(R.string.icon_descr_instant_notifications), - ) - Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) - } - }, - text = { - Column { - Text( - annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), - Modifier.padding(bottom = 8.dp) - ) - Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)) - } - }, - confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } - } - ) + // the branch for the new users who has never seen service notice + if (isIgnoringBatteryOptimizations(appContext)) { + showBGServiceNotice() + } else { + showBGServiceNoticeIgnoreOptimization() + } + // set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice + setBackgroundServiceNoticeShown(true) + setBackgroundServiceBatteryNoticeShown(true) + } else if (!isIgnoringBatteryOptimizations(appContext) && getRunServiceInBackground()) { + // the branch for users who have app installed, and have seen the service notice, + // but the battery optimization for the app is on (Android 12) AND the service is running + if (getBackgroundServiceBatteryNoticeShown()) { + // users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service + showDisablingServiceNotice() + setRunServiceInBackground(false) + chatModel.runServiceInBackground.value = false + } else { + // show battery optimization notice + showBGServiceNoticeIgnoreOptimization() + setBackgroundServiceBatteryNoticeShown(true) } - setBackgroundServiceNoticeShown() } } + private fun showBGServiceNotice() = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row { + Icon( + Icons.Outlined.Bolt, + contentDescription = stringResource(R.string.icon_descr_instant_notifications), + ) + Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) + } + }, + text = { + Column { + Text( + annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), + Modifier.padding(bottom = 8.dp) + ) + Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)) + } + }, + confirmButton = { + Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } + } + ) + } + + private fun showBGServiceNoticeIgnoreOptimization() = AlertManager.shared.showAlert { + val ignoreOptimization = { + AlertManager.shared.hideAlert() + askAboutIgnoringBatteryOptimization(appContext) + } + AlertDialog( + onDismissRequest = ignoreOptimization, + title = { + Row { + Icon( + Icons.Outlined.Bolt, + contentDescription = stringResource(R.string.icon_descr_instant_notifications), + ) + Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) + } + }, + text = { + Column { + Text( + annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), + Modifier.padding(bottom = 8.dp) + ) + Text(annotatedStringResource(R.string.turn_off_battery_optimization)) + } + }, + confirmButton = { + Button(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) } + } + ) + } + + private fun showDisablingServiceNotice() = AlertManager.shared.showAlert { + AlertDialog( + onDismissRequest = AlertManager.shared::hideAlert, + title = { + Row { + Icon( + Icons.Outlined.Bolt, + contentDescription = stringResource(R.string.icon_descr_instant_notifications), + ) + Text(stringResource(R.string.private_instant_notifications_disabled), fontWeight = FontWeight.Bold) + } + }, + text = { + Column { + Text( + annotatedStringResource(R.string.turning_off_background_service), + Modifier.padding(bottom = 8.dp) + ) + } + }, + confirmButton = { + Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } + } + ) + } + fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) fun setAutoRestartWorkerVersion(version: Int) = @@ -572,18 +658,45 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt .putBoolean(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, runService) .apply() - fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) + private fun getBackgroundServiceNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) - fun setBackgroundServiceNoticeShown() = + fun setBackgroundServiceNoticeShown(shown: Boolean) = sharedPreferences.edit() - .putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, true) + .putBoolean(SHARED_PREFS_SERVICE_NOTICE_SHOWN, shown) .apply() + private fun getBackgroundServiceBatteryNoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) + + fun setBackgroundServiceBatteryNoticeShown(shown: Boolean) = + sharedPreferences.edit() + .putBoolean(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, shown) + .apply() + + fun isIgnoringBatteryOptimizations(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true + val powerManager = context.getSystemService(Application.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + private fun askAboutIgnoringBatteryOptimization(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + Intent().apply { + @SuppressLint("BatteryLife") + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.packageName}") + // This flag is needed when you start a new activity from non-Activity context + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(this) + } + } + companion object { private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" + private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index d26f142f35..6703df1aa0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -115,8 +115,7 @@ fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) { ) chatModel.controller.startChat(user) SimplexService.start(chatModel.controller.appContext) - // TODO show it later? - chatModel.controller.showBackgroundServiceNotice() + chatModel.controller.showBackgroundServiceNoticeIfNeeded() chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 64f7610bc9..b09b6e7484 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -37,6 +37,10 @@ fun SettingsView(chatModel: ChatModel) { runServiceInBackground = chatModel.runServiceInBackground, setRunServiceInBackground = { on -> chatModel.controller.setRunServiceInBackground(on) + if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { + chatModel.controller.setBackgroundServiceNoticeShown(false) + } + chatModel.controller.showBackgroundServiceNoticeIfNeeded() chatModel.runServiceInBackground.value = on }, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 9496535853..9667b24111 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -54,8 +54,11 @@ Приватные мгновенные уведомления! + Приватные уведомления выключены! Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает фоновый сервис SimpleX, который потребляет несколько процентов батареи в день. Он может быть выключен через Настройки – вы продолжите получать уведомления о сообщениях пока приложение запущено. + Для использования приватных уведомлений, пожалуйста отключите оптимизацию батареи для SimpleX в слеующем диалоге. Иначе уведомления будут выключены. + Оптимизация батареи включена, поэтому приватные уведомления выключены. Вы можете снова включить их через Настройки. SimpleX Chat сервис diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 5dce4ad6ea..7143c0e314 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -54,8 +54,11 @@ Private instant notifications! + Private notifications disabled! To preserve your privacy, instead of push notifications the app has a SimpleX background service – it uses a few percent of the battery per day. It can be disabled via settings – notifications will still be shown while the app is running. + In order to use it, please disable battery optimization for SimpleX in the next dialog. Otherwise, the notifications will be disabled. + Battery optimization is active, turning off Private notifications. You can re-enable them via settings. SimpleX Chat service From 82445ec8d5cb969eb2d13b0ac851431ec85a936a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 16 May 2022 19:27:58 +0100 Subject: [PATCH 03/32] android: refactor webrtc calls, compress webrtc session info, make compatible with Safari (with flag) (#642) * use simplex.chat relay * update webrtc settings * WebRTCView to use command/response types * compress WebRTC session descriptions, simple web UI for calls * update webrtc ui * use webworked in desktop browser * use RTCRtpScriptTransform in safari * update android type * refactor * add await --- .../android/app/src/main/assets/www/call.html | 1 + apps/android/app/src/main/assets/www/call.js | 958 +++++++------- .../app/src/main/assets/www/lz-string.min.js | 1 + .../java/chat/simplex/app/model/SimpleXAPI.kt | 6 +- .../chat/simplex/app/views/call/CallView.kt | 211 ++-- .../chat/simplex/app/views/call/WebRTC.kt | 35 +- .../app/views/usersettings/SettingsView.kt | 4 +- apps/ios/Shared/Model/Shared/CallTypes.swift | 4 +- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +- apps/ios/Shared/Views/Call/WebRTC.swift | 122 +- apps/ios/Shared/Views/Call/WebRTCView.swift | 139 ++- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 10 +- packages/simplex-chat-webrtc/copy | 4 + packages/simplex-chat-webrtc/package.json | 4 + packages/simplex-chat-webrtc/src/call.html | 1 + packages/simplex-chat-webrtc/src/call.ts | 1099 +++++++++-------- packages/simplex-chat-webrtc/src/ui.js | 115 ++ packages/simplex-chat-webrtc/src/webcall.html | 51 + packages/simplex-chat-webrtc/tsconfig.json | 3 +- src/Simplex/Chat/Call.hs | 6 +- tests/ChatTests.hs | 2 +- 22 files changed, 1612 insertions(+), 1174 deletions(-) create mode 100644 apps/android/app/src/main/assets/www/lz-string.min.js create mode 100644 packages/simplex-chat-webrtc/src/ui.js create mode 100644 packages/simplex-chat-webrtc/src/webcall.html diff --git a/apps/android/app/src/main/assets/www/call.html b/apps/android/app/src/main/assets/www/call.html index fd3019e8eb..a188d38394 100644 --- a/apps/android/app/src/main/assets/www/call.html +++ b/apps/android/app/src/main/assets/www/call.html @@ -2,6 +2,7 @@ + diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index e74864b9dd..b5381b3cd9 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -6,464 +6,536 @@ var CallMediaType; CallMediaType["Audio"] = "audio"; CallMediaType["Video"] = "video"; })(CallMediaType || (CallMediaType = {})); -const keyAlgorithm = { - name: "AES-GCM", - length: 256, -}; -const keyUsages = ["encrypt", "decrypt"]; -let activeCall; -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 { +// for debugging +// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp})) +var sendMessageToNative = (msg) => console.log(JSON.stringify(msg)); +// Global object with cryptrographic/encoding functions +const callCrypto = callCryptoFunction(); +var TransformOperation; +(function (TransformOperation) { + TransformOperation["Encrypt"] = "encrypt"; + TransformOperation["Decrypt"] = "decrypt"; +})(TransformOperation || (TransformOperation = {})); +; +(function () { + let activeCall; + function defaultCallConfig(encodedInsertableStreams) { + return { + peerConnectionConfig: { + iceServers: [ + { urls: "stun:stun.simplex.chat:5349" }, + // {urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"}, + ], + iceCandidatePoolSize: 10, + encodedInsertableStreams, + // iceTransportPolicy: "relay", + }, + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000, + }, + }; + } + async function initializeCall(config, mediaType, aesKey, useWorker) { + const conn = new RTCPeerConnection(config.peerConnectionConfig); + const remoteStream = new MediaStream(); + const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)); + await setUpMediaStreams(conn, localStream, remoteStream, aesKey, useWorker); + 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 = serialize(candidates); + candidates = []; + resolve(iceCandidates); + } + function sendIceCandidates() { + if (candidates.length === 0) + return; + const iceCandidates = serialize(candidates); + candidates = []; + sendMessageToNative({ resp: { type: "ice", iceCandidates } }); + } + }); + return { connection: conn, iceCandidates, localMedia: mediaType, localStream }; + 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(); + activeCall = undefined; + resetVideoElements(); + } + } + } + function serialize(x) { + return LZString.compressToBase64(JSON.stringify(x)); + } + function parse(s) { + return JSON.parse(LZString.decompressFromBase64(s)); + } + Object.defineProperty(window, "processCommand", { value: processCommand }); + async function processCommand(body) { + const { corrId, command } = body; + const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection; + let resp; + try { + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams(command.useWorker); + resp = { type: "capabilities", capabilities: { encryption } }; + break; + case "start": + console.log("starting call"); + if (activeCall) { + resp = { type: "error", message: "start: call already started" }; + } + else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + resp = { type: "error", message: "start: encryption is not supported" }; + } + else { + const { media, useWorker } = command; + const encryption = supportsInsertableStreams(useWorker); + const aesKey = encryption ? command.aesKey : undefined; + activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, aesKey, useWorker); + const pc = activeCall.connection; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + // for debugging, returning the command for callee to use + // resp = { + // type: "offer", + // offer: serialize(offer), + // iceCandidates: await activeCall.iceCandidates, + // media, + // aesKey, + // } + resp = { + type: "offer", + offer: serialize(offer), + iceCandidates: await activeCall.iceCandidates, + capabilities: { encryption }, + }; + } + break; + case "offer": + if (activeCall) { + resp = { type: "error", message: "accept: call already started" }; + } + else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + resp = { type: "error", message: "accept: encryption is not supported" }; + } + else { + const offer = parse(command.offer); + const remoteIceCandidates = parse(command.iceCandidates); + const { media, aesKey, useWorker } = command; + activeCall = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey, useWorker); + const pc = activeCall.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: serialize(answer), + iceCandidates: await activeCall.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 = parse(command.answer); + const remoteIceCandidates = parse(command.iceCandidates); + await pc.setRemoteDescription(new RTCSessionDescription(answer)); + addIceCandidates(pc, remoteIceCandidates); + resp = { type: "ok" }; + } + break; + case "ice": + if (pc) { + const remoteIceCandidates = parse(command.iceCandidates); + addIceCandidates(pc, remoteIceCandidates); + resp = { type: "ok" }; + } + else { + resp = { type: "error", message: "ice: call not started" }; + } + break; + case "media": + if (!activeCall) { + resp = { type: "error", message: "media: call not started" }; + } + else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { + resp = { type: "error", message: "media: no video" }; + } + else { + enableMedia(activeCall.localStream, command.media, command.enable); + resp = { type: "ok" }; + } + break; + case "end": + if (pc) { + pc.close(); + activeCall = 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 = { corrId, resp, command }; + 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, useWorker) { + var _a; + const videos = getVideoElements(); + if (!videos) + throw Error("no video elements"); + let key; + let worker; + if (aesKey) { + key = await callCrypto.decodeAesKey(aesKey); + if (useWorker) { + const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; + worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); + } + } + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream); + } + if (aesKey && key) { + console.log("set up encryption for sending"); + for (const sender of pc.getSenders()) { + setupPeerTransform(TransformOperation.Encrypt, sender, worker, aesKey, key); + } + } + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + if (aesKey && key) { + console.log("set up decryption for receiving"); + setupPeerTransform(TransformOperation.Decrypt, event.receiver, worker, aesKey, key); + } + remoteStream.addTrack(event.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 setupPeerTransform(operation, peer, worker, aesKey, key) { + if (worker && "RTCRtpScriptTransform" in window) { + console.log(`${operation} with worker & RTCRtpScriptTransform`); + peer.transform = new RTCRtpScriptTransform(worker, { operation, aesKey }); + } + else if ("createEncodedStreams" in peer) { + const { readable, writable } = peer.createEncodedStreams(); + if (worker) { + console.log(`${operation} with worker`); + worker.postMessage({ operation, readable, writable, aesKey }, [readable, writable]); + } + else { + console.log(`${operation} without worker`); + const transform = callCrypto.transformFrame[operation](key); + readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable); + } + } + else { + console.log(`no ${operation}`); + } + } + 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(useWorker) { + return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) || + (!!useWorker && "RTCRtpScriptTransform" in window)); + } + 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" + // } + // } + function enableMedia(s, media, enable) { + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) + t.enabled = enable; + } +})(); +// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) +function callCryptoFunction() { + const initialPlainTextRequired = { + key: 10, + delta: 3, + }; + const IV_LENGTH = 12; + function encryptFrame(key) { + return async (frame, controller) => { + const data = new Uint8Array(frame.data); + const n = initialPlainTextRequired[frame.type] || 1; + 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 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, localMedia: mediaType, localStream }; - 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(); - activeCall = undefined; - resetVideoElements(); - } } -} -var sendMessageToNative = (msg) => console.log(JSON.stringify(msg)); -async function processCommand(body) { - const { corrId, command } = body; - const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection; - let resp; - try { - switch (command.type) { - case "capabilities": - const encryption = supportsInsertableStreams(); - resp = { type: "capabilities", capabilities: { encryption } }; - break; - case "start": - console.log("starting call"); - if (activeCall) { - resp = { type: "error", message: "start: call already started" }; - } - else if (!supportsInsertableStreams() && command.aesKey) { - resp = { type: "error", message: "start: encryption is not supported" }; - } - else { - const encryption = supportsInsertableStreams(); - const { media, aesKey } = command; - activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined); - const pc = activeCall.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: JSON.stringify(offer), - iceCandidates: await activeCall.iceCandidates, - capabilities: { encryption }, - }; - } - break; - case "accept": - if (activeCall) { - 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)); - activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey); - const pc = activeCall.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 activeCall.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 "media": - if (!activeCall) { - resp = { type: "error", message: "media: call not started" }; - } - else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { - resp = { type: "error", message: "media: no video" }; - } - else { - enableMedia(activeCall.localStream, command.media, command.enable); - resp = { type: "ok" }; - } - break; - case "end": - if (pc) { - pc.close(); - activeCall = 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 = { corrId, resp, command }; - 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); + function decryptFrame(key) { + return async (frame, controller) => { + const data = new Uint8Array(frame.data); + const n = initialPlainTextRequired[frame.type] || 1; + 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; + } + }; } - // 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 decodeAesKey(aesKey) { + const keyData = callCrypto.decodeBase64(callCrypto.encodeAscii(aesKey)); + return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } -} -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" -// } -// } -function enableMedia(s, media, enable) { - const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); - for (const t of tracks) - t.enabled = enable; -} -/* 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 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 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]; + function randomIV() { + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)); } - 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--; + const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))); + const base64lookup = new Array(256); + base64chars.forEach((c, i) => (base64lookup[c] = i)); + const char_equal = "=".charCodeAt(0); + 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; + } + 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; } - 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 { + transformFrame: { encrypt: encryptFrame, decrypt: decryptFrame }, + decodeAesKey, + encodeAscii, + decodeAscii, + encodeBase64, + decodeBase64, + }; +} +// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob +// We have to use worker optionally, as it crashes in Android web view, regardless of how it is loaded +function workerFunction() { + // encryption with createEncodedStreams support + self.addEventListener("message", async ({ data }) => { + await setupTransform(data); + }); + // encryption using RTCRtpScriptTransform. + if ("RTCTransformEvent" in self) { + self.addEventListener("rtctransform", async ({ transformer }) => { + const { operation, aesKey } = transformer.options; + const { readable, writable } = transformer; + await setupTransform({ operation, aesKey, readable, writable }); + }); + } + async function setupTransform({ operation, aesKey, readable, writable }) { + const key = await callCrypto.decodeAesKey(aesKey); + const transform = callCrypto.transformFrame[operation](key); + readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable); } - return bytes; } //# sourceMappingURL=call.js.map \ No newline at end of file diff --git a/apps/android/app/src/main/assets/www/lz-string.min.js b/apps/android/app/src/main/assets/www/lz-string.min.js new file mode 100644 index 0000000000..2d1900a0d3 --- /dev/null +++ b/apps/android/app/src/main/assets/www/lz-string.min.js @@ -0,0 +1 @@ +var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;ne;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;ie;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString); diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index daa0e62041..3078cb4d70 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -338,20 +338,20 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return r is CR.CmdOk } - suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: List, media: CallMediaType, capabilities: CallCapabilities): Boolean { + suspend fun apiSendCallOffer(contact: Contact, rtcSession: String, rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities): Boolean { val webRtcSession = WebRTCSession(rtcSession, rtcIceCandidates) val callOffer = WebRTCCallOffer(CallType(media, capabilities), webRtcSession) val r = sendCmd(CC.ApiSendCallOffer(contact, callOffer)) return r is CR.CmdOk } - suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: List): Boolean { + suspend fun apiSendCallAnswer(contact: Contact, rtcSession: String, rtcIceCandidates: String): Boolean { val answer = WebRTCSession(rtcSession, rtcIceCandidates) val r = sendCmd(CC.ApiSendCallAnswer(contact, answer)) return r is CR.CmdOk } - suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: List): Boolean { + suspend fun apiSendCallExtraInfo(contact: Contact, rtcIceCandidates: String): Boolean { val extraInfo = WebRTCExtraInfo(rtcIceCandidates) val r = sendCmd(CC.ApiSendCallExtraInfo(contact, extraInfo)) return r is CR.CmdOk diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index b85c6b59b7..4740a74745 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -1,7 +1,6 @@ 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 @@ -24,16 +23,67 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat import chat.simplex.app.TAG +import chat.simplex.app.model.json import chat.simplex.app.views.helpers.TextEditor import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString -@SuppressLint("SetJavaScriptEnabled") @Composable fun VideoCallView(close: () -> Unit) { + val callCommand = remember { mutableStateOf(null)} + val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") } + val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java) + BackHandler(onBack = close) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .background(MaterialTheme.colors.background) + .fillMaxSize() + ) { + WebRTCView(callCommand) { resp -> + // for debugging + // commandText.value = resp + commandText.value = json.encodeToString(resp) + } + + TextEditor(Modifier.height(180.dp), text = commandText) + + Row( + Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button(onClick = { + val clip: ClipData = ClipData.newPlainText("js command", commandText.value) + clipboard?.setPrimaryClip(clip) + }) { Text("Copy") } + Button(onClick = { + try { + val apiCall: WVAPICall = json.decodeFromString(commandText.value) + commandText.value = "" + println("sending: ${commandText.value}") + callCommand.value = apiCall.command + } catch(e: Error) { + println("error parsing command: ${commandText.value}") + println(e) + } + }) { Text("Send") } + Button(onClick = { + commandText.value = "" + }) { Text("Clear") } + } + } +} + +@Composable +// for debugging +// fun WebRTCView(callCommand: MutableState, onResponse: (String) -> Unit) { +fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { lateinit var wv: WebView - val context = LocalContext.current - val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java) val permissionsState = rememberMultiplePermissionsState( permissions = listOf( Manifest.permission.CAMERA, @@ -42,6 +92,10 @@ fun VideoCallView(close: () -> Unit) { Manifest.permission.INTERNET ) ) + fun processCommand(cmd: WCallCommand) { + val apiCall = WVAPICall(command = cmd) + wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null) + } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -52,96 +106,83 @@ fun VideoCallView(close: () -> Unit) { lifecycleOwner.lifecycle.addObserver(observer) onDispose { - wv.evaluateJavascript("endCall()", null) + processCommand(WCallCommand.End()) lifecycleOwner.lifecycle.removeObserver(observer) } } - val localContext = LocalContext.current - val commandToShow = remember { mutableStateOf("processCommand({command: {type: 'start', media: 'video'}})") } //, aesKey: 'FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U='})") } + LaunchedEffect(callCommand.value) { + val cmd = callCommand.value + if (cmd != null) { + callCommand.value = null + processCommand(cmd) + } + } + val assetLoader = WebViewAssetLoader.Builder() - .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(localContext)) + .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current)) .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("{")) { - 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( + if (permissionsState.allPermissionsGranted) { + Box( Modifier .fillMaxWidth() - .padding(bottom = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween + .aspectRatio(ratio = 1F) ) { - 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 = "" - }) {Text("Clear")} + 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() + } + } + } + this.webViewClient = LocalContentWebViewClient(assetLoader) + this.clearHistory() + this.clearCache(true) + this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") + val webViewSettings = this.settings + webViewSettings.allowFileAccess = true + webViewSettings.allowContentAccess = true + webViewSettings.javaScriptEnabled = true + webViewSettings.mediaPlaybackRequiresUserGesture = false + webViewSettings.allowFileAccessFromFileURLs = true; + webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE + this.loadUrl("file:android_asset/www/call.html") + } + } + ) { + wv = it + // for debugging + // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) + wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) + } + } + } else { + Text("NEED PERMISSIONS") + } +} + +// for debugging +// class WebRTCInterface(private val onResponse: (String) -> Unit) { +class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) { + @JavascriptInterface + fun postMessage(message: String) { + Log.d(TAG, "WebRTCInterface.postMessage") + try { + // for debugging + // onResponse(message) + onResponse(json.decodeFromString(message)) + } catch (e: Error) { + Log.e(TAG, "failed parsing WebView message: $message") } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index 6484c1d42a..53b3ec4086 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -40,15 +40,15 @@ enum class CallState { } @Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand) -@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand?) +@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null) @Serializable sealed class WCallCommand { @Serializable @SerialName("capabilities") class Capabilities(): WCallCommand() @Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand() - @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallCommand() - @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: List): WCallCommand() - @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallCommand() + @Serializable @SerialName("offer") class Accept(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallCommand() + @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand() + @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand() @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("end") class End(): WCallCommand() } @@ -56,11 +56,11 @@ sealed class WCallCommand { @Serializable sealed class WCallResponse { @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse() - @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: List): WCallResponse() + @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String): WCallResponse() // TODO remove accept, it is needed for debugging - @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: List, val media: CallMediaType, val aesKey: String? = null): WCallResponse() - @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: List): WCallResponse() - @Serializable @SerialName("ice") class Ice(val iceCandidates: List): WCallResponse() + @Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallResponse() + @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse() + @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("ended") class Ended(): WCallResponse() @Serializable @SerialName("ok") class Ok(): WCallResponse() @@ -69,22 +69,23 @@ sealed class WCallResponse { } @Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) -@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: List) -@Serializable class WebRTCExtraInfo(val rtcIceCandidates: List) +@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) +@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String) @Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?) @Serializable class CallCapabilities(val encryption: Boolean) -enum class WebRTCCallStatus(val status: String) { - Connected("connected"), - Disconnected("disconnected"), - Failed("failed") +@Serializable +enum class WebRTCCallStatus { + @SerialName("connected") Connected, + @SerialName("disconnected") Disconnected, + @SerialName("failed") Failed } @Serializable -enum class CallMediaType(val media: String) { - Video("video"), - Audio("audio") +enum class CallMediaType { + @SerialName("video") Video, + @SerialName("audio") Audio } @Serializable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index b09b6e7484..acc37f9e06 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -208,8 +208,8 @@ fun SettingsLayout( Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) } Divider(Modifier.padding(horizontal = 8.dp)) -// SettingsSectionView(showVideoChatPrototype) { - SettingsSectionView() { + SettingsSectionView(showVideoChatPrototype) { +// SettingsSectionView() { Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } } diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift index 355cb15983..ad59e1cc36 100644 --- a/apps/ios/Shared/Model/Shared/CallTypes.swift +++ b/apps/ios/Shared/Model/Shared/CallTypes.swift @@ -15,11 +15,11 @@ struct WebRTCCallOffer: Encodable { struct WebRTCSession: Codable { var rtcSession: String - var rtcIceCandidates: [String] + var rtcIceCandidates: String } struct WebRTCExtraInfo: Codable { - var rtcIceCandidates: [String] + var rtcIceCandidates: String } struct CallInvitation { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b6573f1bbb..8a1ad8e928 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -382,18 +382,18 @@ func apiRejectCall(_ contact: Contact) async throws { try await sendCommandOkResp(.apiRejectCall(contact: contact)) } -func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String], media: CallMediaType, capabilities: CallCapabilities) async throws { +func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities) async throws { let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession) try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer)) } -func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: [String]) async throws { +func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws { let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) } -func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: [String]) async throws { +func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws { let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) } @@ -624,7 +624,7 @@ func processReceivedMsg(_ res: ChatResponse) { // TODO check encryption is compatible withCall(contact) { call in m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey) - m.callCommand = .accept(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey) + m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey) } case let .callAnswer(contact, answer): withCall(contact) { call in diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ec3842bb07..41845d72aa 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -97,18 +97,18 @@ struct WVAPICall: Encodable { var command: WCallCommand } -struct WVAPIMessage: Equatable, Decodable { +struct WVAPIMessage: Equatable, Decodable, Encodable { var corrId: Int? var resp: WCallResponse var command: WCallCommand? } enum WCallCommand: Equatable, Encodable, Decodable { - 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 capabilities(useWorker: Bool? = nil) + case start(media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil) + case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, useWorker: Bool? = nil) + case answer(answer: String, iceCandidates: String) + case ice(iceCandidates: String) case media(media: CallMediaType, enable: Bool) case end @@ -116,6 +116,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case type case media case aesKey + case useWorker case offer case answer case iceCandidates @@ -127,7 +128,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { switch self { case .capabilities: return "capabilities" case .start: return "start" - case .accept: return "accept" + case .offer: return "offer" case .answer: return "answer" case .ice: return "ice" case .media: return "media" @@ -139,18 +140,21 @@ enum WCallCommand: Equatable, Encodable, Decodable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .capabilities: + case let .capabilities(useWorker): try container.encode("capabilities", forKey: .type) - case let .start(media, aesKey): + try container.encode(useWorker, forKey: .useWorker) + case let .start(media, aesKey, useWorker): 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(useWorker, forKey: .useWorker) + case let .offer(offer, iceCandidates, media, aesKey, useWorker): 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) + try container.encode(useWorker, forKey: .useWorker) case let .answer(answer, iceCandidates): try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) @@ -172,23 +176,26 @@ enum WCallCommand: Equatable, Encodable, Decodable { let type = try container.decode(String.self, forKey: CodingKeys.type) switch type { case "capabilities": - self = .capabilities + let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker) + self = .capabilities(useWorker: useWorker) 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 useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker) + self = .start(media: media, aesKey: aesKey, useWorker: useWorker) + case "offer": let offer = try container.decode(String.self, forKey: CodingKeys.offer) - let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + 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) + let useWorker = try container.decode((Bool?).self, forKey: CodingKeys.useWorker) + self = .offer(offer: offer, iceCandidates: iceCandidates, media: media, aesKey: aesKey, useWorker: useWorker) case "answer": let answer = try container.decode(String.self, forKey: CodingKeys.answer) - let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + 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) + let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) self = .ice(iceCandidates: iceCandidates) case "media": let media = try container.decode(CallMediaType.self, forKey: CodingKeys.media) @@ -205,11 +212,11 @@ enum WCallCommand: Equatable, Encodable, Decodable { enum WCallResponse: Equatable, Decodable { case capabilities(capabilities: CallCapabilities) - case offer(offer: String, iceCandidates: [String], capabilities: CallCapabilities) + case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities) // TODO remove accept, it is needed for debugging -// case accept(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) - case answer(answer: String, iceCandidates: [String]) - case ice(iceCandidates: [String]) +// case offer(offer: String, iceCandidates: [String], media: CallMediaType, aesKey: String? = nil) + case answer(answer: String, iceCandidates: String) + case ice(iceCandidates: String) case connection(state: ConnectionState) case ended case ok @@ -234,7 +241,6 @@ enum WCallResponse: Equatable, Decodable { switch self { case .capabilities: return("capabilities") case .offer: return("offer") -// case .accept: return("accept") case .answer: return("answer (TODO remove)") case .ice: return("ice") case .connection: return("connection") @@ -256,15 +262,15 @@ enum WCallResponse: Equatable, Decodable { 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) + let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) let capabilities = try container.decode(CallCapabilities.self, forKey: CodingKeys.capabilities) self = .offer(offer: offer, iceCandidates: iceCandidates, capabilities: capabilities) case "answer": let answer = try container.decode(String.self, forKey: CodingKeys.answer) - let iceCandidates = try container.decode([String].self, forKey: CodingKeys.iceCandidates) + 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) + 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) @@ -286,39 +292,39 @@ enum WCallResponse: Equatable, Decodable { } // This protocol is 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, capabilities): -// try container.encode("offer", forKey: .type) -// try container.encode(offer, forKey: .offer) -// try container.encode(iceCandidates, forKey: .iceCandidates) -// try container.encode(capabilities, forKey: .capabilities) -// 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) -// } -// } -//} +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, capabilities): + try container.encode("offer", forKey: .type) + try container.encode(offer, forKey: .offer) + try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encode(capabilities, forKey: .capabilities) + 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) + } + } +} struct ConnectionState: Codable, Equatable { var connectionState: String diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift index 1dad35e67c..39970f2513 100644 --- a/apps/ios/Shared/Views/Call/WebRTCView.swift +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -31,6 +31,7 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler didReceive message: WKScriptMessage ) { logger.debug("WebRTCCoordinator.userContentController") + logger.debug("\(String(describing: message.body as? String))") if let msgStr = message.body as? String, let msg: WVAPIMessage = decodeJSON(msgStr) { webViewMsg.wrappedValue = msg @@ -90,74 +91,76 @@ struct WebRTCView: UIViewRepresentable { } } -//struct CallViewDebug: View { -// @State private var coordinator: WebRTCCoordinator? = nil -// @State private var commandStr = "" -// @State private var webViewReady: Bool = false -// @State private var webViewMsg: WVAPIMessage? = nil -// @FocusState private var keyboardVisible: Bool -// -// var body: some View { -// VStack(spacing: 30) { -// WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260) -// .onChange(of: webViewMsg) { _ in -// if let resp = webViewMsg { -// commandStr = encodeJSON(resp) -// } -// } -// 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") { -// if let c = coordinator, -// let command: WCallCommand = decodeJSON(commandStr) { -// c.sendCommand(command: command) -// } -// } -// } -// HStack(spacing: 20) { -// Button("Capabilities") { -// -// } -// Button("Start") { -// if let c = coordinator { -// c.sendCommand(command: .start(media: .video)) -// } -// } -// Button("Accept") { -// -// } -// Button("Answer") { -// -// } -// Button("ICE") { -// -// } -// Button("End") { -// -// } -// } -// } -// } -//} +struct CallViewDebug: View { + @State private var coordinator: WebRTCCoordinator? = nil + @State private var commandStr = "" + @State private var webViewReady: Bool = false + @State private var webViewMsg: WVAPIMessage? = nil + @FocusState private var keyboardVisible: Bool + + var body: some View { + VStack(spacing: 30) { + WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg).frame(maxHeight: 260) + .onChange(of: webViewMsg) { _ in + if let resp = webViewMsg { + commandStr = encodeJSON(resp) + } + } + 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") { + if let c = coordinator, + let command: WCallCommand = decodeJSON(commandStr) { + c.sendCommand(command: command) + } + } + } + HStack(spacing: 20) { + Button("Capabilities") { + if let c = coordinator { + c.sendCommand(command: .capabilities(useWorker: true)) + } + } + Button("Start") { + if let c = coordinator { + c.sendCommand(command: .start(media: .video)) + } + } + Button("Accept") { + + } + Button("Answer") { + + } + Button("ICE") { + + } + Button("End") { + + } + } + } + } +} // //struct CallViewDebug_Previews: PreviewProvider { // static var previews: some View { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index f9c313863a..10e21774c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -126,7 +126,7 @@ struct ChatView: View { localMedia: media ) showCallView = true - chatModel.callCommand = .capabilities + chatModel.callCommand = .capabilities(useWorker: true) } label: { Image(systemName: imageName) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 05af85086b..3119c3a77d 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -126,12 +126,12 @@ struct SettingsView: View { // notificationsToggle(token) // } // } -// NavigationLink { -// CallViewDebug() -// .frame(maxHeight: .infinity, alignment: .top) -// } label: { + NavigationLink { + CallViewDebug() + .frame(maxHeight: .infinity, alignment: .top) + } label: { Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") -// } + } } } .navigationTitle("Your settings") diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy index 9175480911..1b766264fb 100755 --- a/packages/simplex-chat-webrtc/copy +++ b/packages/simplex-chat-webrtc/copy @@ -3,8 +3,12 @@ # it can be tested in the browser from dist folder cp ./src/call.html ./dist/call.html cp ./src/style.css ./dist/style.css +cp ./node_modules/lz-string/libs/lz-string.min.js ./dist/lz-string.min.js +cp ./src/webcall.html ./dist/webcall.html +cp ./src/ui.js ./dist/ui.js # 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 +cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/android/app/src/main/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index 222c0c0a56..86fe6c0fb0 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -14,6 +14,7 @@ "author": "", "license": "AGPL-3.0-or-later", "devDependencies": { + "@types/lz-string": "^1.3.34", "husky": "^7.0.4", "lint-staged": "^12.4.1", "prettier": "^2.6.2", @@ -21,5 +22,8 @@ }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "lz-string": "^1.4.4" } } diff --git a/packages/simplex-chat-webrtc/src/call.html b/packages/simplex-chat-webrtc/src/call.html index fd3019e8eb..a188d38394 100644 --- a/packages/simplex-chat-webrtc/src/call.html +++ b/packages/simplex-chat-webrtc/src/call.html @@ -2,6 +2,7 @@ + diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 5c0678b6e2..b5fa306870 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -1,11 +1,6 @@ // Inspired by // https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption -interface WVAPICall { - corrId?: number - command: WCallCommand -} - interface WVApiMessage { corrId?: number resp: WCallResponse @@ -14,9 +9,18 @@ interface WVApiMessage { type WCallCommand = WCCapabilities | WCStartCall | WCAcceptOffer | WCallAnswer | WCallIceCandidates | WCEnableMedia | WCEndCall -type WCallResponse = WRCapabilities | WCallOffer | WCallAnswer | WCallIceCandidates | WRConnection | WRCallEnded | WROk | WRError +type WCallResponse = + | WRCapabilities + | WCallOffer + | WCallAnswer + | WCallIceCandidates + | WRConnection + | WRCallEnded + | WROk + | WRError + | WCAcceptOffer -type WCallCommandTag = "capabilities" | "start" | "accept" | "answer" | "ice" | "media" | "end" +type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "end" type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "ended" | "ok" | "error" @@ -35,12 +39,14 @@ interface IWCallResponse { interface WCCapabilities extends IWCallCommand { type: "capabilities" + useWorker?: boolean } interface WCStartCall extends IWCallCommand { type: "start" media: CallMediaType aesKey?: string + useWorker?: boolean } interface WCEndCall extends IWCallCommand { @@ -48,29 +54,30 @@ interface WCEndCall extends IWCallCommand { } interface WCAcceptOffer extends IWCallCommand { - type: "accept" + type: "offer" offer: string // JSON string for RTCSessionDescriptionInit - iceCandidates: string[] // JSON strings for RTCIceCandidateInit + iceCandidates: string // JSON strings for RTCIceCandidateInit media: CallMediaType aesKey?: string + useWorker?: boolean } interface WCallOffer extends IWCallResponse { type: "offer" offer: string // JSON string for RTCSessionDescriptionInit - iceCandidates: string[] // JSON strings for RTCIceCandidateInit + iceCandidates: string // JSON strings for RTCIceCandidateInit[] capabilities: CallCapabilities } interface WCallAnswer extends IWCallCommand, IWCallResponse { type: "answer" answer: string // JSON string for RTCSessionDescriptionInit - iceCandidates: string[] // JSON strings for RTCIceCandidateInit + iceCandidates: string // JSON strings for RTCIceCandidateInit[] } interface WCallIceCandidates extends IWCallCommand, IWCallResponse { type: "ice" - iceCandidates: string[] // JSON strings for RTCIceCandidateInit + iceCandidates: string // JSON strings for RTCIceCandidateInit[] } interface WCEnableMedia extends IWCallCommand { @@ -111,513 +118,643 @@ interface WRError extends IWCallResponse { 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 activeCall: Call | undefined - -const IV_LENGTH = 12 - -const initialPlainTextRequired = { - key: 10, - delta: 3, - undefined: 1, -} - -interface Call { - connection: RTCPeerConnection - iceCandidates: Promise // JSON strings for RTCIceCandidate - localMedia: CallMediaType - localStream: MediaStream -} - -interface CallConfig { - peerConnectionConfig: RTCConfigurationWithEncryption - iceCandidates: { - delay: number - extrasInterval: number - extrasTimeout: number - } -} - -function defaultCallConfig(encodedInsertableStreams: boolean): CallConfig { - return { - peerConnectionConfig: { - iceServers: [{urls: ["stun:stun.l.google.com:19302"]}], - iceCandidatePoolSize: 10, - encodedInsertableStreams, - }, - iceCandidates: { - delay: 2000, - extrasInterval: 2000, - extrasTimeout: 8000, - }, - } -} - -async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { - const conn = new RTCPeerConnection(config.peerConnectionConfig) - const remoteStream = new MediaStream() - const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)) - await setUpMediaStreams(conn, localStream, remoteStream, aesKey) - conn.addEventListener("connectionstatechange", connectionStateChange) - const iceCandidates = new Promise((resolve, _) => { - let candidates: RTCIceCandidate[] = [] - let resolved = false - let extrasInterval: number | undefined - let extrasTimeout: number | undefined - const delay = setTimeout(() => { - if (!resolved) { - resolveIceCandidates() - extrasInterval = setInterval(() => { - sendIceCandidates() - }, config.iceCandidates.extrasInterval) - extrasTimeout = setTimeout(() => { - clearInterval(extrasInterval) - sendIceCandidates() - }, config.iceCandidates.extrasTimeout) - } - }, config.iceCandidates.delay) - - conn.onicecandidate = ({candidate: c}) => c && candidates.push(c) - conn.onicegatheringstatechange = () => { - if (conn.iceGatheringState == "complete") { - if (resolved) { - if (extrasInterval) clearInterval(extrasInterval) - if (extrasTimeout) clearTimeout(extrasTimeout) - sendIceCandidates() - } else { - resolveIceCandidates() - } - } - } - - function resolveIceCandidates() { - if (delay) clearTimeout(delay) - resolved = true - const iceCandidates = candidates.map((c) => JSON.stringify(c)) - candidates = [] - resolve(iceCandidates) - } - - function sendIceCandidates() { - if (candidates.length === 0) return - const iceCandidates = candidates.map((c) => JSON.stringify(c)) - candidates = [] - sendMessageToNative({resp: {type: "ice", iceCandidates}}) - } - }) - - return {connection: conn, iceCandidates, localMedia: mediaType, localStream} - - 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() - activeCall = undefined - resetVideoElements() - } - } -} - +// for debugging +// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp})) var sendMessageToNative = (msg: WVApiMessage) => console.log(JSON.stringify(msg)) -async function processCommand(body: WVAPICall): Promise { - const {corrId, command} = body - const pc = activeCall?.connection - let resp: WCallResponse - try { - switch (command.type) { - case "capabilities": - const encryption = supportsInsertableStreams() - resp = {type: "capabilities", capabilities: {encryption}} - break - case "start": - console.log("starting call") - if (activeCall) { - resp = {type: "error", message: "start: call already started"} - } else if (!supportsInsertableStreams() && command.aesKey) { - resp = {type: "error", message: "start: encryption is not supported"} - } else { - const encryption = supportsInsertableStreams() - const {media, aesKey} = command - activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, encryption ? aesKey : undefined) - const pc = activeCall.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: JSON.stringify(offer), - iceCandidates: await activeCall.iceCandidates, - capabilities: {encryption}, - } - } - break - case "accept": - if (activeCall) { - 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)) - activeCall = await initializeCall(defaultCallConfig(!!command.aesKey), command.media, command.aesKey) - const pc = activeCall.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 activeCall.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 "media": - if (!activeCall) { - resp = {type: "error", message: "media: call not started"} - } else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { - resp = {type: "error", message: "media: no video"} - } else { - enableMedia(activeCall.localStream, command.media, command.enable) - resp = {type: "ok"} - } - break - case "end": - if (pc) { - pc.close() - activeCall = 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 = {corrId, resp, command} - sendMessageToNative(apiResp) - return apiResp +// Global object with cryptrographic/encoding functions +const callCrypto = callCryptoFunction() + +declare var RTCRtpScriptTransform: { + prototype: RTCRtpScriptTransform + new (worker: Worker, options?: any): RTCRtpScriptTransform } -function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { - for (const c of iceCandidates) { - conn.addIceCandidate(new RTCIceCandidate(c)) - } +enum TransformOperation { + Encrypt = "encrypt", + Decrypt = "decrypt", } -async function setUpMediaStreams( - pc: RTCPeerConnection, - localStream: MediaStream, - remoteStream: MediaStream, - aesKey?: string -): Promise { - const videos = getVideoElements() - if (!videos) throw Error("no video elements") +interface RTCRtpScriptTransform {} - let key: CryptoKey | undefined - if (aesKey) { - const keyData = decodeBase64(encodeAscii(aesKey)) - if (keyData) key = await crypto.subtle.importKey("raw", keyData, keyAlgorithm, false, keyUsages) +;(function () { + interface WVAPICall { + corrId?: number + command: WCallCommand } - 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) + type RTCRtpSenderWithEncryption = RTCRtpSender & { + createEncodedStreams: () => TransformStream + transform: RTCRtpScriptTransform + } + + type RTCRtpReceiverWithEncryption = RTCRtpReceiver & { + createEncodedStreams: () => TransformStream + transform: RTCRtpScriptTransform + } + + type RTCConfigurationWithEncryption = RTCConfiguration & { + encodedInsertableStreams: boolean + } + + interface Call { + connection: RTCPeerConnection + iceCandidates: Promise // JSON strings for RTCIceCandidate + localMedia: CallMediaType + localStream: MediaStream + } + + interface CallConfig { + peerConnectionConfig: RTCConfigurationWithEncryption + iceCandidates: { + delay: number + extrasInterval: number + extrasTimeout: number + } + } + + let activeCall: Call | undefined + + function defaultCallConfig(encodedInsertableStreams: boolean): CallConfig { + return { + peerConnectionConfig: { + iceServers: [ + {urls: "stun:stun.simplex.chat:5349"}, + // {urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"}, + ], + iceCandidatePoolSize: 10, + encodedInsertableStreams, + // iceTransportPolicy: "relay", + }, + iceCandidates: { + delay: 2000, + extrasInterval: 2000, + extrasTimeout: 8000, + }, + } + } + + async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string, useWorker?: boolean): Promise { + const conn = new RTCPeerConnection(config.peerConnectionConfig) + const remoteStream = new MediaStream() + const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType)) + await setUpMediaStreams(conn, localStream, remoteStream, aesKey, useWorker) + conn.addEventListener("connectionstatechange", connectionStateChange) + const iceCandidates = new Promise((resolve, _) => { + let candidates: RTCIceCandidate[] = [] + let resolved = false + let extrasInterval: number | undefined + let extrasTimeout: number | undefined + const delay = setTimeout(() => { + if (!resolved) { + resolveIceCandidates() + extrasInterval = setInterval(() => { + sendIceCandidates() + }, config.iceCandidates.extrasInterval) + extrasTimeout = setTimeout(() => { + clearInterval(extrasInterval) + sendIceCandidates() + }, config.iceCandidates.extrasTimeout) + } + }, config.iceCandidates.delay) + + conn.onicecandidate = ({candidate: c}) => c && candidates.push(c) + conn.onicegatheringstatechange = () => { + if (conn.iceGatheringState == "complete") { + if (resolved) { + if (extrasInterval) clearInterval(extrasInterval) + if (extrasTimeout) clearTimeout(extrasTimeout) + sendIceCandidates() + } else { + resolveIceCandidates() + } + } } - } - } - // 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, + function resolveIceCandidates() { + if (delay) clearTimeout(delay) + resolved = true + const iceCandidates = serialize(candidates) + candidates = [] + resolve(iceCandidates) + } + + function sendIceCandidates() { + if (candidates.length === 0) return + const iceCandidates = serialize(candidates) + candidates = [] + sendMessageToNative({resp: {type: "ice", iceCandidates}}) + } + }) + + return {connection: conn, iceCandidates, localMedia: mediaType, localStream} + + function connectionStateChange() { + sendMessageToNative({ + resp: { + type: "connection", + state: { + connectionState: conn.connectionState, + iceConnectionState: conn.iceConnectionState, + iceGatheringState: conn.iceGatheringState, + signalingState: conn.signalingState, }, - aspectRatio: 1.33, }, + }) + if (conn.connectionState == "disconnected" || conn.connectionState == "failed") { + conn.removeEventListener("connectionstatechange", connectionStateChange) + sendMessageToNative({resp: {type: "ended"}}) + conn.close() + activeCall = undefined + resetVideoElements() } - } -} - -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" -// } -// } - -function enableMedia(s: MediaStream, media: CallMediaType, enable: boolean) { - const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() - for (const t of tracks) t.enabled = enable -} - -/* Stream Transforms */ -function setupPeerTransform( - peer: RTCRtpSenderWithEncryption | RTCRtpReceiverWithEncryption, - transform: (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void -) { - const streams = peer.createEncodedStreams() - streams.readable.pipeThrough(new TransformStream({transform})).pipeTo(streams.writable) -} - -/* Cryptography */ -function encodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => void { - return async (frame, controller) => { - const data = new Uint8Array(frame.data) - const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 - const iv = randomIV() - const initial = data.subarray(0, n) - const plaintext = data.subarray(n, data.byteLength) - try { - const ciphertext = await crypto.subtle.encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) - frame.data = concatN(initial, new Uint8Array(ciphertext), iv).buffer - controller.enqueue(frame) - } catch (e) { - console.log(`encryption error ${e}`) - throw e } } -} -function decodeFunction(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise { - return async (frame, controller) => { - const data = new Uint8Array(frame.data) - const n = frame instanceof RTCEncodedVideoFrame ? initialPlainTextRequired[frame.type] : 0 - const initial = data.subarray(0, n) - const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH) - const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength) + function serialize(x: T): string { + return LZString.compressToBase64(JSON.stringify(x)) + } + + function parse(s: string): T { + return JSON.parse(LZString.decompressFromBase64(s)!) + } + + Object.defineProperty(window, "processCommand", {value: processCommand}) + + async function processCommand(body: WVAPICall): Promise { + const {corrId, command} = body + const pc = activeCall?.connection + let resp: WCallResponse try { - const plaintext = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ciphertext) - frame.data = concatN(initial, new Uint8Array(plaintext)).buffer - controller.enqueue(frame) + switch (command.type) { + case "capabilities": + const encryption = supportsInsertableStreams(command.useWorker) + resp = {type: "capabilities", capabilities: {encryption}} + break + case "start": + console.log("starting call") + if (activeCall) { + resp = {type: "error", message: "start: call already started"} + } else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + resp = {type: "error", message: "start: encryption is not supported"} + } else { + const {media, useWorker} = command + const encryption = supportsInsertableStreams(useWorker) + const aesKey = encryption ? command.aesKey : undefined + activeCall = await initializeCall(defaultCallConfig(encryption && !!aesKey), media, aesKey, useWorker) + const pc = activeCall.connection + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + // for debugging, returning the command for callee to use + // resp = { + // type: "offer", + // offer: serialize(offer), + // iceCandidates: await activeCall.iceCandidates, + // media, + // aesKey, + // } + resp = { + type: "offer", + offer: serialize(offer), + iceCandidates: await activeCall.iceCandidates, + capabilities: {encryption}, + } + } + break + case "offer": + if (activeCall) { + resp = {type: "error", message: "accept: call already started"} + } else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + resp = {type: "error", message: "accept: encryption is not supported"} + } else { + const offer: RTCSessionDescriptionInit = parse(command.offer) + const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + const {media, aesKey, useWorker} = command + activeCall = await initializeCall(defaultCallConfig(!!aesKey), media, aesKey, useWorker) + const pc = activeCall.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: serialize(answer), + iceCandidates: await activeCall.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: RTCSessionDescriptionInit = parse(command.answer) + const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + await pc.setRemoteDescription(new RTCSessionDescription(answer)) + addIceCandidates(pc, remoteIceCandidates) + resp = {type: "ok"} + } + break + case "ice": + if (pc) { + const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + addIceCandidates(pc, remoteIceCandidates) + resp = {type: "ok"} + } else { + resp = {type: "error", message: "ice: call not started"} + } + break + case "media": + if (!activeCall) { + resp = {type: "error", message: "media: call not started"} + } else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) { + resp = {type: "error", message: "media: no video"} + } else { + enableMedia(activeCall.localStream, command.media, command.enable) + resp = {type: "ok"} + } + break + case "end": + if (pc) { + pc.close() + activeCall = 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) { - console.log(`decryption error ${e}`) - throw e + resp = {type: "error", message: (e as Error).message} + } + const apiResp = {corrId, resp, command} + sendMessageToNative(apiResp) + return apiResp + } + + function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { + for (const c of iceCandidates) { + conn.addIceCandidate(new RTCIceCandidate(c)) } } -} -class RTCEncodedVideoFrame { - constructor(public type: "key" | "delta", public data: ArrayBuffer) {} -} + async function setUpMediaStreams( + pc: RTCPeerConnection, + localStream: MediaStream, + remoteStream: MediaStream, + aesKey?: string, + useWorker?: boolean + ): Promise { + const videos = getVideoElements() + if (!videos) throw Error("no video elements") -function randomIV() { - return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) -} + let key: CryptoKey | undefined + let worker: Worker | undefined + if (aesKey) { + key = await callCrypto.decodeAesKey(aesKey) + if (useWorker) { + const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` + worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) + } + } -const char_equal = "=".charCodeAt(0) + for (const track of localStream.getTracks()) { + pc.addTrack(track, localStream) + } -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 -} + if (aesKey && key) { + console.log("set up encryption for sending") + for (const sender of pc.getSenders() as RTCRtpSenderWithEncryption[]) { + setupPeerTransform(TransformOperation.Encrypt, sender, worker, aesKey, key) + } + } -function encodeAscii(s: string): Uint8Array { - const a = new Uint8Array(s.length) - let i = s.length - while (i--) a[i] = s.charCodeAt(i) - return a -} + // Pull tracks from remote stream as they arrive add them to remoteStream video + pc.ontrack = (event) => { + if (aesKey && key) { + console.log("set up decryption for receiving") + setupPeerTransform(TransformOperation.Decrypt, event.receiver as RTCRtpReceiverWithEncryption, worker, aesKey, key) + } + remoteStream.addTrack(event.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 -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] + 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 } - if (len % 3) b64[b64len - 1] = char_equal - if (len % 3 === 1) b64[b64len - 2] = char_equal + function setupPeerTransform( + operation: TransformOperation, + peer: RTCRtpReceiverWithEncryption | RTCRtpSenderWithEncryption, + worker: Worker | undefined, + aesKey: string, + key: CryptoKey + ) { + if (worker && "RTCRtpScriptTransform" in window) { + console.log(`${operation} with worker & RTCRtpScriptTransform`) + peer.transform = new RTCRtpScriptTransform(worker, {operation, aesKey}) + } else if ("createEncodedStreams" in peer) { + const {readable, writable} = peer.createEncodedStreams() + if (worker) { + console.log(`${operation} with worker`) + worker.postMessage({operation, readable, writable, aesKey}, [readable, writable] as unknown as Transferable[]) + } else { + console.log(`${operation} without worker`) + const transform = callCrypto.transformFrame[operation](key) + readable.pipeThrough(new TransformStream({transform})).pipeTo(writable) + } + } else { + console.log(`no ${operation}`) + } + } - return b64 + 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(useWorker: boolean | undefined): boolean { + return ( + ("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) || + (!!useWorker && "RTCRtpScriptTransform" in window) + ) + } + + 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" + // } + // } + + function enableMedia(s: MediaStream, media: CallMediaType, enable: boolean) { + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) t.enabled = enable + } +})() + +type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise + +interface CallCrypto { + transformFrame: {[x in TransformOperation]: TransformFrameFunc} + decodeAesKey: (aesKey: string) => Promise + encodeAscii: (s: string) => Uint8Array + decodeAscii: (a: Uint8Array) => string + encodeBase64: (a: Uint8Array) => Uint8Array + decodeBase64: (b64: Uint8Array) => Uint8Array | undefined } -function decodeBase64(b64: Uint8Array): Uint8Array | undefined { - let len = b64.length - if (len % 4) return - let bLen = (len * 3) / 4 +interface RTCEncodedVideoFrame { + type: "key" | "delta" + data: ArrayBuffer +} + +// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) +function callCryptoFunction(): CallCrypto { + const initialPlainTextRequired = { + key: 10, + delta: 3, + } + + const IV_LENGTH = 12 + + function encryptFrame(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise { + return async (frame, controller) => { + const data = new Uint8Array(frame.data) + const n = initialPlainTextRequired[frame.type] || 1 + 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 decryptFrame(key: CryptoKey): (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise { + return async (frame, controller) => { + const data = new Uint8Array(frame.data) + const n = initialPlainTextRequired[frame.type] || 1 + 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 + } + } + } + + function decodeAesKey(aesKey: string): Promise { + const keyData = callCrypto.decodeBase64(callCrypto.encodeAscii(aesKey)) + return crypto.subtle.importKey("raw", keyData!, {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"]) + } + + 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 randomIV() { + return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + } + + 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)) + + const char_equal = "=".charCodeAt(0) + + 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 + } + + 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-- + 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 } - 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 { + transformFrame: {encrypt: encryptFrame, decrypt: decryptFrame}, + decodeAesKey, + encodeAscii, + decodeAscii, + encodeBase64, + decodeBase64, + } +} + +// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob +// We have to use worker optionally, as it crashes in Android web view, regardless of how it is loaded +function workerFunction() { + interface WorkerMessage { + data: Transform + } + + interface Transform { + operation: TransformOperation + readable: ReadableStream + writable: WritableStream + aesKey: string + } + + // encryption with createEncodedStreams support + self.addEventListener("message", async ({data}: WorkerMessage) => { + await setupTransform(data) + }) + + // encryption using RTCRtpScriptTransform. + if ("RTCTransformEvent" in self) { + self.addEventListener("rtctransform", async ({transformer}: any) => { + const {operation, aesKey} = transformer.options + const {readable, writable} = transformer + await setupTransform({operation, aesKey, readable, writable}) + }) + } + + async function setupTransform({operation, aesKey, readable, writable}: Transform): Promise { + const key = await callCrypto.decodeAesKey(aesKey) + const transform = callCrypto.transformFrame[operation](key) + readable.pipeThrough(new TransformStream({transform})).pipeTo(writable) } - - return bytes } diff --git a/packages/simplex-chat-webrtc/src/ui.js b/packages/simplex-chat-webrtc/src/ui.js new file mode 100644 index 0000000000..09b413763c --- /dev/null +++ b/packages/simplex-chat-webrtc/src/ui.js @@ -0,0 +1,115 @@ +;(async function run() { + const START_E2EE_CALL_BTN = "start-e2ee-call" + const START_CALL_BTN = "start-call" + const URL_FOR_PEER = "url-for-peer" + const COPY_URL_FOR_PEER_BTN = "copy-url-for-peer" + const DATA_FOR_PEER = "data-for-peer" + const COPY_DATA_FOR_PEER_BTN = "copy-data-for-peer" + const PASS_DATA_TO_PEER_TEXT = "pass-data-to-peer" + const CHAT_COMMAND_FOR_PEER = "chat-command-for-peer" + const COMMAND_TO_PROCESS = "command-to-process" + const PROCESS_COMMAND_BTN = "process-command" + const urlForPeer = document.getElementById(URL_FOR_PEER) + const dataForPeer = document.getElementById(DATA_FOR_PEER) + const passDataToPeerText = document.getElementById(PASS_DATA_TO_PEER_TEXT) + const chatCommandForPeer = document.getElementById(CHAT_COMMAND_FOR_PEER) + const commandToProcess = document.getElementById(COMMAND_TO_PROCESS) + const processCommandButton = document.getElementById(PROCESS_COMMAND_BTN) + const startE2EECallButton = document.getElementById(START_E2EE_CALL_BTN) + const {resp} = await processCommand({command: {type: "capabilities", useWorker: true}}) + if (resp?.capabilities?.encryption) { + startE2EECallButton.onclick = startCall(true) + } else { + startE2EECallButton.style.display = "none" + } + const startCallButton = document.getElementById(START_CALL_BTN) + startCallButton.onclick = startCall() + const copyUrlButton = document.getElementById(COPY_URL_FOR_PEER_BTN) + copyUrlButton.onclick = () => { + navigator.clipboard.writeText(urlForPeer.innerText) + commandToProcess.style.display = "" + processCommandButton.style.display = "" + } + const copyDataButton = document.getElementById(COPY_DATA_FOR_PEER_BTN) + copyDataButton.onclick = () => { + navigator.clipboard.writeText(dataForPeer.innerText) + commandToProcess.style.display = "" + processCommandButton.style.display = "" + } + processCommandButton.onclick = () => { + sendCommand(JSON.parse(commandToProcess.value)) + commandToProcess.value = "" + } + const parsed = new URLSearchParams(document.location.hash.substring(1)) + let apiCallStr = parsed.get("command") + if (apiCallStr) { + startE2EECallButton.style.display = "none" + startCallButton.style.display = "none" + await sendCommand(JSON.parse(decodeURIComponent(apiCallStr))) + } + + function startCall(encryption) { + return async () => { + let aesKey + if (encryption) { + const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]) + const keyBytes = await crypto.subtle.exportKey("raw", key) + aesKey = callCrypto.decodeAscii(callCrypto.encodeBase64(new Uint8Array(keyBytes))) + } + sendCommand({command: {type: "start", media: "video", aesKey, useWorker: true}}) + startE2EECallButton.style.display = "none" + startCallButton.style.display = "none" + } + } + + async function sendCommand(apiCall) { + try { + console.log(apiCall) + const {command} = apiCall + const {resp} = await processCommand(apiCall) + console.log(resp) + switch (resp.type) { + case "offer": { + const {media, aesKey} = command + const {offer, iceCandidates, capabilities} = resp + const peerWCommand = { + command: {type: "offer", offer, iceCandidates, media, aesKey: capabilities.encryption ? aesKey : undefined, useWorker: true}, + } + const url = new URL(document.location) + parsed.set("command", encodeURIComponent(JSON.stringify(peerWCommand))) + url.hash = parsed.toString() + urlForPeer.innerText = url.toString() + dataForPeer.innerText = JSON.stringify(peerWCommand) + copyUrlButton.style.display = "" + copyDataButton.style.display = "" + + // const webRTCCallOffer = {callType: {media, capabilities}, rtcSession: {rtcSession: offer, rtcIceCandidates: iceCandidates}} + // const peerChatCommand = `/_call @${parsed.contact} offer ${JSON.stringify(webRTCCallOffer)}` + // chatCommandForPeer.innerText = peerChatCommand + return + } + case "answer": { + const {answer, iceCandidates} = resp + const peerWCommand = {command: {type: "answer", answer, iceCandidates}} + dataForPeer.innerText = JSON.stringify(peerWCommand) + copyUrlButton.style.display = "none" + copyDataButton.style.display = "" + + // const webRTCSession = {rtcSession: answer, rtcIceCandidates: iceCandidates} + // const peerChatCommand = `/_call @${parsed.contact} answer ${JSON.stringify(webRTCSession)}` + // chatCommandForPeer.innerText = peerChatCommand + return + } + case "ok": + if ((command.type = "answer")) { + console.log("connecting") + commandToProcess.style.display = "none" + processCommandButton.style.display = "none" + } + return + } + } catch (e) { + console.log("error: ", e) + } + } +})() diff --git a/packages/simplex-chat-webrtc/src/webcall.html b/packages/simplex-chat-webrtc/src/webcall.html new file mode 100644 index 0000000000..e648df8537 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/webcall.html @@ -0,0 +1,51 @@ + + + + + + + + + + +
+
+ + + + + +
+
+ +
+
+ +
+ + + +
+ +
+ + +
+ diff --git a/packages/simplex-chat-webrtc/tsconfig.json b/packages/simplex-chat-webrtc/tsconfig.json index d4d0acb3d0..061f01e8c6 100644 --- a/packages/simplex-chat-webrtc/tsconfig.json +++ b/packages/simplex-chat-webrtc/tsconfig.json @@ -16,6 +16,7 @@ "sourceMap": true, "strict": true, "strictNullChecks": true, - "target": "ES2018" + "target": "ES2018", + "types": ["lz-string"] } } diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index a0f3707dd4..c4577c6b1c 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -177,8 +177,8 @@ instance ToJSON CallExtraInfo where toEncoding = J.genericToEncoding J.defaultOptions data WebRTCSession = WebRTCSession - { rtcSession :: Text, - rtcIceCandidates :: [Text] + { rtcSession :: Text, -- LZW compressed JSON encoding of offer or answer + rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } deriving (Eq, Show, Generic, FromJSON) @@ -187,7 +187,7 @@ instance ToJSON WebRTCSession where toEncoding = J.genericToEncoding J.defaultOptions data WebRTCExtraInfo = WebRTCExtraInfo - { rtcIceCandidates :: [Text] + { rtcIceCandidates :: Text -- LZW compressed JSON encoding of array of ICE candidates } deriving (Eq, Show, Generic, FromJSON) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index f3ad49831c..254da1eb0a 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1870,7 +1870,7 @@ testWebRTCSession :: WebRTCSession testWebRTCSession = WebRTCSession { rtcSession = "{}", - rtcIceCandidates = [""] + rtcIceCandidates = "[]" } testWebRTCCallOffer :: WebRTCCallOffer From 84bf815e5cf2411a5636a12a66f8a8c98b996c69 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 17 May 2022 11:22:09 +0400 Subject: [PATCH 04/32] core: fully delete direct chat items; clear chat API (#658) --- src/Simplex/Chat.hs | 119 +++++++++++++++++++++--------- src/Simplex/Chat/Controller.hs | 5 ++ src/Simplex/Chat/Messages.hs | 15 ++++ src/Simplex/Chat/Store.hs | 131 +++++++++++++++++++++++---------- src/Simplex/Chat/View.hs | 14 +++- tests/ChatTests.hs | 67 ++++++++++++----- 6 files changed, 255 insertions(+), 96 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 912a57bf20..41e06b0bdc 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -334,15 +334,16 @@ processChatCommand = \case case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do deleteCIFile user file - toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId + toCi <- withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMInternal pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do - SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) + void $ sendDirectContactMessage ct (XMsgDel itemSharedMId) deleteCIFile user file - toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId + toCi <- withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMBroadcast setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete + -- TODO for group integrity and pending messages, group items and messages are set to "deleted"; maybe a different workaround is needed CTGroup -> do Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved @@ -365,9 +366,9 @@ processChatCommand = \case deleteCIFile :: MsgDirectionI d => User -> Maybe (CIFile d) -> m () deleteCIFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do - cancelFiles user [(fileId, AFS msgDirection fileStatus)] - withFilesFolder $ \filesFolder -> - deleteFiles filesFolder [filePath] + let fileInfo = CIFileInfo {fileId, fileStatus = AFS msgDirection fileStatus, filePath} + cancelFile user fileInfo + withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo APIChatRead (ChatRef cType chatId) fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk @@ -378,12 +379,12 @@ processChatCommand = \case ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case [] -> do - files <- withStore $ \st -> getContactFiles st userId ct + filesInfo <- withStore $ \st -> getContactFileInfo st userId ct conns <- withStore $ \st -> getContactConnections st userId ct withChatLock . procCmd $ do - cancelFiles user (map (\(fId, fStatus, _) -> (fId, fStatus)) files) - withFilesFolder $ \filesFolder -> - deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) + forM_ filesInfo $ \fileInfo -> do + cancelFile user fileInfo + withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId ct @@ -397,6 +398,27 @@ processChatCommand = \case pure $ CRContactConnectionDeleted conn CTGroup -> pure $ chatCmdError "not implemented" CTContactRequest -> pure $ chatCmdError "not supported" + APIClearChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of + CTDirect -> do + ct <- withStore $ \st -> getContact st userId chatId + ciIdsAndFileInfo <- withStore $ \st -> getContactChatItemIdsAndFileInfo st userId chatId + forM_ ciIdsAndFileInfo $ \(itemId, fileInfo_) -> do + forM_ fileInfo_ $ \fileInfo -> do + cancelFile user fileInfo + withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo + void $ withStore $ \st -> deleteDirectChatItemLocal st userId ct itemId CIDMInternal + pure $ CRChatCleared (AChatInfo SCTDirect (DirectChat ct)) + CTGroup -> do + gInfo <- withStore $ \st -> getGroupInfo st user chatId + ciIdsAndFileInfo <- withStore $ \st -> getGroupChatItemIdsAndFileInfo st userId chatId + forM_ ciIdsAndFileInfo $ \(itemId, fileInfo_) -> do + forM_ fileInfo_ $ \fileInfo -> do + cancelFile user fileInfo + withFilesFolder $ \filesFolder -> deleteFile filesFolder fileInfo + void $ withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId + pure $ CRChatCleared (AChatInfo SCTGroup (GroupChat gInfo)) + CTContactConnection -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError "not supported" APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock $ do cReq <- withStore $ \st -> getContactRequest st userId connReqId procCmd $ CRAcceptingContactRequest <$> acceptContactRequest user cReq @@ -507,6 +529,9 @@ processChatCommand = \case DeleteContact cName -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId) + ClearContact cName -> withUser $ \User {userId} -> do + contactId <- withStore $ \st -> getContactIdByName st userId cName + processChatCommand $ APIClearChat (ChatRef CTDirect contactId) ListContacts -> withUser $ \user -> CRContactsList <$> withStore (`getUserContacts` user) CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do (connId, cReq) <- withAgent (`createConnection` SCMContact) @@ -629,6 +654,9 @@ processChatCommand = \case mapM_ deleteMemberConnection members withStore $ \st -> deleteGroup st user g pure $ CRGroupDeletedUser gInfo + ClearGroup gName -> withUser $ \user -> do + groupId <- withStore $ \st -> getGroupIdByName st user gName + processChatCommand $ APIClearChat (ChatRef CTGroup groupId) ListMembers gName -> CRGroupMembers <$> withUser (\user -> withStore (\st -> getGroupByName st user gName)) ListGroups -> CRGroupsList <$> withUser (\user -> withStore (`getUserGroupDetails` user)) SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do @@ -754,15 +782,14 @@ processChatCommand = \case -- perform an action only if filesFolder is set (i.e. on mobile devices) withFilesFolder :: (FilePath -> m ()) -> m () withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action - deleteFiles :: FilePath -> [Maybe FilePath] -> m () - deleteFiles filesFolder filePaths = - forM_ filePaths $ \filePath_ -> - forM_ filePath_ $ \filePath -> do - let fsFilePath = filesFolder <> "/" <> filePath - removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> - removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () - cancelFiles :: User -> [(Int64, ACIFileStatus)] -> m () - cancelFiles user files = forM_ files $ \(fileId, AFS dir status) -> + deleteFile :: FilePath -> CIFileInfo -> m () + deleteFile filesFolder CIFileInfo {filePath} = + forM_ filePath $ \fPath -> do + let fsFilePath = filesFolder <> "/" <> fPath + removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> + removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () + cancelFile :: User -> CIFileInfo -> m () + cancelFile user CIFileInfo {fileId, fileStatus = (AFS dir status)} = unless (ciFileEnded status) $ case dir of SMDSnd -> do @@ -1416,23 +1443,41 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage pure $ Just ciFile messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () - messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do + messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@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 -> updateDirectChatItemView userId ct itemId (ACIContent SMDRcv $ CIRcvMsgContent mc) $ Just msgId - SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" + updateRcvChatItem `catchError` \e -> + case e of + (ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do + -- This patches initial sharedMsgId into chat item when locally deleted chat item + -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). + -- Chat item and update message which created it will have different sharedMsgId in this case... + ci@ChatItem {formattedText} <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta (CIRcvMsgContent mc) Nothing + toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + showMsgToast (c <> "> ") mc formattedText + setActive $ ActiveC c + _ -> throwError e + where + updateRcvChatItem = do + CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId + case msgDir of + 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 - SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" + deleteRcvChatItem `catchError` \e -> + case e of + (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound ct sMsgId + _ -> throwError e + where + deleteRcvChatItem = do + CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId + case msgDir of + SMDRcv -> do + toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId + toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi + 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 @@ -1989,9 +2034,12 @@ saveSndChatItem user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem = liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) createdAt createdAt saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv) -saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content ciFile = do +saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} = saveRcvChatItem' user cd msg sharedMsgId_ + +saveRcvChatItem' :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv) +saveRcvChatItem' user cd msg sharedMsgId_ MsgMeta {broker = (_, brokerTs)} content ciFile = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt + (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg sharedMsgId_ content brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt @@ -2124,6 +2172,7 @@ chatCommandP = <|> "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) <|> "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))) <|> "/_delete " *> (APIDeleteChat <$> chatRefP) + <|> "/_clear chat " *> (APIClearChat <$> chatRefP) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) <|> "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP) @@ -2153,6 +2202,9 @@ chatCommandP = <|> ("/remove #" <|> "/remove " <|> "/rm #" <|> "/rm ") *> (RemoveMember <$> displayName <* A.space <*> displayName) <|> ("/leave #" <|> "/leave " <|> "/l #" <|> "/l ") *> (LeaveGroup <$> displayName) <|> ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName) + <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) + <|> "/clear #" *> (ClearGroup <$> displayName) + <|> ("/clear @" <|> "/clear ") *> (ClearContact <$> displayName) <|> ("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName) <|> ("/groups" <|> "/gs") $> ListGroups <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString) @@ -2160,7 +2212,6 @@ chatCommandP = <|> ("/contacts" <|> "/cs") $> ListContacts <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact - <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) <|> (SendMessage <$> chatNameP <* A.space <*> A.takeByteString) <|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv) <|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1de9da5371..d197e0a215 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -109,6 +109,7 @@ data ChatCommand | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIDeleteChat ChatRef + | APIClearChat ChatRef | APIAcceptContact Int64 | APIRejectContact Int64 | APISendCallInvitation ContactId CallType @@ -132,6 +133,7 @@ data ChatCommand | Connect (Maybe AConnectionRequestUri) | ConnectSimplex | DeleteContact ContactName + | ClearContact ContactName | ListContacts | CreateMyAddress | DeleteMyAddress @@ -151,6 +153,7 @@ data ChatCommand | MemberRole GroupName ContactName GroupMemberRole | LeaveGroup GroupName | DeleteGroup GroupName + | ClearGroup GroupName | ListMembers GroupName | ListGroups | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} @@ -179,6 +182,7 @@ data ChatResponse | CRChatItemStatusUpdated {chatItem :: AChatItem} | CRChatItemUpdated {chatItem :: AChatItem} | CRChatItemDeleted {deletedChatItem :: AChatItem, toChatItem :: AChatItem} + | CRChatItemDeletedNotFound {contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent MsgContent Int ZonedTime | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile | CRCmdAccepted {corr :: CorrId} @@ -205,6 +209,7 @@ data ChatResponse | CRContactUpdated {fromContact :: Contact, toContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} | CRContactDeleted {contact :: Contact} + | CRChatCleared {chatInfo :: AChatInfo} | CRUserContactLinkCreated {connReqContact :: ConnReqContact} | CRUserContactLinkDeleted | CRReceivedContactRequest {contactRequest :: UserContactRequest} diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 47e0aa8218..5febc79ca2 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -82,6 +82,14 @@ jsonChatInfo = \case ContactRequest g -> JCInfoContactRequest g ContactConnection c -> JCInfoContactConnection c +data AChatInfo = forall c. AChatInfo (SChatType c) (ChatInfo c) + +deriving instance Show AChatInfo + +instance ToJSON AChatInfo where + toJSON (AChatInfo _ c) = J.toJSON c + toEncoding (AChatInfo _ c) = J.toEncoding c + data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, meta :: CIMeta d, @@ -363,6 +371,13 @@ instance StrEncoding ACIFileStatus where "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled _ -> fail "bad file status" +-- to conveniently read file data from db +data CIFileInfo = CIFileInfo + { fileId :: Int64, + fileStatus :: ACIFileStatus, + filePath :: Maybe FilePath + } + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: CIStatus 'MDSnd diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 98a2e440de..2886356700 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -116,7 +116,9 @@ module Simplex.Chat.Store getFileTransfer, getFileTransferProgress, getSndFileTransfer, - getContactFiles, + getContactFileInfo, + getContactChatItemIdsAndFileInfo, + getGroupChatItemIdsAndFileInfo, createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, @@ -143,9 +145,8 @@ module Simplex.Chat.Store updateDirectChatItemStatus, updateDirectCIFileStatus, updateDirectChatItem, - deleteDirectChatItemInternal, + deleteDirectChatItemLocal, deleteDirectChatItemRcvBroadcast, - deleteDirectChatItemSndBroadcast, updateGroupChatItem, deleteGroupChatItemInternal, deleteGroupChatItemRcvBroadcast, @@ -2280,18 +2281,56 @@ getFileTransferMeta_ db userId fileId = fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) = FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_} -getContactFiles :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [(Int64, ACIFileStatus, Maybe FilePath)] -getContactFiles st userId Contact {contactId} = +getContactFileInfo :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [CIFileInfo] +getContactFileInfo st userId Contact {contactId} = liftIO . withTransaction st $ \db -> - DB.query - db - [sql| + map toFileInfo + <$> DB.query + db + [sql| SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.contact_id = ? |] - (userId, contactId) + (userId, contactId) + +toFileInfo :: (Int64, ACIFileStatus, Maybe FilePath) -> CIFileInfo +toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} + +getContactChatItemIdsAndFileInfo :: MonadUnliftIO m => SQLiteStore -> UserId -> ContactId -> m [(ChatItemId, Maybe CIFileInfo)] +getContactChatItemIdsAndFileInfo st userId contactId = + liftIO . withTransaction st $ \db -> + map toItemIdAndFileInfo + <$> DB.query + db + [sql| + SELECT i.chat_item_id, f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? + |] + (userId, contactId) + +getGroupChatItemIdsAndFileInfo :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m [(ChatItemId, Maybe CIFileInfo)] +getGroupChatItemIdsAndFileInfo st userId groupId = + liftIO . withTransaction st $ \db -> + map toItemIdAndFileInfo + <$> DB.query + db + [sql| + SELECT i.chat_item_id, f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? + |] + (userId, groupId) + +toItemIdAndFileInfo :: (ChatItemId, Maybe Int64, Maybe ACIFileStatus, Maybe FilePath) -> (ChatItemId, Maybe CIFileInfo) +toItemIdAndFileInfo (chatItemId, fileId_, fileStatus_, filePath) = + case (fileId_, fileStatus_) of + (Just fileId, Just fileStatus) -> (chatItemId, Just CIFileInfo {fileId, fileStatus, filePath}) + _ -> (chatItemId, Nothing) createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage createNewSndMessage st gVar connOrGroupId mkMessage = @@ -2449,8 +2488,8 @@ createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciCon CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDRcv -> RcvMessage -> CIContent 'MDRcv -> UTCTime -> UTCTime -> m (ChatItemId, Maybe (CIQuote c)) -createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent, sharedMsgId_} ciContent itemTs createdAt = +createNewRcvChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> UTCTime -> UTCTime -> m (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent} sharedMsgId_ ciContent itemTs createdAt = liftIO . withTransaction st $ \db -> do ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemTs createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg @@ -3229,13 +3268,39 @@ updateDirectChatItem_ db userId contactId itemId newContent currentTs = runExcep correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -deleteDirectChatItemInternal :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> m AChatItem -deleteDirectChatItemInternal st userId ct itemId = +deleteDirectChatItemLocal :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> m AChatItem +deleteDirectChatItemLocal st userId ct itemId mode = liftIOEither . withTransaction st $ \db -> do - currentTs <- liftIO getCurrentTime - ci <- deleteDirectChatItem_ db userId ct itemId CIDMInternal True currentTs - setChatItemMessagesDeleted_ db itemId - pure ci + deleteChatItemMessages_ db itemId + deleteDirectChatItem_ db userId ct itemId mode + +deleteDirectChatItem_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> IO (Either StoreError AChatItem) +deleteDirectChatItem_ db userId ct@Contact {contactId} itemId mode = runExceptT $ do + (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + let toContent = msgDirToDeletedContent_ msgDir mode + liftIO $ do + DB.execute + db + [sql| + DELETE FROM chat_items + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + |] + (userId, contactId, itemId) + pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {content = toContent, meta = (meta ci) {itemText = ciDeleteModeToText mode, itemDeleted = True}, formattedText = Nothing}) + +deleteChatItemMessages_ :: DB.Connection -> ChatItemId -> IO () +deleteChatItemMessages_ db itemId = + DB.execute + db + [sql| + DELETE FROM messages + WHERE message_id IN ( + SELECT message_id + FROM chat_item_messages + WHERE chat_item_id = ? + ) + |] + (Only itemId) setChatItemMessagesDeleted_ :: DB.Connection -> ChatItemId -> IO () setChatItemMessagesDeleted_ db itemId = @@ -3256,38 +3321,26 @@ setChatItemMessagesDeleted_ db itemId = deleteDirectChatItemRcvBroadcast :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> MessageId -> m AChatItem deleteDirectChatItemRcvBroadcast st userId ct itemId msgId = - liftIOEither . withTransaction st $ \db -> deleteDirectChatItemBroadcast_ db userId ct itemId False msgId - -deleteDirectChatItemSndBroadcast :: StoreMonad m => SQLiteStore -> UserId -> Contact -> ChatItemId -> MessageId -> m AChatItem -deleteDirectChatItemSndBroadcast st userId ct itemId msgId = liftIOEither . withTransaction st $ \db -> do - ci <- deleteDirectChatItemBroadcast_ db userId ct itemId True msgId - setChatItemMessagesDeleted_ db itemId - pure ci + currentTs <- liftIO getCurrentTime + insertChatItemMessage_ db itemId msgId currentTs + updateDirectChatItemRcvDeleted_ db userId ct itemId currentTs -deleteDirectChatItemBroadcast_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> Bool -> MessageId -> IO (Either StoreError AChatItem) -deleteDirectChatItemBroadcast_ db userId ct itemId itemDeleted msgId = do - currentTs <- liftIO getCurrentTime - insertChatItemMessage_ db itemId msgId currentTs - deleteDirectChatItem_ db userId ct itemId CIDMBroadcast itemDeleted currentTs - -deleteDirectChatItem_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> CIDeleteMode -> Bool -> UTCTime -> IO (Either StoreError AChatItem) -deleteDirectChatItem_ db userId ct@Contact {contactId} itemId mode itemDeleted currentTs = runExceptT $ do +updateDirectChatItemRcvDeleted_ :: DB.Connection -> UserId -> Contact -> ChatItemId -> UTCTime -> IO (Either StoreError AChatItem) +updateDirectChatItemRcvDeleted_ db userId ct@Contact {contactId} itemId currentTs = runExceptT $ do (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId - let toContent = msgDirToDeletedContent_ msgDir mode + let toContent = msgDirToDeletedContent_ msgDir CIDMBroadcast + toText = ciDeleteModeToText CIDMBroadcast liftIO $ do DB.execute db [sql| UPDATE chat_items - SET item_content = ?, item_text = ?, item_deleted = ?, updated_at = ? + SET item_content = ?, item_text = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] - (toContent, toText, itemDeleted, currentTs, userId, contactId, itemId) - when itemDeleted $ deleteQuote_ db itemId - pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted}, formattedText = Nothing}) - where - toText = ciDeleteModeToText mode + (toContent, toText, currentTs, userId, contactId, itemId) + pure $ AChatItem SCTDirect msgDir (DirectChat ct) (ci {content = toContent, meta = (meta ci) {itemText = toText}, formattedText = Nothing}) deleteQuote_ :: DB.Connection -> ChatItemId -> IO () deleteQuote_ db itemId = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 72d374a161..5283a607c1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -54,6 +54,7 @@ responseToView testView = \case CRChatItemStatusUpdated _ -> [] CRChatItemUpdated (AChatItem _ _ chat item) -> viewItemUpdate chat item CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> viewItemDelete chat deletedItem toItem + CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> [] @@ -83,6 +84,7 @@ responseToView testView = \case CRSentConfirmation -> ["confirmation sent!"] CRSentInvitation -> ["connection request sent!"] CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"] + CRChatCleared chatInfo -> viewChatCleared chatInfo CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."] CRContactAlreadyExists c -> [ttyFullContact c <> ": contact already exists"] CRContactRequestAlreadyAccepted c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] @@ -254,14 +256,12 @@ viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem (CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc meta CIDMInternal -> ["message deleted"] - (CIDirectSnd, _, _) -> ["message deleted"] - _ -> [] + _ -> ["message deleted"] GroupChat g -> case (chatDir, deletedContent, toContent) of (CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc meta CIDMInternal -> ["message deleted"] - (CIGroupSnd, _, _) -> ["message deleted"] - _ -> [] + _ -> ["message deleted"] _ -> [] directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString] @@ -315,6 +315,12 @@ viewConnReqInvitation cReq = "and ask them to connect: " <> highlight' "/c " ] +viewChatCleared :: AChatInfo -> [StyledString] +viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of + DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"] + GroupChat gi -> [ttyGroup' gi <> ": all messages are removed locally ONLY"] + _ -> [] + viewContactsList :: [Contact] -> [StyledString] viewContactsList = let ldn = T.toLower . (localDisplayName :: Contact -> ContactName) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 254da1eb0a..8c53408e7c 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -130,6 +130,11 @@ testAddContact = alice <## "no contact bob_1" alice @@@ [("@bob", "hi")] bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] + -- test clearing chat + alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") + alice #$> ("/_get chat @2 count=100", chat, []) + bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") + bob #$> ("/_get chat @2 count=100", chat, []) where chatsEmpty alice bob = do alice @@@ [("@bob", "")] @@ -241,49 +246,66 @@ testDirectMessageDelete = \alice bob -> do connectUsers alice bob - -- msg id 1 + -- alice, bob: msg id 1 alice #> "@bob hello 🙂" bob <# "alice> hello 🙂" - -- msg id 2 - bob `send` "> @alice (hello) hey alic" + -- alice, bob: msg id 2 + bob `send` "> @alice (hello 🙂) hey alic" bob <# "@alice > hello 🙂" bob <## " hey alic" alice <# "bob> > hello 🙂" alice <## " hey alic" + -- alice: deletes msg ids 1,2 alice #$> ("/_delete item @2 1 internal", id, "message deleted") alice #$> ("/_delete item @2 2 internal", id, "message deleted") alice @@@ [("@bob", "")] alice #$> ("/_get chat @2 count=100", chat, []) - alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send @2 json {\"quotedItemId\": 1, \"msgContent\": {\"type\": \"text\", \"text\": \"quoting deleted message\"}}", id, "cannot reply to this message") - + -- alice: msg id 1 bob #$> ("/_update item @2 2 text hey alice", id, "message updated") alice <# "bob> [edited] hey alice" - alice @@@ [("@bob", "hey alice")] alice #$> ("/_get chat @2 count=100", chat, [(0, "hey alice")]) - -- msg id 3 + -- bob: deletes msg id 2 + bob #$> ("/_delete item @2 2 broadcast", id, "message deleted") + alice <# "bob> [deleted] hey alice" + alice @@@ [("@bob", "this item is deleted (broadcast)")] + alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")]) + + -- alice: deletes msg id 1 that was broadcast deleted by bob + alice #$> ("/_delete item @2 1 internal", id, "message deleted") + alice @@@ [("@bob", "")] + alice #$> ("/_get chat @2 count=100", chat, []) + + -- alice: msg id 1, bob: msg id 2 (quoting message alice deleted locally) + bob `send` "> @alice (hello 🙂) do you receive my messages?" + bob <# "@alice > hello 🙂" + bob <## " do you receive my messages?" + alice <# "bob> > hello 🙂" + alice <## " do you receive my messages?" + alice @@@ [("@bob", "do you receive my messages?")] + alice #$> ("/_get chat @2 count=100", chat', [((0, "do you receive my messages?"), Just (1, "hello 🙂"))]) + alice #$> ("/_delete item @2 1 broadcast", id, "cannot delete this item") + + -- alice: msg id 2, bob: msg id 3 bob #> "@alice how are you?" alice <# "bob> how are you?" - bob #$> ("/_delete item @2 3 broadcast", id, "message deleted") - alice <# "bob> [deleted] how are you?" - - alice #$> ("/_delete item @2 1 broadcast", id, "message deleted") - bob <# "alice> [deleted] hello 🙂" - - alice #$> ("/_delete item @2 2 broadcast", id, "cannot delete this item") + -- alice: deletes msg id 2 alice #$> ("/_delete item @2 2 internal", id, "message deleted") - alice @@@ [("@bob", "this item is deleted (broadcast)")] - alice #$> ("/_get chat @2 count=100", chat, [(0, "this item is deleted (broadcast)")]) - bob @@@ [("@alice", "hey alice")] - bob #$> ("/_get chat @2 count=100", chat', [((0, "this item is deleted (broadcast)"), Nothing), ((1, "hey alice"), (Just (0, "hello 🙂")))]) + -- bob: deletes msg id 3 (that alice deleted locally) + bob #$> ("/_delete item @2 3 broadcast", id, "message deleted") + alice <## "bob> [deleted - original message not found]" + + alice @@@ [("@bob", "do you receive my messages?")] + alice #$> ("/_get chat @2 count=100", chat', [((0, "do you receive my messages?"), Just (1, "hello 🙂"))]) + bob @@@ [("@alice", "do you receive my messages?")] + bob #$> ("/_get chat @2 count=100", chat', [((0, "hello 🙂"), Nothing), ((1, "do you receive my messages?"), Just (0, "hello 🙂"))]) testGroup :: IO () testGroup = @@ -372,6 +394,13 @@ testGroup = cath ##> "#team hello" cath <## "you are no longer a member of the group" bob <##> cath + -- test clearing chat + alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") + alice #$> ("/_get chat #1 count=100", chat, []) + bob #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") + bob #$> ("/_get chat #1 count=100", chat, []) + cath #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") + cath #$> ("/_get chat #1 count=100", chat, []) where getReadChats alice bob cath = do alice @@@ [("#team", "hey team"), ("@cath", ""), ("@bob", "")] From 295cec7c535efcb8bd8d155487015fb3dc11828f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 17 May 2022 08:37:00 +0100 Subject: [PATCH 05/32] terminal: support to connect via webrtc (#659) * terminal: support to connect via webrtc * npm package * update webrtc npm package * remove console.log * fix test --- apps/android/app/src/main/assets/www/call.js | 35 ++--- package.yaml | 1 + packages/simplex-chat-webrtc/package.json | 21 ++- packages/simplex-chat-webrtc/src/call.ts | 40 +++--- packages/simplex-chat-webrtc/src/ui.js | 136 ++++++++++-------- packages/simplex-chat-webrtc/src/webcall.html | 7 +- simplex-chat.cabal | 5 + src/Simplex/Chat.hs | 4 + src/Simplex/Chat/Call.hs | 3 + src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/View.hs | 78 +++++++++- tests/ChatTests.hs | 13 +- 12 files changed, 231 insertions(+), 113 deletions(-) diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index b5381b3cd9..dd71ea87a2 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -431,8 +431,9 @@ function callCryptoFunction() { }; } function decodeAesKey(aesKey) { - const keyData = callCrypto.decodeBase64(callCrypto.encodeAscii(aesKey)); - return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey)); + console.log("keyData", keyData); + return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); } function concatN(...bs) { const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0)); @@ -445,9 +446,9 @@ function callCryptoFunction() { function randomIV() { return crypto.getRandomValues(new Uint8Array(IV_LENGTH)); } - const base64chars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0))); - const base64lookup = new Array(256); - base64chars.forEach((c, i) => (base64lookup[c] = i)); + const base64urlChars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0))); + const base64urlLookup = new Array(256); + base64urlChars.forEach((c, i) => (base64urlLookup[c] = i)); const char_equal = "=".charCodeAt(0); function encodeAscii(s) { const a = new Uint8Array(s.length); @@ -462,16 +463,16 @@ function callCryptoFunction() { s += String.fromCharCode(a[i]); return s; } - function encodeBase64(a) { + function encodeBase64url(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]; + b64[j++] = base64urlChars[a[i] >> 2]; + b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)]; + b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)]; + b64[j++] = base64urlChars[a[i + 2] & 63]; } if (len % 3) b64[b64len - 1] = char_equal; @@ -479,7 +480,7 @@ function callCryptoFunction() { b64[b64len - 2] = char_equal; return b64; } - function decodeBase64(b64) { + function decodeBase64url(b64) { let len = b64.length; if (len % 4) return; @@ -496,10 +497,10 @@ function callCryptoFunction() { 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; + const enc1 = base64urlLookup[b64[i++]]; + const enc2 = i < len ? base64urlLookup[b64[i++]] : 0; + const enc3 = i < len ? base64urlLookup[b64[i++]] : 0; + const enc4 = i < len ? base64urlLookup[b64[i++]] : 0; if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined) return; bytes[pos++] = (enc1 << 2) | (enc2 >> 4); @@ -513,8 +514,8 @@ function callCryptoFunction() { decodeAesKey, encodeAscii, decodeAscii, - encodeBase64, - decodeBase64, + encodeBase64url, + decodeBase64url, }; } // If the worker is used for decryption, this function code (as string) is used to load the worker via Blob diff --git a/package.yaml b/package.yaml index 784ba532af..fe136ddad9 100644 --- a/package.yaml +++ b/package.yaml @@ -26,6 +26,7 @@ dependencies: - email-validate == 2.3.* - exceptions == 0.10.* - filepath == 1.4.* + - http-types == 0.12.* - mtl == 2.2.* - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index 86fe6c0fb0..6b33d7292d 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -1,17 +1,30 @@ { - "name": "simplex-chat-webrtc", - "version": "0.0.1", - "description": "WebRTC call in browser and webview", + "name": "@simplex-chat/webrtc", + "version": "0.0.2", + "description": "WebRTC call in browser and webview for SimpleX Chat clients", "main": "dist/call.js", + "types": "dist/call.d.ts", + "files": [ + "dist" + ], "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "prettier --write --ignore-unknown . && tsc && ./copy" }, + "repository": { + "type": "git", + "url": "git+https://github.com/simplex-chat/simplex-chat.git" + }, "keywords": [ "SimpleX", "WebRTC" ], - "author": "", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-webrtc#readme", + "author": "SimpleX Chat", "license": "AGPL-3.0-or-later", "devDependencies": { "@types/lz-string": "^1.3.34", diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index b5fa306870..f21210d2d6 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -569,8 +569,8 @@ interface CallCrypto { decodeAesKey: (aesKey: string) => Promise encodeAscii: (s: string) => Uint8Array decodeAscii: (a: Uint8Array) => string - encodeBase64: (a: Uint8Array) => Uint8Array - decodeBase64: (b64: Uint8Array) => Uint8Array | undefined + encodeBase64url: (a: Uint8Array) => Uint8Array + decodeBase64url: (b64: Uint8Array) => Uint8Array | undefined } interface RTCEncodedVideoFrame { @@ -624,8 +624,8 @@ function callCryptoFunction(): CallCrypto { } function decodeAesKey(aesKey: string): Promise { - const keyData = callCrypto.decodeBase64(callCrypto.encodeAscii(aesKey)) - return crypto.subtle.importKey("raw", keyData!, {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"]) + const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey)) + return crypto.subtle.importKey("raw", keyData!, {name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]) } function concatN(...bs: Uint8Array[]): Uint8Array { @@ -641,12 +641,12 @@ function callCryptoFunction(): CallCrypto { return crypto.getRandomValues(new Uint8Array(IV_LENGTH)) } - const base64chars = new Uint8Array( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").map((c) => c.charCodeAt(0)) + const base64urlChars = new Uint8Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0)) ) - const base64lookup = new Array(256) as (number | undefined)[] - base64chars.forEach((c, i) => (base64lookup[c] = i)) + const base64urlLookup = new Array(256) as (number | undefined)[] + base64urlChars.forEach((c, i) => (base64urlLookup[c] = i)) const char_equal = "=".charCodeAt(0) @@ -663,17 +663,17 @@ function callCryptoFunction(): CallCrypto { return s } - function encodeBase64(a: Uint8Array): Uint8Array { + function encodeBase64url(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] + b64[j++] = base64urlChars[a[i] >> 2] + b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)] + b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)] + b64[j++] = base64urlChars[a[i + 2] & 63] } if (len % 3) b64[b64len - 1] = char_equal @@ -682,7 +682,7 @@ function callCryptoFunction(): CallCrypto { return b64 } - function decodeBase64(b64: Uint8Array): Uint8Array | undefined { + function decodeBase64url(b64: Uint8Array): Uint8Array | undefined { let len = b64.length if (len % 4) return let bLen = (len * 3) / 4 @@ -701,10 +701,10 @@ function callCryptoFunction(): CallCrypto { 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 + const enc1 = base64urlLookup[b64[i++]] + const enc2 = i < len ? base64urlLookup[b64[i++]] : 0 + const enc3 = i < len ? base64urlLookup[b64[i++]] : 0 + const enc4 = i < len ? base64urlLookup[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) @@ -719,8 +719,8 @@ function callCryptoFunction(): CallCrypto { decodeAesKey, encodeAscii, decodeAscii, - encodeBase64, - decodeBase64, + encodeBase64url, + decodeBase64url, } } diff --git a/packages/simplex-chat-webrtc/src/ui.js b/packages/simplex-chat-webrtc/src/ui.js index 09b413763c..3ef8d32f2d 100644 --- a/packages/simplex-chat-webrtc/src/ui.js +++ b/packages/simplex-chat-webrtc/src/ui.js @@ -1,103 +1,113 @@ ;(async function run() { - const START_E2EE_CALL_BTN = "start-e2ee-call" - const START_CALL_BTN = "start-call" + // const START_E2EE_CALL_BTN = "start-e2ee-call" + // const START_CALL_BTN = "start-call" const URL_FOR_PEER = "url-for-peer" - const COPY_URL_FOR_PEER_BTN = "copy-url-for-peer" + // const COPY_URL_FOR_PEER_BTN = "copy-url-for-peer" const DATA_FOR_PEER = "data-for-peer" - const COPY_DATA_FOR_PEER_BTN = "copy-data-for-peer" + // const COPY_DATA_FOR_PEER_BTN = "copy-data-for-peer" const PASS_DATA_TO_PEER_TEXT = "pass-data-to-peer" - const CHAT_COMMAND_FOR_PEER = "chat-command-for-peer" + const SIMPLEX_CHAT_COMMAND = "simplex-chat-command" + const COPY_SIMPLEX_CHAT_COMMAND_BTN = "copy-simplex-chat-command" const COMMAND_TO_PROCESS = "command-to-process" const PROCESS_COMMAND_BTN = "process-command" const urlForPeer = document.getElementById(URL_FOR_PEER) const dataForPeer = document.getElementById(DATA_FOR_PEER) const passDataToPeerText = document.getElementById(PASS_DATA_TO_PEER_TEXT) - const chatCommandForPeer = document.getElementById(CHAT_COMMAND_FOR_PEER) + const simplexChatCommand = document.getElementById(SIMPLEX_CHAT_COMMAND) const commandToProcess = document.getElementById(COMMAND_TO_PROCESS) const processCommandButton = document.getElementById(PROCESS_COMMAND_BTN) - const startE2EECallButton = document.getElementById(START_E2EE_CALL_BTN) - const {resp} = await processCommand({command: {type: "capabilities", useWorker: true}}) - if (resp?.capabilities?.encryption) { - startE2EECallButton.onclick = startCall(true) - } else { - startE2EECallButton.style.display = "none" - } - const startCallButton = document.getElementById(START_CALL_BTN) - startCallButton.onclick = startCall() - const copyUrlButton = document.getElementById(COPY_URL_FOR_PEER_BTN) - copyUrlButton.onclick = () => { - navigator.clipboard.writeText(urlForPeer.innerText) - commandToProcess.style.display = "" - processCommandButton.style.display = "" - } - const copyDataButton = document.getElementById(COPY_DATA_FOR_PEER_BTN) - copyDataButton.onclick = () => { - navigator.clipboard.writeText(dataForPeer.innerText) - commandToProcess.style.display = "" - processCommandButton.style.display = "" + // const startE2EECallButton = document.getElementById(START_E2EE_CALL_BTN) + // const {resp} = await processCommand({command: {type: "capabilities", useWorker: true}}) + // if (resp?.capabilities?.encryption) { + // startE2EECallButton.onclick = startCall(true) + // } else { + // startE2EECallButton.style.display = "none" + // } + // const startCallButton = document.getElementById(START_CALL_BTN) + // startCallButton.onclick = startCall() + // const copyUrlButton = document.getElementById(COPY_URL_FOR_PEER_BTN) + // copyUrlButton.onclick = () => { + // navigator.clipboard.writeText(urlForPeer.innerText) + // commandToProcess.style.display = "" + // processCommandButton.style.display = "" + // } + // const copyDataButton = document.getElementById(COPY_DATA_FOR_PEER_BTN) + // copyDataButton.onclick = () => { + // navigator.clipboard.writeText(dataForPeer.innerText) + // commandToProcess.style.display = "" + // processCommandButton.style.display = "" + // } + const copySimplexChatCommandButton = document.getElementById(COPY_SIMPLEX_CHAT_COMMAND_BTN) + copySimplexChatCommandButton.onclick = () => { + navigator.clipboard.writeText(simplexChatCommand.innerText) + if (simplexChatCommand.innerText.startsWith("/_call offer")) { + commandToProcess.style.display = "" + processCommandButton.style.display = "" + } } processCommandButton.onclick = () => { sendCommand(JSON.parse(commandToProcess.value)) commandToProcess.value = "" } const parsed = new URLSearchParams(document.location.hash.substring(1)) - let apiCallStr = parsed.get("command") - if (apiCallStr) { - startE2EECallButton.style.display = "none" - startCallButton.style.display = "none" - await sendCommand(JSON.parse(decodeURIComponent(apiCallStr))) + let commandStr = parsed.get("command") + if (commandStr) { + // startE2EECallButton.style.display = "none" + // startCallButton.style.display = "none" + await sendCommand(JSON.parse(decodeURIComponent(commandStr))) } - function startCall(encryption) { - return async () => { - let aesKey - if (encryption) { - const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]) - const keyBytes = await crypto.subtle.exportKey("raw", key) - aesKey = callCrypto.decodeAscii(callCrypto.encodeBase64(new Uint8Array(keyBytes))) - } - sendCommand({command: {type: "start", media: "video", aesKey, useWorker: true}}) - startE2EECallButton.style.display = "none" - startCallButton.style.display = "none" - } - } + // function startCall(encryption) { + // return async () => { + // let aesKey + // if (encryption) { + // const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]) + // const keyBytes = await crypto.subtle.exportKey("raw", key) + // aesKey = callCrypto.decodeAscii(callCrypto.encodeBase64url(new Uint8Array(keyBytes))) + // } + // startE2EECallButton.style.display = "none" + // startCallButton.style.display = "none" + // await sendCommand({type: "start", media: "video", aesKey, useWorker: true}) + // } + // } - async function sendCommand(apiCall) { + async function sendCommand(command) { try { - console.log(apiCall) - const {command} = apiCall - const {resp} = await processCommand(apiCall) + console.log(command) + const {resp} = await processCommand({command}) console.log(resp) switch (resp.type) { case "offer": { - const {media, aesKey} = command + const {media} = command const {offer, iceCandidates, capabilities} = resp - const peerWCommand = { - command: {type: "offer", offer, iceCandidates, media, aesKey: capabilities.encryption ? aesKey : undefined, useWorker: true}, - } + const aesKey = capabilities.encryption ? command.aesKey : undefined + const peerWCommand = {type: "offer", offer, iceCandidates, media, aesKey, useWorker: true} const url = new URL(document.location) parsed.set("command", encodeURIComponent(JSON.stringify(peerWCommand))) url.hash = parsed.toString() urlForPeer.innerText = url.toString() dataForPeer.innerText = JSON.stringify(peerWCommand) - copyUrlButton.style.display = "" - copyDataButton.style.display = "" - // const webRTCCallOffer = {callType: {media, capabilities}, rtcSession: {rtcSession: offer, rtcIceCandidates: iceCandidates}} - // const peerChatCommand = `/_call @${parsed.contact} offer ${JSON.stringify(webRTCCallOffer)}` - // chatCommandForPeer.innerText = peerChatCommand + const webRTCCallOffer = {callType: {media, capabilities}, rtcSession: {rtcSession: offer, rtcIceCandidates: iceCandidates}} + const peerChatCommand = `/_call offer @${parsed.get("contact_id")} ${JSON.stringify(webRTCCallOffer)}` + simplexChatCommand.innerText = peerChatCommand + + // copyUrlButton.style.display = "" + // copyDataButton.style.display = "" + copySimplexChatCommandButton.style.display = "" return } case "answer": { const {answer, iceCandidates} = resp - const peerWCommand = {command: {type: "answer", answer, iceCandidates}} + const peerWCommand = {type: "answer", answer, iceCandidates} dataForPeer.innerText = JSON.stringify(peerWCommand) - copyUrlButton.style.display = "none" - copyDataButton.style.display = "" - // const webRTCSession = {rtcSession: answer, rtcIceCandidates: iceCandidates} - // const peerChatCommand = `/_call @${parsed.contact} answer ${JSON.stringify(webRTCSession)}` - // chatCommandForPeer.innerText = peerChatCommand + const webRTCSession = {rtcSession: answer, rtcIceCandidates: iceCandidates} + const peerChatCommand = `/_call answer @${parsed.get("contact_id")} ${JSON.stringify(webRTCSession)}` + // copyUrlButton.style.display = "none" + // copyDataButton.style.display = "" + copySimplexChatCommandButton.style.display = "" + simplexChatCommand.innerText = peerChatCommand return } case "ok": diff --git a/packages/simplex-chat-webrtc/src/webcall.html b/packages/simplex-chat-webrtc/src/webcall.html index e648df8537..1e1fad9e12 100644 --- a/packages/simplex-chat-webrtc/src/webcall.html +++ b/packages/simplex-chat-webrtc/src/webcall.html @@ -21,10 +21,11 @@
- + +
@@ -41,7 +42,7 @@
- +