From 546ad01fcb6395209ae88584bc4d62c4fea92aca Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 24 May 2022 19:34:27 +0100 Subject: [PATCH] ios: integrating webrtc calls with callkit (#686) * ios: integrating webrtc calls with callkit * accept call via chat item (e.g. when DND is on, and callkit blocks the call); refactor * fix remote video, support logging from ios * use callkit depending on CallController setting * call sound * update incoming call view * fixing audio encryption * refactor encryption webrtc fix * log ontrack success/error * accept / ignore call via notification * remove unused imports * remove unused file * remove comments --- apps/android/app/src/main/assets/www/call.js | 71 ++-- .../android/app/src/main/assets/www/style.css | 21 +- .../chat/simplex/app/views/chat/ChatView.kt | 24 +- apps/ios/Shared/AppDelegate.swift | 2 +- apps/ios/Shared/ContentView.swift | 17 +- apps/ios/Shared/Model/ChatModel.swift | 3 +- apps/ios/Shared/Model/NtfManager.swift | 29 +- apps/ios/Shared/Model/Shared/CallTypes.swift | 20 +- apps/ios/Shared/Model/Shared/ChatTypes.swift | 14 +- .../Shared/Model/Shared/Notifications.swift | 6 +- apps/ios/Shared/Model/SimpleXAPI.swift | 43 ++- .../Shared/Views/Call/ActiveCallView.swift | 336 ++++++++++++------ .../Shared/Views/Call/CallController.swift | 215 +++++++++++ apps/ios/Shared/Views/Call/CallManager.swift | 104 ++++++ .../Shared/Views/Call/IncomingCallView.swift | 86 +++++ apps/ios/Shared/Views/Call/SoundPlayer.swift | 45 +++ apps/ios/Shared/Views/Call/WebRTC.swift | 110 +++--- apps/ios/Shared/Views/Call/WebRTCView.swift | 95 +++-- .../Views/Chat/ChatItem/CICallItemView.swift | 15 +- apps/ios/Shared/Views/Chat/ChatView.swift | 24 +- .../Shared/Views/ChatList/ChatListView.swift | 51 --- .../Views/UserSettings/SettingsView.swift | 33 +- .../en.xcloc/Localized Contents/en.xliff | 48 +-- .../ru.xcloc/Localized Contents/ru.xliff | 48 +-- .../SimpleX NSE/ru.lproj/Localizable.strings | 8 +- apps/ios/SimpleX--iOS--Info.plist | 2 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++ apps/ios/ru.lproj/Localizable.strings | 8 +- apps/ios/sounds/ringtone2.m4a | Bin 0 -> 46459 bytes packages/simplex-chat-webrtc/src/call.ts | 69 ++-- packages/simplex-chat-webrtc/src/style.css | 21 +- src/Simplex/Chat.hs | 1 + src/Simplex/Chat/Call.hs | 4 +- 33 files changed, 1089 insertions(+), 504 deletions(-) create mode 100644 apps/ios/Shared/Views/Call/CallController.swift create mode 100644 apps/ios/Shared/Views/Call/CallManager.swift create mode 100644 apps/ios/Shared/Views/Call/IncomingCallView.swift create mode 100644 apps/ios/Shared/Views/Call/SoundPlayer.swift create mode 100644 apps/ios/sounds/ringtone2.m4a diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index a2e770e4e0..f97bd4b9ae 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -96,7 +96,8 @@ const processCommand = (function () { const pc = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; - const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType, localCamera)); + const constraints = callMediaConstraints(mediaType, localCamera); + const localStream = await navigator.mediaDevices.getUserMedia(constraints); const iceCandidates = getIceCandidates(pc, config); const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; await setupMediaStreams(call); @@ -116,8 +117,10 @@ const processCommand = (function () { }); if (pc.connectionState == "disconnected" || pc.connectionState == "failed") { pc.removeEventListener("connectionstatechange", connectionStateChange); + if (activeCall) { + setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0); + } endCall(); - setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0); } else if (pc.connectionState == "connected") { const stats = (await pc.getStats()); @@ -133,7 +136,7 @@ const processCommand = (function () { remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId), }, }; - setTimeout(() => sendMessageToNative({ resp }), 0); + setTimeout(() => sendMessageToNative({ resp }), 500); break; } } @@ -256,19 +259,9 @@ const processCommand = (function () { if (!activeCall || !pc) { resp = { type: "error", message: "camera: call not started" }; } - else if (activeCall.localMedia == CallMediaType.Audio) { - resp = { type: "error", message: "camera: no video" }; - } else { - try { - if (command.camera != activeCall.localCamera) { - await replaceCamera(activeCall, command.camera); - } - resp = { type: "ok" }; - } - catch (e) { - resp = { type: "error", message: `camera: ${e.message}` }; - } + await replaceMedia(activeCall, command.camera); + resp = { type: "ok" }; } break; case "end": @@ -281,7 +274,7 @@ const processCommand = (function () { } } catch (e) { - resp = { type: "error", message: e.message }; + resp = { type: "error", message: `${command.type}: ${e.message}` }; } const apiResp = { corrId, resp, command }; sendMessageToNative(apiResp); @@ -323,6 +316,8 @@ const processCommand = (function () { if (call.useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); + call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); + call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data })); } } } @@ -346,14 +341,20 @@ const processCommand = (function () { // Pull tracks from remote stream as they arrive add them to remoteStream video const pc = call.connection; pc.ontrack = (event) => { - if (call.aesKey && call.key) { - console.log("set up decryption for receiving"); - setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key); - } - for (const stream of event.streams) { - for (const track of stream.getTracks()) { - call.remoteStream.addTrack(track); + try { + if (call.aesKey && call.key) { + console.log("set up decryption for receiving"); + setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key); } + for (const stream of event.streams) { + for (const track of stream.getTracks()) { + call.remoteStream.addTrack(track); + } + } + console.log(`ontrack success`); + } + catch (e) { + console.log(`ontrack error: ${e.message}`); } }; } @@ -385,7 +386,7 @@ const processCommand = (function () { } } } - async function replaceCamera(call, camera) { + async function replaceMedia(call, camera) { const videos = getVideoElements(); if (!videos) throw Error("no video elements"); @@ -401,6 +402,8 @@ const processCommand = (function () { videos.local.srcObject = localStream; } function replaceTracks(pc, tracks) { + if (!tracks.length) + return; const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; }); if (sender) for (const t of tracks) @@ -494,8 +497,8 @@ function callCryptoFunction() { 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; + const ciphertext = new Uint8Array(plaintext.length ? await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext) : 0); + frame.data = concatN(initial, ciphertext, iv).buffer; controller.enqueue(frame); } catch (e) { @@ -512,8 +515,8 @@ function callCryptoFunction() { 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; + const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext) : 0); + frame.data = concatN(initial, plaintext).buffer; controller.enqueue(frame); } catch (e) { @@ -619,9 +622,15 @@ function workerFunction() { // 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 }); + try { + const { operation, aesKey } = transformer.options; + const { readable, writable } = transformer; + await setupTransform({ operation, aesKey, readable, writable }); + self.postMessage({ result: "setupTransform success" }); + } + catch (e) { + self.postMessage({ message: `setupTransform error: ${e.message}` }); + } }); } async function setupTransform({ operation, aesKey, readable, writable }) { diff --git a/apps/android/app/src/main/assets/www/style.css b/apps/android/app/src/main/assets/www/style.css index a59f7c39af..3d2941c71e 100644 --- a/apps/android/app/src/main/assets/www/style.css +++ b/apps/android/app/src/main/assets/www/style.css @@ -1,12 +1,10 @@ -video::-webkit-media-controls { - display: none; -} html, body { padding: 0; margin: 0; background-color: black; } + #remote-video-stream { position: absolute; width: 100%; @@ -24,3 +22,20 @@ body { top: 0; right: 0; } + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 5f353069c1..ef1c52209b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -209,18 +209,18 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: ( ) { val cInfo = chat.chatInfo toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back) -// if (cInfo is ChatInfo.Direct) { -// Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { -// Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { -// toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { -// startCall(CallMediaType.Audio) -// } -// } -// toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) { -// startCall(CallMediaType.Video) -// } -// } -// } + if (cInfo is ChatInfo.Direct) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { + toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { + startCall(CallMediaType.Audio) + } + } + toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) { + startCall(CallMediaType.Video) + } + } + } Row( Modifier .padding(horizontal = 80.dp) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 415f6348f0..95e21a2513 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -70,7 +70,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { // TODO check if app in background logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") // TODO remove - NtfManager.shared.notifyCheckingMessages() + // NtfManager.shared.notifyCheckingMessages() receiveMessages(completionHandler) } else if let smpQueue = ntfData["checkMessage"] as? String { // TODO check if app in background diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 64eeb2fc1f..e542a979fb 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared + @ObservedObject var callController = CallController.shared @State private var showNotificationAlert = false var body: some View { @@ -17,11 +18,17 @@ struct ContentView: View { if let step = chatModel.onboardingStage { if case .onboardingComplete = step, let user = chatModel.currentUser { - ChatListView(user: user) - .onAppear { - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) + ZStack(alignment: .top) { + ChatListView(user: user) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) + } + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call) + } + IncomingCallView() } } else { OnboardingView(onboarding: step) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e754c4d38f..4d4ae6ad91 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -9,6 +9,7 @@ import Foundation import Combine import SwiftUI +import WebKit final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @@ -28,10 +29,10 @@ final class ChatModel: ObservableObject { @Published var tokenStatus = NtfTknStatus.new // current WebRTC call @Published var callInvitations: Dictionary = [:] - @Published var activeCallInvitation: ContactRef? @Published var activeCall: Call? @Published var callCommand: WCallCommand? @Published var showCallView = false + var callWebView: WKWebView? var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index dfb01bb37b..146075ded4 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -37,25 +37,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { Task { await acceptContactRequest(contactRequest) } } else if content.categoryIdentifier == ntfCategoryCallInvitation && (action == ntfActionAcceptCall || action == ntfActionRejectCall), let chatId = content.userInfo["chatId"] as? String, - case let .direct(contact) = chatModel.getChat(chatId)?.chatInfo, let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { + let cc = CallController.shared if action == ntfActionAcceptCall { - chatModel.activeCallInvitation = nil - chatModel.activeCall = Call(contact: contact, callState: .invitationReceived, localMedia: invitation.peerMedia) - chatModel.showCallView = true - chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey) + cc.answerCall(invitation: invitation) } else { - Task { - do { - try await apiRejectCall(contact) - if chatModel.activeCall?.contact.id == chatId { - DispatchQueue.main.async { - chatModel.callCommand = .end - chatModel.activeCall = nil - } - } - } - } + cc.endCall(invitation: invitation) } } else { chatModel.chatId = content.targetContentIdentifier @@ -89,6 +76,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { // this notification is deliverd from the notifications server // when the app is in foreground it does not need to be shown case ntfCategoryCheckMessage: return [] + case ntfCategoryCallInvitation: return [] default: return [.sound, .banner, .list] } } else { @@ -136,11 +124,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { actions: [ UNNotificationAction( identifier: ntfActionAcceptCall, - title: NSLocalizedString("Answer", comment: "accept incoming call via notification") + title: NSLocalizedString("Accept", comment: "accept incoming call via notification"), + options: .foreground ), UNNotificationAction( identifier: ntfActionRejectCall, - title: NSLocalizedString("Ignore", comment: "ignore incoming call via notification") + title: NSLocalizedString("Reject", comment: "reject incoming call via notification") ) ], intentIdentifiers: [], @@ -194,9 +183,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createMessageReceivedNtf(cInfo, cItem)) } - func notifyCallInvitation(_ contact: Contact, _ invitation: CallInvitation) { + func notifyCallInvitation(_ invitation: CallInvitation) { logger.debug("NtfManager.notifyCallInvitation") - addNotification(createCallInvitationNtf(contact, invitation)) + addNotification(createCallInvitationNtf(invitation)) } // TODO remove diff --git a/apps/ios/Shared/Model/Shared/CallTypes.swift b/apps/ios/Shared/Model/Shared/CallTypes.swift index d7681541e6..402387055f 100644 --- a/apps/ios/Shared/Model/Shared/CallTypes.swift +++ b/apps/ios/Shared/Model/Shared/CallTypes.swift @@ -24,25 +24,23 @@ struct WebRTCExtraInfo: Codable { } struct CallInvitation { + var contact: Contact + var callkitUUID: UUID? var peerMedia: CallMediaType var sharedKey: String? var callTypeText: LocalizedStringKey { get { switch peerMedia { - case .video: return sharedKey == nil ? "video call (not e2e encrypted)." : "**e2e encrypted** video call." - case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)." : "**e2e encrypted** audio call." + case .video: return sharedKey == nil ? "video call (not e2e encrypted)" : "**e2e encrypted** video call" + case .audio: return sharedKey == nil ? "audio call (not e2e encrypted)" : "**e2e encrypted** audio call" } } } - var callTitle: LocalizedStringKey { - get { - switch peerMedia { - case .video: return "Incoming video call" - case .audio: return "Incoming audio call" - } - } - } - var encryptionText: LocalizedStringKey { get { sharedKey == nil ? "no e2e encryption" : "with e2e encryption" } } + + static let sampleData = CallInvitation( + contact: Contact.sampleData, + peerMedia: .audio + ) } struct CallType: Codable { diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index f737cc8912..ee3696a1ad 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -879,13 +879,13 @@ enum CICallStatus: String, Decodable { func text(_ sec: Int) -> String { switch self { case .pending: return NSLocalizedString("calling…", comment: "call status") - case .missed: return NSLocalizedString("missed", comment: "call status") - case .rejected: return NSLocalizedString("rejected", comment: "call status") - case .accepted: return NSLocalizedString("accepted", comment: "call status") - case .negotiated: return NSLocalizedString("connecting…", comment: "call status") - case .progress: return NSLocalizedString("in progress", comment: "call status") - case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended %@", comment: "call status"), CICallStatus.durationText(sec)) - case .error: return NSLocalizedString("error", comment: "call status") + case .missed: return NSLocalizedString("missed call", comment: "call status") + case .rejected: return NSLocalizedString("rejected call", comment: "call status") + case .accepted: return NSLocalizedString("accepted call", comment: "call status") + case .negotiated: return NSLocalizedString("connecting call…", comment: "call status") + case .progress: return NSLocalizedString("call in progress", comment: "call status") + case .ended: return String.localizedStringWithFormat(NSLocalizedString("ended call %@", comment: "call status"), CICallStatus.durationText(sec)) + case .error: return NSLocalizedString("call error", comment: "call status") } } diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift index 7255d8ad60..84bb1b5bad 100644 --- a/apps/ios/Shared/Model/Shared/Notifications.swift +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -50,16 +50,16 @@ func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutable ) } -func createCallInvitationNtf(_ contact: Contact, _ invitation: CallInvitation) -> UNMutableNotificationContent { +func createCallInvitationNtf(_ invitation: CallInvitation) -> UNMutableNotificationContent { let text = invitation.peerMedia == .video ? NSLocalizedString("Incoming video call", comment: "notification") : NSLocalizedString("Incoming audio call", comment: "notification") return createNotification( categoryIdentifier: ntfCategoryCallInvitation, - title: "\(contact.chatViewName):", + title: "\(invitation.contact.chatViewName):", body: text, targetContentIdentifier: nil, - userInfo: ["chatId": contact.id] + userInfo: ["chatId": invitation.contact.id] ) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d2349e505b..8035f34b2d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -11,6 +11,7 @@ import UIKit import Dispatch import BackgroundTasks import SwiftUI +import CallKit private var chatController: chat_ctrl? @@ -629,22 +630,41 @@ func processReceivedMsg(_ res: ChatResponse) { removeFile(fileName) } case let .callInvitation(contact, callType, sharedKey): - let invitation = CallInvitation(peerMedia: callType.media, sharedKey: sharedKey) + let uuid = UUID() + var invitation = CallInvitation(contact: contact, callkitUUID: uuid, peerMedia: callType.media, sharedKey: sharedKey) m.callInvitations[contact.id] = invitation - if (m.activeCallInvitation == nil) { - m.activeCallInvitation = ContactRef(contactId: contact.apiId, localDisplayName: contact.localDisplayName) + CallController.shared.reportNewIncomingCall(invitation: invitation) { error in + if let error = error { + invitation.callkitUUID = nil + m.callInvitations[contact.id] = invitation + logger.error("reportNewIncomingCall error: \(error.localizedDescription)") + } else { + logger.debug("reportNewIncomingCall success") + } } - NtfManager.shared.notifyCallInvitation(contact, invitation) + +// This will be called from notification service extension +// CXProvider.reportNewIncomingVoIPPushPayload([ +// "displayName": contact.displayName, +// "contactId": contact.id, +// "uuid": invitation.callkitUUID +// ]) { error in +// if let error = error { +// logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription)") +// } else { +// logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)") +// } +// } case let .callOffer(contact, callType, offer, sharedKey, _): - // TODO askConfirmation? - // TODO check encryption is compatible withCall(contact) { call in - m.activeCall = call.copy(callState: .offerReceived, peerMedia: callType.media, sharedKey: sharedKey) + call.callState = .offerReceived + call.peerMedia = callType.media + call.sharedKey = sharedKey m.callCommand = .offer(offer: offer.rtcSession, iceCandidates: offer.rtcIceCandidates, media: callType.media, aesKey: sharedKey, useWorker: true) } case let .callAnswer(contact, answer): withCall(contact) { call in - m.activeCall = call.copy(callState: .negotiated) + call.callState = .answerReceived m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) } case let .callExtraInfo(contact, extraInfo): @@ -652,9 +672,12 @@ func processReceivedMsg(_ res: ChatResponse) { m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) } case let .callEnded(contact): - m.activeCallInvitation = nil - withCall(contact) { _ in + if let invitation = m.callInvitations.removeValue(forKey: contact.id) { + CallController.shared.reportCallRemoteEnded(invitation: invitation) + } + withCall(contact) { call in m.callCommand = .end + CallController.shared.reportCallRemoteEnded(call: call) } default: logger.debug("unsupported event: \(res.responseType)") diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 760c02e6c4..f61105997c 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -7,160 +7,211 @@ // import SwiftUI +import WebKit struct ActiveCallView: View { - @EnvironmentObject var chatModel: ChatModel - @Environment(\.dismiss) private var dismiss - @State private var coordinator: WebRTCCoordinator? = nil - @State private var webViewReady: Bool = false + @EnvironmentObject var m: ChatModel + @ObservedObject var call: Call + @State private var rtcWebView: WKWebView? = nil @State private var webViewMsg: WVAPIMessage? = nil var body: some View { ZStack(alignment: .bottom) { - WebRTCView(coordinator: $coordinator, webViewReady: $webViewReady, webViewMsg: $webViewMsg) + WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg) .onAppear() { sendCommandToWebView() } - .onChange(of: chatModel.callCommand) { _ in sendCommandToWebView() } - .onChange(of: webViewReady) { _ in sendCommandToWebView() } + .onChange(of: m.callCommand) { _ in sendCommandToWebView() } + .onChange(of: rtcWebView) { _ in sendCommandToWebView() } .onChange(of: webViewMsg) { _ in processWebViewMessage() } .background(.black) - ActiveCallOverlay(call: chatModel.activeCall, dismiss: { dismiss() }) + if let call = m.activeCall, let webView = rtcWebView { + ActiveCallOverlay(call: call, webView: webView) + } } .preferredColorScheme(.dark) } private func sendCommandToWebView() { - if chatModel.activeCall != nil && webViewReady, - let cmd = chatModel.callCommand, - let c = coordinator { - chatModel.callCommand = nil - logger.debug("ActiveCallView: command \(cmd.cmdType)") - c.sendCommand(command: cmd) + if m.activeCall != nil, + let wv = rtcWebView, + let cmd = m.callCommand { + m.callCommand = nil + sendCallCommand(wv, cmd) } } private func processWebViewMessage() { - let m = chatModel if let msg = webViewMsg, - let call = chatModel.activeCall { + let call = m.activeCall, + let webView = rtcWebView { logger.debug("ActiveCallView: response \(msg.resp.respType)") - Task { - switch msg.resp { - case let .capabilities(capabilities): - let callType = CallType(media: call.localMedia, capabilities: capabilities) - try await apiSendCallInvitation(call.contact, callType) - m.activeCall = call.copy(callState: .invitationSent, localCapabilities: capabilities) - case let .offer(offer, iceCandidates, capabilities): - try await apiSendCallOffer(call.contact, offer, iceCandidates, - media: call.localMedia, capabilities: capabilities) - m.activeCall = call.copy(callState: .offerSent, localCapabilities: capabilities) - case let .answer(answer, iceCandidates): - try await apiSendCallAnswer(call.contact, answer, iceCandidates) - m.activeCall = call.copy(callState: .negotiated) - case let .ice(iceCandidates): - try await apiSendCallExtraInfo(call.contact, iceCandidates) - case let .connection(state): - if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), - case .connected = callStatus { - m.activeCall = call.copy(callState: .connected) + switch msg.resp { + case let .capabilities(capabilities): + let callType = CallType(media: call.localMedia, capabilities: capabilities) + Task { + do { + try await apiSendCallInvitation(call.contact, callType) + } catch { + logger.error("apiSendCallInvitation \(responseError(error))") } - try await apiCallStatus(call.contact, state.connectionState) - case let .connected(connectionInfo): - m.activeCall = call.copy(callState: .connected, connectionInfo: connectionInfo) - case .ended: - m.activeCall = nil - m.activeCallInvitation = nil - m.callCommand = nil - m.showCallView = false - case .ok: - switch msg.command { - case let .media(media, enable): - switch media { - case .video: m.activeCall = call.copy(videoEnabled: enable) - case .audio: m.activeCall = call.copy(audioEnabled: enable) - } - case let .camera(camera): - m.activeCall = call.copy(localCamera: camera) - case .end: - m.activeCall = nil - m.activeCallInvitation = nil - m.callCommand = nil - m.showCallView = false - default: () + DispatchQueue.main.async { + call.callState = .invitationSent + call.localCapabilities = capabilities } - case let .error(message): - logger.debug("ActiveCallView: command error: \(message)") - case let .invalid(type): - logger.debug("ActiveCallView: invalid response: \(type)") } + case let .offer(offer, iceCandidates, capabilities): + Task { + do { + try await apiSendCallOffer(call.contact, offer, iceCandidates, + media: call.localMedia, capabilities: capabilities) + } catch { + logger.error("apiSendCallOffer \(responseError(error))") + } + DispatchQueue.main.async { + call.callState = .offerSent + call.localCapabilities = capabilities + } + } + case let .answer(answer, iceCandidates): + Task { + do { + try await apiSendCallAnswer(call.contact, answer, iceCandidates) + } catch { + logger.error("apiSendCallAnswer \(responseError(error))") + } + DispatchQueue.main.async { + call.callState = .negotiated + } + } + case let .ice(iceCandidates): + Task { + do { + try await apiSendCallExtraInfo(call.contact, iceCandidates) + } catch { + logger.error("apiSendCallExtraInfo \(responseError(error))") + } + } + case let .connection(state): + if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), + case .connected = callStatus { + if case .outgoing = call.direction { + CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + } + call.callState = .connected + // CallKit doesn't work well with WKWebView + // This is a hack to enable microphone in WKWebView after CallKit takes over it + if CallController.useCallKit { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + m.callCommand = .camera(camera: call.localCamera) + } + } + } + Task { + do { + try await apiCallStatus(call.contact, state.connectionState) + } catch { + logger.error("apiCallStatus \(responseError(error))") + } + } + case let .connected(connectionInfo): + call.callState = .connected + call.connectionInfo = connectionInfo + case .ended: + closeCallView(webView) + call.callState = .ended + if let uuid = call.callkitUUID { + CallController.shared.endCall(callUUID: uuid) + } + case .ok: + switch msg.command { + case .answer: + call.callState = .negotiated + case let .camera(camera): + call.localCamera = camera + Task { + // This disables microphone if it was disabled before flipping the camera + await webView.setMicrophoneCaptureState(call.audioEnabled ? .active : .muted) + // This compensates for the bug on some devices when remote video does not appear + // await webView.setCameraCaptureState(.muted) + // await webView.setCameraCaptureState(call.videoEnabled ? .active : .muted) + } + case .end: + closeCallView(webView) + m.activeCall = nil + default: () + } + case let .error(message): + logger.debug("ActiveCallView: command error: \(message)") + case let .invalid(type): + logger.debug("ActiveCallView: invalid response: \(type)") } } } + + private func closeCallView(_ webView: WKWebView) { + m.showCallView = false + Task { + await webView.setMicrophoneCaptureState(.muted) + await webView.setCameraCaptureState(.muted) + } + } } struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel - var call: Call? - var dismiss: () -> Void + @ObservedObject var call: Call + var webView: WKWebView var body: some View { VStack { - if let call = call { - switch call.localMedia { - case .video: - callInfoView(call, .leading) - .foregroundColor(.white) - .opacity(0.8) - .padding() + switch call.localMedia { + case .video: + callInfoView(call, .leading) + .foregroundColor(.white) + .opacity(0.8) + .padding() + Spacer() + + HStack { + toggleAudioButton() Spacer() - - HStack { - controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { - chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled) - } - Spacer() + Color.clear.frame(width: 40, height: 40) + Spacer() + endCallButton() + Spacer() + if call.videoEnabled { + flipCameraButton() + } else { Color.clear.frame(width: 40, height: 40) - Spacer() - callButton("phone.down.fill", size: 60) { dismiss() } - .foregroundColor(.red) - Spacer() - controlButton(call, "arrow.triangle.2.circlepath") { - chatModel.callCommand = .camera(camera: call.localCamera == .user ? .environment : .user) - } - Spacer() - controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") { - chatModel.callCommand = .media(media: .video, enable: !call.videoEnabled) - } } - .padding(.horizontal, 20) - .padding(.bottom, 16) - .frame(maxWidth: .infinity, alignment: .center) - - case .audio: - VStack { - ProfileImage(imageStr: call.contact.profile.image) - .scaledToFit() - .frame(width: 192, height: 192) - callInfoView(call, .center) - } - .foregroundColor(.white) - .opacity(0.8) - .padding() - .frame(maxHeight: .infinity) - Spacer() - - ZStack(alignment: .bottom) { - controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { - chatModel.callCommand = .media(media: .audio, enable: !call.audioEnabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - callButton("phone.down.fill", size: 60) { dismiss() } - .foregroundColor(.red) - } - .padding(.bottom, 60) - .padding(.horizontal, 48) + toggleVideoButton() } + .padding(.horizontal, 20) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, alignment: .center) + + case .audio: + VStack { + ProfileImage(imageStr: call.contact.profile.image) + .scaledToFit() + .frame(width: 192, height: 192) + callInfoView(call, .center) + } + .foregroundColor(.white) + .opacity(0.8) + .padding() + .frame(maxHeight: .infinity) + + Spacer() + + ZStack(alignment: .bottom) { + toggleAudioButton() + .frame(maxWidth: .infinity, alignment: .leading) + endCallButton() + } + .padding(.bottom, 60) + .padding(.horizontal, 48) } } .frame(maxWidth: .infinity) @@ -186,6 +237,57 @@ struct ActiveCallOverlay: View { } } + private func endCallButton() -> some View { + let cc = CallController.shared + return callButton("phone.down.fill", size: 60) { + if let uuid = call.callkitUUID { + cc.endCall(callUUID: uuid) + } else { + cc.endCall(call: call) {} + } + } + .foregroundColor(.red) + } + + private func toggleAudioButton() -> some View { + controlButton(call, call.audioEnabled ? "mic.fill" : "mic.slash") { + Task { + await webView.setMicrophoneCaptureState(call.audioEnabled ? .muted : .active) + DispatchQueue.main.async { + call.audioEnabled = !call.audioEnabled + } + } + } + } + + private func toggleVideoButton() -> some View { + controlButton(call, call.videoEnabled ? "video.fill" : "video.slash") { + Task { + await webView.setCameraCaptureState(call.videoEnabled ? .muted : .active) + DispatchQueue.main.async { + call.videoEnabled = !call.videoEnabled + } + } + } + } + + @ViewBuilder private func flipCameraButton() -> some View { + let cmd = WCallCommand.camera(camera: call.localCamera == .user ? .environment : .user) + controlButton(call, "arrow.triangle.2.circlepath") { + if call.audioEnabled { + chatModel.callCommand = cmd + } else { + Task { + // Microphone has to be enabled before flipping the camera to avoid prompt for user permission when getUserMedia is called in webview + await webView.setMicrophoneCaptureState(.active) + DispatchQueue.main.async { + chatModel.callCommand = cmd + } + } + } + } + } + @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View { if call.hasMedia { callButton(imageName, size: 40, perform) @@ -211,9 +313,9 @@ struct ActiveCallOverlay: View { struct ActiveCallOverlay_Previews: PreviewProvider { static var previews: some View { Group{ - ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .video), dismiss: {}) + ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), webView: WKWebView()) .background(.black) - ActiveCallOverlay(call: Call(contact: Contact.sampleData, callState: .offerSent, localMedia: .audio), dismiss: {}) + ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), webView: WKWebView()) .background(.black) } } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift new file mode 100644 index 0000000000..ecd7633ec1 --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -0,0 +1,215 @@ +// +// CallController.swift +// SimpleX (iOS) +// +// Created by Evgeny on 21/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import CallKit +import AVFoundation + +class CallController: NSObject, CXProviderDelegate, ObservableObject { + static let useCallKit = false + static let shared = CallController() + private let provider = CXProvider(configuration: CallController.configuration) + private let controller = CXCallController() + private let callManager = CallManager() + @Published var activeCallInvitation: CallInvitation? + +// PKPushRegistry will be used from notification service extension +// let registry = PKPushRegistry(queue: nil) + + static let configuration: CXProviderConfiguration = { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.supportedHandleTypes = [.generic] + configuration.includesCallsInRecents = true // TODO disable or add option + configuration.maximumCallsPerCallGroup = 1 + return configuration + }() + + override init() { + super.init() + self.provider.setDelegate(self, queue: nil) +// self.registry.delegate = self +// self.registry.desiredPushTypes = [.voIP] + } + + func providerDidReset(_ provider: CXProvider) { + } + + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + logger.debug("CallController.provider CXStartCallAction") + if callManager.startOutgoingCall(callUUID: action.callUUID) { + action.fulfill() + provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: nil) + } else { + action.fail() + } + } + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + logger.debug("CallController.provider CXAnswerCallAction") + if callManager.answerIncomingCall(callUUID: action.callUUID) { + action.fulfill() + } else { + action.fail() + } + } + + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + logger.debug("CallController.provider CXEndCallAction") + callManager.endCall(callUUID: action.callUUID) { ok in + if ok { + action.fulfill() + } else { + action.fail() + } + } + } + + func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { + print("timed out", #function) + action.fulfill() + } + + func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + print("received", #function) +// do { +// try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers) +// logger.debug("audioSession category set") +// try audioSession.setActive(true) +// logger.debug("audioSession activated") +// } catch { +// print(error) +// logger.error("failed activating audio session") +// } + } + + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + print("received", #function) + } + +// func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { +// +// } + +// This will be needed when we have notification service extension +// func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { +// if type == .voIP { +// // Extract the call information from the push notification payload +// if let displayName = payload.dictionaryPayload["displayName"] as? String, +// let contactId = payload.dictionaryPayload["contactId"] as? String, +// let uuidStr = payload.dictionaryPayload["uuid"] as? String, +// let uuid = UUID(uuidString: uuidStr) { +// let callUpdate = CXCallUpdate() +// callUpdate.remoteHandle = CXHandle(type: .phoneNumber, value: displayName) +// provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in +// if error != nil { +// let m = ChatModel.shared +// m.callInvitations.removeValue(forKey: contactId) +// } +// // Tell PushKit that the notification is handled. +// completion() +// }) +// } +// } +// } + + func reportNewIncomingCall(invitation: CallInvitation, completion: @escaping (Error?) -> Void) { + logger.debug("CallController.reportNewIncomingCall") + if CallController.useCallKit, let uuid = invitation.callkitUUID { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.displayName) + update.hasVideo = invitation.peerMedia == .video + provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) + } else { + NtfManager.shared.notifyCallInvitation(invitation) + activeCallInvitation = invitation + } + } + + func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { + if CallController.useCallKit, let uuid = call.callkitUUID { + provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) + } + } + + func reportCallRemoteEnded(invitation: CallInvitation) { + if CallController.useCallKit, let uuid = invitation.callkitUUID { + provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } else if invitation.contact.id == activeCallInvitation?.contact.id { + activeCallInvitation = nil + } + } + + func reportCallRemoteEnded(call: Call) { + if CallController.useCallKit, let uuid = call.callkitUUID { + provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } + } + + func startCall(_ contact: Contact, _ media: CallMediaType) { + logger.debug("CallController.startCall") + let uuid = callManager.newOutgoingCall(contact, media) + if CallController.useCallKit { + let handle = CXHandle(type: .generic, value: contact.displayName) + let action = CXStartCallAction(call: uuid, handle: handle) + action.isVideo = media == .video + requestTransaction(with: action) + } else if callManager.startOutgoingCall(callUUID: uuid) { + logger.debug("CallController.startCall: call started") + } else { + logger.error("CallController.startCall: no active call") + } + } + + func answerCall(invitation: CallInvitation) { + callManager.answerIncomingCall(invitation: invitation) + if invitation.contact.id == self.activeCallInvitation?.contact.id { + self.activeCallInvitation = nil + } + } + + func endCall(callUUID: UUID) { + if CallController.useCallKit { + requestTransaction(with: CXEndCallAction(call: callUUID)) + } else { + callManager.endCall(callUUID: callUUID) { ok in + if ok { + logger.debug("CallController.endCall: call ended") + } else { + logger.error("CallController.endCall: no actove call pr call invitation to end") + } + } + } + } + + func endCall(invitation: CallInvitation) { + callManager.endCall(invitation: invitation) { + if invitation.contact.id == self.activeCallInvitation?.contact.id { + DispatchQueue.main.async { + self.activeCallInvitation = nil + } + } + } + } + + func endCall(call: Call, completed: @escaping () -> Void) { + callManager.endCall(call: call, completed: completed) + } + + private func requestTransaction(with action: CXAction) { + let t = CXTransaction() + t.addAction(action) + controller.request(t) { error in + if let error = error { + logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)") + } else { + logger.debug("CallController.requestTransaction requested transaction successfully") + } + } + } +} diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift new file mode 100644 index 0000000000..f22535e9ad --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -0,0 +1,104 @@ +// +// CallManager.swift +// SimpleX (iOS) +// +// Created by Evgeny on 22/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +class CallManager { + func newOutgoingCall(_ contact: Contact, _ media: CallMediaType) -> UUID { + let uuid = UUID() + ChatModel.shared.activeCall = Call(direction: .outgoing, contact: contact, callkitUUID: uuid, callState: .waitCapabilities, localMedia: media) + return uuid + } + + func startOutgoingCall(callUUID: UUID) -> Bool { + let m = ChatModel.shared + if let call = m.activeCall, call.callkitUUID == callUUID { + m.showCallView = true + m.callCommand = .capabilities(useWorker: true) + return true + } + return false + } + + func answerIncomingCall(callUUID: UUID) -> Bool { + if let invitation = getCallInvitation(callUUID) { + answerIncomingCall(invitation: invitation) + return true + } + return false + } + + func answerIncomingCall(invitation: CallInvitation) { + let m = ChatModel.shared + m.callInvitations.removeValue(forKey: invitation.contact.id) + m.activeCall = Call( + direction: .incoming, + contact: invitation.contact, + callkitUUID: invitation.callkitUUID, + callState: .invitationAccepted, + localMedia: invitation.peerMedia, + sharedKey: invitation.sharedKey + ) + m.showCallView = true + m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) + } + + func endCall(callUUID: UUID, completed: @escaping (Bool) -> Void) { + if let call = ChatModel.shared.activeCall, call.callkitUUID == callUUID { + endCall(call: call) { completed(true) } + } else if let invitation = getCallInvitation(callUUID) { + endCall(invitation: invitation) { completed(true) } + } else { + completed(false) + } + } + + func endCall(call: Call, completed: @escaping () -> Void) { + let m = ChatModel.shared + if case .ended = call.callState { + logger.debug("CallController.provider CXEndCallAction: call ended") + m.activeCall = nil + m.showCallView = false + completed() + } else { + logger.debug("CallController.provider CXEndCallAction: ending call...") + m.callCommand = .end + m.showCallView = false + Task { + do { + try await apiEndCall(call.contact) + } catch { + logger.error("CallController.provider apiEndCall error: \(responseError(error))") + } + DispatchQueue.main.async { + m.activeCall = nil + completed() + } + } + } + } + + func endCall(invitation: CallInvitation, completed: @escaping () -> Void) { + ChatModel.shared.callInvitations.removeValue(forKey: invitation.contact.id) + Task { + do { + try await apiRejectCall(invitation.contact) + } catch { + logger.error("CallController.provider apiRejectCall error: \(responseError(error))") + } + completed() + } + } + + private func getCallInvitation(_ callUUID: UUID) -> CallInvitation? { + if let (_, invitation) = ChatModel.shared.callInvitations.first(where: { (_, inv) in inv.callkitUUID == callUUID }) { + return invitation + } + return nil + } +} diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift new file mode 100644 index 0000000000..089ffea5f5 --- /dev/null +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -0,0 +1,86 @@ +// +// IncomingCallView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct IncomingCallView: View { + @EnvironmentObject var m: ChatModel + @ObservedObject var cc = CallController.shared + + var body: some View { + let sp = SoundPlayer.shared + if let invitation = cc.activeCallInvitation { + if m.showCallView { + incomingCall(invitation) + } else { + incomingCall(invitation) + .onAppear { sp.startRingtone() } + .onDisappear { sp.stopRingtone() } + } + } + } + + private func incomingCall(_ invitation: CallInvitation) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: invitation.peerMedia == .video ? "video.fill" : "phone.fill").foregroundColor(.green) + Text(invitation.callTypeText) + } + HStack { + ProfilePreview(profileOf: invitation.contact, color: .white) + Spacer() + + callButton("Reject", "phone.down.fill", .red) { + cc.endCall(invitation: invitation) + } + + callButton("Ignore", "multiply", .accentColor) { + cc.activeCallInvitation = nil + } + + callButton("Accept", "checkmark", .green) { + if let call = m.activeCall { + cc.endCall(call: call) { + DispatchQueue.main.async { + cc.answerCall(invitation: invitation) + } + } + } else { + cc.answerCall(invitation: invitation) + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + } + + private func callButton(_ text: LocalizedStringKey, _ image: String, _ color: Color, action: @escaping () -> Void) -> some View { + Button(action: action, label: { + VStack(spacing: 2) { + Image(systemName: image) + .scaleEffect(1.24) + .foregroundColor(color) + .frame(width: 24, height: 24) + Text(text) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(minWidth: 44) + }) + } +} + +struct IncomingCallView_Previews: PreviewProvider { + static var previews: some View { + CallController.shared.activeCallInvitation = CallInvitation.sampleData + return IncomingCallView() + } +} diff --git a/apps/ios/Shared/Views/Call/SoundPlayer.swift b/apps/ios/Shared/Views/Call/SoundPlayer.swift new file mode 100644 index 0000000000..17c13ab403 --- /dev/null +++ b/apps/ios/Shared/Views/Call/SoundPlayer.swift @@ -0,0 +1,45 @@ +// +// SoundPlayer.swift +// SimpleX (iOS) +// +// Created by Evgeny on 24/05/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import AVFoundation + +class SoundPlayer { + static let shared = SoundPlayer() + private var audioPlayer: AVAudioPlayer? + + func startRingtone() { + audioPlayer?.stop() + logger.debug("startRingtone") + guard let path = Bundle.main.path(forResource: "ringtone2", ofType: "m4a", inDirectory: "sounds") else { + logger.debug("startRingtone: file not found") + return + } + do { + let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) + if player.prepareToPlay() { + audioPlayer = player + } + } catch { + logger.debug("startRingtone: AVAudioPlayer error \(error.localizedDescription)") + } + + Task { + while let player = audioPlayer { + player.play() + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + _ = try? await Task.sleep(nanoseconds: UInt64(player.duration * 1_000_000_000)) + } + } + } + + func stopRingtone() { + audioPlayer?.stop() + audioPlayer = nil + } +} diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 066dcc7798..49a3782eb7 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -9,70 +9,39 @@ import Foundation import SwiftUI -class Call: Equatable { +class Call: ObservableObject, Equatable { static func == (lhs: Call, rhs: Call) -> Bool { lhs.contact.apiId == rhs.contact.apiId } + var direction: CallDirection var contact: Contact - var callState: CallState + var callkitUUID: UUID? var localMedia: CallMediaType - var localCapabilities: CallCapabilities? - var peerMedia: CallMediaType? - var sharedKey: String? - var audioEnabled: Bool - var videoEnabled: Bool - var localCamera: VideoCamera - var connectionInfo: ConnectionInfo? + @Published var callState: CallState + @Published var localCapabilities: CallCapabilities? + @Published var peerMedia: CallMediaType? + @Published var sharedKey: String? + @Published var audioEnabled = true + @Published var videoEnabled: Bool + @Published var localCamera = VideoCamera.user + @Published var connectionInfo: ConnectionInfo? init( + direction: CallDirection, contact: Contact, + callkitUUID: UUID?, callState: CallState, localMedia: CallMediaType, - localCapabilities: CallCapabilities? = nil, - peerMedia: CallMediaType? = nil, - sharedKey: String? = nil, - audioEnabled: Bool? = nil, - videoEnabled: Bool? = nil, - localCamera: VideoCamera = .user, - connectionInfo: ConnectionInfo? = nil + sharedKey: String? = nil ) { + self.direction = direction self.contact = contact + self.callkitUUID = callkitUUID self.callState = callState self.localMedia = localMedia - self.localCapabilities = localCapabilities - self.peerMedia = peerMedia self.sharedKey = sharedKey - self.audioEnabled = audioEnabled ?? true - self.videoEnabled = videoEnabled ?? (localMedia == .video) - self.localCamera = localCamera - self.connectionInfo = connectionInfo - } - - func copy( - contact: Contact? = nil, - callState: CallState? = nil, - localMedia: CallMediaType? = nil, - localCapabilities: CallCapabilities? = nil, - peerMedia: CallMediaType? = nil, - sharedKey: String? = nil, - audioEnabled: Bool? = nil, - videoEnabled: Bool? = nil, - localCamera: VideoCamera? = nil, - connectionInfo: ConnectionInfo? = nil - ) -> Call { - Call ( - contact: contact ?? self.contact, - callState: callState ?? self.callState, - localMedia: localMedia ?? self.localMedia, - localCapabilities: localCapabilities ?? self.localCapabilities, - peerMedia: peerMedia ?? self.peerMedia, - sharedKey: sharedKey ?? self.sharedKey, - audioEnabled: audioEnabled ?? self.audioEnabled, - videoEnabled: videoEnabled ?? self.videoEnabled, - localCamera: localCamera ?? self.localCamera, - connectionInfo: connectionInfo ?? self.connectionInfo - ) + self.videoEnabled = localMedia == .video } var encrypted: Bool { get { localEncrypted && sharedKey != nil } } @@ -82,7 +51,7 @@ class Call: Equatable { switch callState { case .waitCapabilities: return "" case .invitationSent: return localEncrypted ? "e2e encrypted" : "no e2e encryption" - case .invitationReceived: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption" + case .invitationAccepted: return sharedKey == nil ? "contact has no e2e encryption" : "contact has e2e encryption" default: return !localEncrypted ? "no e2e encryption" : sharedKey == nil ? "contact has no e2e encryption" : "e2e encrypted" } } @@ -90,24 +59,33 @@ class Call: Equatable { var hasMedia: Bool { get { callState == .offerSent || callState == .negotiated || callState == .connected } } } +enum CallDirection { + case incoming + case outgoing +} + enum CallState { - case waitCapabilities - case invitationSent - case invitationReceived - case offerSent - case offerReceived - case negotiated + case waitCapabilities // outgoing call started + case invitationSent // outgoing call - sent invitation + case invitationAccepted // incoming call started + case offerSent // incoming - webrtc started and offer sent + case offerReceived // outgoing - webrtc offer received via API + case answerReceived // incoming - webrtc answer received via API + case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed case connected + case ended var text: LocalizedStringKey { switch self { case .waitCapabilities: return "starting…" case .invitationSent: return "waiting for answer…" - case .invitationReceived: return "starting…" + case .invitationAccepted: return "starting…" case .offerSent: return "waiting for confirmation…" case .offerReceived: return "received answer…" + case .answerReceived: return "received confirmation…" case .negotiated: return "connecting…" case .connected: return "connected" + case .ended: return "ended" } } } @@ -275,16 +253,16 @@ enum WCallResponse: Equatable, Decodable { var respType: String { get { switch self { - case .capabilities: return("capabilities") - case .offer: return("offer") - case .answer: return("answer") - case .ice: return("ice") - case .connection: return("connection") - case .connected: return("connected") - case .ended: return("ended") - case .ok: return("ok") - case .error: return("error") - case .invalid: return("invalid") + case .capabilities: return "capabilities" + case .offer: return "offer" + case .answer: return "answer" + case .ice: return "ice" + case .connection: return "connection" + case .connected: return "connected" + case .ended: return "ended" + case .ok: return "ok" + case .error: return "error" + case .invalid: return "invalid" } } } diff --git a/apps/ios/Shared/Views/Call/WebRTCView.swift b/apps/ios/Shared/Views/Call/WebRTCView.swift index fc4d7310b4..428ef130b5 100644 --- a/apps/ios/Shared/Views/Call/WebRTCView.swift +++ b/apps/ios/Shared/Views/Call/WebRTCView.swift @@ -9,20 +9,24 @@ import SwiftUI import WebKit -class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { - var webViewReady: Binding +class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate { + var rtcWebView: Binding var webViewMsg: Binding - private var webView: WKWebView? - internal init(webViewReady: Binding, webViewMsg: Binding) { - self.webViewReady = webViewReady + internal init(rtcWebView: Binding, webViewMsg: Binding) { + self.rtcWebView = rtcWebView self.webViewMsg = webViewMsg } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webView.allowsBackForwardNavigationGestures = false - self.webView = webView - webViewReady.wrappedValue = true + self.rtcWebView.wrappedValue = webView + ChatModel.shared.callWebView = webView + } + + func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin : WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision { + print("webView", #function) + return .grant } // receive message from WKWebView @@ -31,34 +35,37 @@ class WebRTCCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler didReceive message: WKScriptMessage ) { logger.debug("WebRTCCoordinator.userContentController") - if let msgStr = message.body as? String, - let msg: WVAPIMessage = decodeJSON(msgStr) { - webViewMsg.wrappedValue = msg - if case .invalid = msg.resp { - logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") + switch message.name { + case "webrtc": + if let msgStr = message.body as? String, + let msg: WVAPIMessage = decodeJSON(msgStr) { + // this is the binding that communicates messages from webview to swift view + webViewMsg.wrappedValue = msg + if case .invalid = msg.resp { + logger.error("WebRTCCoordinator.userContentController: invalid message \(String(describing: message.body))") + } + } else { + logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))") } - } else { - logger.error("WebRTCCoordinator.userContentController: message parsing error \(String(describing: message.body))") - } - } - - func sendCommand(command: WCallCommand) { - if let webView = webView { - logger.debug("WebRTCCoordinator.sendCommand") - let apiCmd = encodeJSON(WVAPICall(command: command)) - let js = "processCommand(\(apiCmd))" - webView.evaluateJavaScript(js) + case "logger": + if let msgStr = message.body as? String { + logger.error("WebRTCCoordinator console.log: \(msgStr)") + } else { + logger.error("WebRTCCoordinator console.log: \(String(describing: message.body))") + } + default: + logger.error("WebRTCCoordinator.userContentController: invalid message.name \(message.name)") } } } struct WebRTCView: UIViewRepresentable { - @Binding var coordinator: WebRTCCoordinator? - @Binding var webViewReady: Bool + @State private var coordinator: WebRTCCoordinator? + @Binding var rtcWebView: WKWebView? @Binding var webViewMsg: WVAPIMessage? func makeCoordinator() -> WebRTCCoordinator { - WebRTCCoordinator(webViewReady: $webViewReady, webViewMsg: $webViewMsg) + WebRTCCoordinator(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg) } func makeUIView(context: Context) -> WKWebView { @@ -72,10 +79,14 @@ struct WebRTCView: UIViewRepresentable { cfg.mediaTypesRequiringUserActionForPlayback = [] cfg.allowsInlineMediaPlayback = true - let source = "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))" - let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) - wkController.addUserScript(script) - wkController.add(wkCoordinator, name: "webrtc") + let addScript = { (handler: String, source: String) in + let script = WKUserScript(source: source, injectionTime: .atDocumentEnd, forMainFrameOnly: false) + wkController.addUserScript(script) + wkController.add(wkCoordinator, name: handler) + } + + addScript("webrtc", "sendMessageToNative = (msg) => webkit.messageHandlers.webrtc.postMessage(JSON.stringify(msg))") + addScript("logger", "console.log = (arg) => webkit.messageHandlers.logger.postMessage(JSON.stringify(arg))") let wkWebView = WKWebView(frame: .zero, configuration: cfg) wkWebView.navigationDelegate = wkCoordinator @@ -93,16 +104,22 @@ struct WebRTCView: UIViewRepresentable { } } +func sendCallCommand(_ webView: WKWebView, _ command: WCallCommand) { + logger.debug("sendCallCommand: \(command.cmdType)") + let apiCmd = encodeJSON(WVAPICall(command: command)) + let js = "processCommand(\(apiCmd))" + webView.evaluateJavaScript(js) +} + struct CallViewDebug: View { - @State private var coordinator: WebRTCCoordinator? = nil @State private var commandStr = "" - @State private var webViewReady: Bool = false + @State private var rtcWebView: WKWebView? = nil @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) + WebRTCView(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg).frame(maxHeight: 260) .onChange(of: webViewMsg) { _ in if let resp = webViewMsg { commandStr = encodeJSON(resp) @@ -130,21 +147,21 @@ struct CallViewDebug: View { commandStr = "" } Button("Send") { - if let c = coordinator, + if let wv = rtcWebView, let command: WCallCommand = decodeJSON(commandStr) { - c.sendCommand(command: command) + sendCallCommand(wv, command) } } } HStack(spacing: 20) { Button("Capabilities") { - if let c = coordinator { - c.sendCommand(command: .capabilities(useWorker: true)) + if let wv = rtcWebView { + sendCallCommand(wv, .capabilities(useWorker: true)) } } Button("Start") { - if let c = coordinator { - c.sendCommand(command: .start(media: .video)) + if let wv = rtcWebView { + sendCallCommand(wv, .start(media: .video)) } } Button("Accept") { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 96465ae387..6b3498900b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -26,7 +26,7 @@ struct CICallItemView: View { acceptCallButton() } case .missed: missedCallIcon(sent).foregroundColor(.red) - case .rejected: Image(systemName: "phone.down").foregroundColor(.secondary) + case .rejected: Image(systemName: "phone.down").foregroundColor(.red) case .accepted: connectingCallIcon() case .negotiated: connectingCallIcon() case .progress: Image(systemName: "phone.and.waveform.fill").foregroundColor(.green) @@ -61,16 +61,9 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { if case let .direct(contact) = chatInfo { Button { - if let invitation = m.callInvitations.removeValue(forKey: contact.id) { - m.activeCallInvitation = nil - m.activeCall = Call( - contact: contact, - callState: .invitationReceived, - localMedia: invitation.peerMedia, - sharedKey: invitation.sharedKey - ) - m.showCallView = true - m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) + if let invitation = m.callInvitations[contact.id] { + CallController.shared.answerCall(invitation: invitation) + logger.debug("acceptCallButton call answered") } else { AlertManager.shared.showAlertMsg(title: "Call already ended!") } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1f1bf1104a..579069435a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -105,27 +105,21 @@ struct ChatView: View { ChatInfoView(chat: chat, showChatInfo: $showChatInfo) } } -// ToolbarItem(placement: .navigationBarTrailing) { -// if case let .direct(contact) = cInfo { -// HStack { -// callButton(contact, .audio, imageName: "phone") -// callButton(contact, .video, imageName: "video") -// } -// } -// } + ToolbarItem(placement: .navigationBarTrailing) { + if case let .direct(contact) = cInfo { + HStack { + callButton(contact, .audio, imageName: "phone") + callButton(contact, .video, imageName: "video") + } + } + } } .navigationBarBackButtonHidden(true) } private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { - chatModel.activeCall = Call( - contact: contact, - callState: .waitCapabilities, - localMedia: media - ) - chatModel.showCallView = true - chatModel.callCommand = .capabilities(useWorker: true) + CallController.shared.startCall(contact, media) } label: { Image(systemName: imageName) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9f6da6c6d2..746763583a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -49,29 +49,6 @@ struct ChatListView: View { NewChatButton() } } - .fullScreenCover(isPresented: $chatModel.showCallView) { - ActiveCallView() - } - .onChange(of: chatModel.showCallView) { _ in - if (chatModel.showCallView) { return } - if let call = chatModel.activeCall { - Task { - do { - try await apiEndCall(call.contact) - } catch { - logger.error("ChatListView apiEndCall error: \(error.localizedDescription)") - } - } - } - chatModel.callCommand = .end - } - .onChange(of: chatModel.activeCallInvitation) { _ in - if let contactRef = chatModel.activeCallInvitation, - case let .direct(contact) = chatModel.getChat(contactRef.id)?.chatInfo, - let invitation = chatModel.callInvitations[contactRef.id] { - answerCallAlert(contact, invitation) - } - } } .navigationViewStyle(.stack) @@ -95,34 +72,6 @@ struct ChatListView: View { $0.chatInfo.chatViewName.localizedLowercase.contains(s) } } - - private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) { - return AlertManager.shared.showAlert(Alert( - title: Text(invitation.callTitle), - message: Text(contact.profile.displayName).bold() + - Text(" wants to connect with you via ") + - Text(invitation.callTypeText), - primaryButton: .default(Text("Answer")) { - if let activeCallInvitation = chatModel.activeCallInvitation { - chatModel.callInvitations.removeValue(forKey: activeCallInvitation.id) - chatModel.activeCallInvitation = nil - chatModel.activeCall = Call( - contact: contact, - callState: .invitationReceived, - localMedia: invitation.peerMedia, - sharedKey: invitation.sharedKey - ) - chatModel.showCallView = true - chatModel.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) - } else { - DispatchQueue.main.async { - AlertManager.shared.showAlertMsg(title: "Call already ended!") - } - } - }, - secondaryButton: .cancel() - )) - } } struct ChatListView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 05af85086b..684e16928a 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -38,18 +38,7 @@ struct SettingsView: View { UserProfile() .navigationTitle("Your chat profile") } label: { - HStack { - ProfileImage(imageStr: user.image) - .frame(width: 44, height: 44) - .padding(.trailing, 6) - .padding(.vertical, 6) - VStack(alignment: .leading) { - Text(user.displayName) - .fontWeight(.bold) - .font(.title2) - Text(user.fullName) - } - } + ProfilePreview(profileOf: user) .padding(.leading, -8) } NavigationLink { @@ -242,6 +231,26 @@ struct SettingsView: View { } } +struct ProfilePreview: View { + var profileOf: NamedChat + var color = Color(uiColor: .tertiarySystemGroupedBackground) + + var body: some View { + HStack { + ProfileImage(imageStr: profileOf.image, color: color) + .frame(width: 44, height: 44) + .padding(.trailing, 6) + .padding(.vertical, 6) + VStack(alignment: .leading) { + Text(profileOf.displayName) + .fontWeight(.bold) + .font(.title2) + Text(profileOf.fullName) + } + } + } +} + struct SettingsView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 4bdf3d3203..0d2ab9e89b 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -90,14 +90,14 @@ **Scan QR code**: to connect to your contact in person or via video call. No comment provided by engineer. - - **e2e encrypted** audio call. - **e2e encrypted** audio call. + + **e2e encrypted** audio call + **e2e encrypted** audio call No comment provided by engineer. - - **e2e encrypted** video call. - **e2e encrypted** video call. + + **e2e encrypted** video call + **e2e encrypted** video call No comment provided by engineer. @@ -1118,9 +1118,9 @@ SimpleX servers cannot see your profile. accepted call status - - audio call (not e2e encrypted). - audio call (not e2e encrypted). + + audio call (not e2e encrypted) + audio call (not e2e encrypted) No comment provided by engineer. @@ -1279,9 +1279,9 @@ SimpleX servers cannot see your profile. via relay No comment provided by engineer. - - video call (not e2e encrypted). - video call (not e2e encrypted). + + video call (not e2e encrypted) + video call (not e2e encrypted) No comment provided by engineer. @@ -1380,14 +1380,14 @@ SimpleX servers cannot see your profile. %@ wants to connect! notification title - - **e2e encrypted** audio call. - **e2e encrypted** audio call. + + **e2e encrypted** audio call + **e2e encrypted** audio call No comment provided by engineer. - - **e2e encrypted** video call. - **e2e encrypted** video call. + + **e2e encrypted** video call + **e2e encrypted** video call No comment provided by engineer. @@ -1415,9 +1415,9 @@ SimpleX servers cannot see your profile. accepted call status - - audio call (not e2e encrypted). - audio call (not e2e encrypted). + + audio call (not e2e encrypted) + audio call (not e2e encrypted) No comment provided by engineer. @@ -1491,9 +1491,9 @@ SimpleX servers cannot see your profile. via one-time link chat list item description - - video call (not e2e encrypted). - video call (not e2e encrypted). + + video call (not e2e encrypted) + video call (not e2e encrypted) No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 8bbbb124c6..c26a7bb81e 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -90,14 +90,14 @@ **Сканировать QR код**: соединиться с вашим контактом при встрече или во время видеозвонка. No comment provided by engineer. - - **e2e encrypted** audio call. - **e2e зашифрованный** аудиозвонок. + + **e2e encrypted** audio call + **e2e зашифрованный** аудиозвонок No comment provided by engineer. - - **e2e encrypted** video call. - **e2e зашифрованный** видеозвонок. + + **e2e encrypted** video call + **e2e зашифрованный** видеозвонок No comment provided by engineer. @@ -1118,9 +1118,9 @@ SimpleX серверы не могут получить доступ к ваше принятый звонок call status - - audio call (not e2e encrypted). - аудиозвонок (не e2e зашифрованный). + + audio call (not e2e encrypted) + аудиозвонок (не e2e зашифрованный) No comment provided by engineer. @@ -1279,9 +1279,9 @@ SimpleX серверы не могут получить доступ к ваше через relay сервер No comment provided by engineer. - - video call (not e2e encrypted). - видеозвонок (не e2e зашифрованный). + + video call (not e2e encrypted) + видеозвонок (не e2e зашифрованный) No comment provided by engineer. @@ -1380,14 +1380,14 @@ SimpleX серверы не могут получить доступ к ваше %@ хочет соединиться! notification title - - **e2e encrypted** audio call. - **e2e зашифрованный** аудиозвонок. + + **e2e encrypted** audio call + **e2e зашифрованный** аудиозвонок No comment provided by engineer. - - **e2e encrypted** video call. - **e2e зашифрованный** видеозвонок. + + **e2e encrypted** video call + **e2e зашифрованный** видеозвонок No comment provided by engineer. @@ -1415,9 +1415,9 @@ SimpleX серверы не могут получить доступ к ваше принятый звонок call status - - audio call (not e2e encrypted). - аудиозвонок (не e2e зашифрованный). + + audio call (not e2e encrypted) + аудиозвонок (не e2e зашифрованный) No comment provided by engineer. @@ -1491,9 +1491,9 @@ SimpleX серверы не могут получить доступ к ваше через одноразовую ссылку chat list item description - - video call (not e2e encrypted). - видеозвонок (не e2e зашифрованный). + + video call (not e2e encrypted) + видеозвонок (не e2e зашифрованный) No comment provided by engineer. diff --git a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings index fc8627b30f..ab483cb82e 100644 --- a/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/ru.lproj/Localizable.strings @@ -1,8 +1,8 @@ /* No comment provided by engineer. */ -"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок."; +"**e2e encrypted** audio call" = "**e2e зашифрованный** аудиозвонок"; /* No comment provided by engineer. */ -"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок."; +"**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок"; /* notification title */ "%@ is connected!" = "соединение с %@ установлено!"; @@ -17,7 +17,7 @@ "accepted" = "принятый звонок"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный)."; +"audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; /* call status */ "calling…" = "входящий звонок…"; @@ -69,7 +69,7 @@ "via one-time link" = "через одноразовую ссылку"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный)."; +"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; /* No comment provided by engineer. */ "with e2e encryption" = "e2e зашифровано"; diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 1287772e17..01f16a5a1c 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -23,8 +23,10 @@ UIBackgroundModes + audio fetch remote-notification + voip diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f4f5559a2f..c028015df9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; + 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C13730A28156D2700F43030 /* ContactConnectionView.swift */; }; @@ -27,6 +28,10 @@ 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; + 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; + 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; + 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; + 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; 5C5E5D3D282447AB00B0488A /* CallTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3C282447AB00B0488A /* CallTypes.swift */; }; @@ -130,6 +135,7 @@ 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; + 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C13730A28156D2700F43030 /* ContactConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionView.swift; sourceTree = ""; }; @@ -146,6 +152,10 @@ 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; + 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; + 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; + 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; + 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveCallView.swift; sourceTree = ""; }; 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; @@ -271,6 +281,10 @@ 3C714776281C081000CB4D4B /* WebRTCView.swift */, 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */, 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */, + 5C029EA9283942EA004A9677 /* CallController.swift */, + 5C55A91E283AD0E400C4E99E /* CallManager.swift */, + 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */, + 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */, ); path = Call; sourceTree = ""; @@ -362,6 +376,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 5C55A92D283D0FDE00C4E99E /* sounds */, 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, @@ -625,6 +640,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C55A92E283D0FDE00C4E99E /* sounds in Resources */, 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, @@ -657,6 +673,7 @@ files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, + 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, @@ -667,6 +684,7 @@ 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, + 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, @@ -701,10 +719,12 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */, 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */, 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, + 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 459f5515bf..16563e6163 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -38,10 +38,10 @@ "**Create link / QR code** for your contact to use." = "**Создать ссылку / QR код** для вашего контакта."; /* No comment provided by engineer. */ -"**e2e encrypted** audio call." = "**e2e зашифрованный** аудиозвонок."; +"**e2e encrypted** audio call" = "**e2e зашифрованный** аудиозвонок"; /* No comment provided by engineer. */ -"**e2e encrypted** video call." = "**e2e зашифрованный** видеозвонок."; +"**e2e encrypted** video call" = "**e2e зашифрованный** видеозвонок"; /* No comment provided by engineer. */ "**Paste received link** or open it in the browser and tap **Open in mobile app**." = "**Вставить полученную ссылку**, или откройте её в браузере и нажмите **Open in mobile app**."; @@ -122,7 +122,7 @@ "Attach" = "Прикрепить"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)." = "аудиозвонок (не e2e зашифрованный)."; +"audio call (not e2e encrypted)" = "аудиозвонок (не e2e зашифрованный)"; /* No comment provided by engineer. */ "bold" = "жирный"; @@ -678,7 +678,7 @@ "via relay" = "через relay сервер"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)." = "видеозвонок (не e2e зашифрованный)."; +"video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; /* No comment provided by engineer. */ "waiting for answer…" = "ожидается ответ…"; diff --git a/apps/ios/sounds/ringtone2.m4a b/apps/ios/sounds/ringtone2.m4a new file mode 100644 index 0000000000000000000000000000000000000000..73c79ec94cff535485395b3a017dc37b9b7579bf GIT binary patch literal 46459 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 literal 0 HcmV?d00001 diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index b8b7cfdb2f..f0d707f03f 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -289,7 +289,8 @@ const processCommand = (function () { const pc = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() const localCamera = VideoCamera.User - const localStream = await navigator.mediaDevices.getUserMedia(callMediaConstraints(mediaType, localCamera)) + const constraints = callMediaConstraints(mediaType, localCamera) + const localStream = await navigator.mediaDevices.getUserMedia(constraints) const iceCandidates = getIceCandidates(pc, config) const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} await setupMediaStreams(call) @@ -310,8 +311,10 @@ const processCommand = (function () { }) if (pc.connectionState == "disconnected" || pc.connectionState == "failed") { pc.removeEventListener("connectionstatechange", connectionStateChange) + if (activeCall) { + setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) + } endCall() - setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } else if (pc.connectionState == "connected") { const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { @@ -326,7 +329,7 @@ const processCommand = (function () { remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId), }, } - setTimeout(() => sendMessageToNative({resp}), 0) + setTimeout(() => sendMessageToNative({resp}), 500) break } } @@ -442,17 +445,9 @@ const processCommand = (function () { case "camera": if (!activeCall || !pc) { resp = {type: "error", message: "camera: call not started"} - } else if (activeCall.localMedia == CallMediaType.Audio) { - resp = {type: "error", message: "camera: no video"} } else { - try { - if (command.camera != activeCall.localCamera) { - await replaceCamera(activeCall, command.camera) - } - resp = {type: "ok"} - } catch (e) { - resp = {type: "error", message: `camera: ${(e as Error).message}`} - } + await replaceMedia(activeCall, command.camera) + resp = {type: "ok"} } break case "end": @@ -464,7 +459,7 @@ const processCommand = (function () { break } } catch (e) { - resp = {type: "error", message: (e as Error).message} + resp = {type: "error", message: `${command.type}: ${(e as Error).message}`} } const apiResp = {corrId, resp, command} sendMessageToNative(apiResp) @@ -506,6 +501,9 @@ const processCommand = (function () { if (call.useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) + call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => + console.log(JSON.stringify({error, filename, lineno, message})) + call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } @@ -532,14 +530,19 @@ const processCommand = (function () { // Pull tracks from remote stream as they arrive add them to remoteStream video const pc = call.connection pc.ontrack = (event) => { - if (call.aesKey && call.key) { - console.log("set up decryption for receiving") - setupPeerTransform(TransformOperation.Decrypt, event.receiver as RTCRtpReceiverWithEncryption, call.worker, call.aesKey, call.key) - } - for (const stream of event.streams) { - for (const track of stream.getTracks()) { - call.remoteStream.addTrack(track) + try { + if (call.aesKey && call.key) { + console.log("set up decryption for receiving") + setupPeerTransform(TransformOperation.Decrypt, event.receiver as RTCRtpReceiverWithEncryption, call.worker, call.aesKey, call.key) } + for (const stream of event.streams) { + for (const track of stream.getTracks()) { + call.remoteStream.addTrack(track) + } + } + console.log(`ontrack success`) + } catch (e) { + console.log(`ontrack error: ${(e as Error).message}`) } } } @@ -573,7 +576,7 @@ const processCommand = (function () { } } - async function replaceCamera(call: Call, camera: VideoCamera): Promise { + async function replaceMedia(call: Call, camera: VideoCamera): Promise { const videos = getVideoElements() if (!videos) throw Error("no video elements") const pc = call.connection @@ -588,6 +591,7 @@ const processCommand = (function () { } function replaceTracks(pc: RTCPeerConnection, tracks: MediaStreamTrack[]) { + if (!tracks.length) return const sender = pc.getSenders().find((s) => s.track?.kind === tracks[0].kind) if (sender) for (const t of tracks) sender.replaceTrack(t) } @@ -713,8 +717,10 @@ function callCryptoFunction(): CallCrypto { 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 + const ciphertext = new Uint8Array( + plaintext.length ? await crypto.subtle.encrypt({name: "AES-GCM", iv: iv.buffer}, key, plaintext) : 0 + ) + frame.data = concatN(initial, ciphertext, iv).buffer controller.enqueue(frame) } catch (e) { console.log(`encryption error ${e}`) @@ -731,8 +737,8 @@ function callCryptoFunction(): CallCrypto { 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 + const plaintext = new Uint8Array(ciphertext.length ? await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ciphertext) : 0) + frame.data = concatN(initial, plaintext).buffer controller.enqueue(frame) } catch (e) { console.log(`decryption error ${e}`) @@ -864,9 +870,14 @@ function workerFunction() { // 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}) + try { + const {operation, aesKey} = transformer.options + const {readable, writable} = transformer + await setupTransform({operation, aesKey, readable, writable}) + self.postMessage({result: "setupTransform success"}) + } catch (e) { + self.postMessage({message: `setupTransform error: ${(e as Error).message}`}) + } }) } diff --git a/packages/simplex-chat-webrtc/src/style.css b/packages/simplex-chat-webrtc/src/style.css index a59f7c39af..3d2941c71e 100644 --- a/packages/simplex-chat-webrtc/src/style.css +++ b/packages/simplex-chat-webrtc/src/style.css @@ -1,12 +1,10 @@ -video::-webkit-media-controls { - display: none; -} html, body { padding: 0; margin: 0; background-color: black; } + #remote-video-stream { position: absolute; width: 100%; @@ -24,3 +22,20 @@ body { top: 0; right: 0; } + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b30ec9da7a..046a5abfb0 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -880,6 +880,7 @@ callStatusItemContent userId Contact {contactId} chatItemId receivedStatus = do (Just CISCallPending, WCSDisconnected) -> Just (CISCallMissed, 0) (Just CISCallEnded, _) -> Nothing -- if call already ended or failed -> no change (Just CISCallError, _) -> Nothing + (Just _, WCSConnecting) -> Just (CISCallNegotiated, 0) (Just _, WCSConnected) -> Just (CISCallProgress, 0) -- if call ended that was never connected, duration = 0 (Just _, WCSDisconnected) -> Just (CISCallEnded, 0) (Just _, WCSFailed) -> Just (CISCallError, 0) diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 1ad9ba65bc..c40351aef4 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -202,16 +202,18 @@ instance ToJSON WebRTCExtraInfo where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions -data WebRTCCallStatus = WCSConnected | WCSDisconnected | WCSFailed +data WebRTCCallStatus = WCSConnecting | WCSConnected | WCSDisconnected | WCSFailed deriving (Show) instance StrEncoding WebRTCCallStatus where strEncode = \case + WCSConnecting -> "connecting" WCSConnected -> "connected" WCSDisconnected -> "disconnected" WCSFailed -> "failed" strP = A.takeTill (== ' ') >>= \case + "connecting" -> pure WCSConnecting "connected" -> pure WCSConnected "disconnected" -> pure WCSDisconnected "failed" -> pure WCSFailed