SimpleX-Chat/apps/ios/Shared/Views/Call/ActiveCallView.swift
sh 1eb1e52912
call.ts: include udp stun/turn (#1892)
* call.ts: include udp stun/turn

* update JS

* show protocol, support TURNS

* mobile: add turn via UDP, remove protocol from view

* remove enums for protocol strings in ICE candidates

* 0.2.3

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-02-05 21:57:50 +00:00

324 lines
12 KiB
Swift

//
// ActiveCallView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 05/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import WebKit
import SimpleXChat
struct ActiveCallView: View {
@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(rtcWebView: $rtcWebView, webViewMsg: $webViewMsg)
.onAppear() { sendCommandToWebView() }
.onChange(of: m.callCommand) { _ in sendCommandToWebView() }
.onChange(of: rtcWebView) { _ in sendCommandToWebView() }
.onChange(of: webViewMsg) { _ in processWebViewMessage() }
.background(.black)
if let call = m.activeCall, let webView = rtcWebView {
ActiveCallOverlay(call: call, webView: webView)
}
}
.preferredColorScheme(.dark)
}
private func sendCommandToWebView() {
if m.activeCall != nil,
let wv = rtcWebView,
let cmd = m.callCommand {
m.callCommand = nil
sendCallCommand(wv, cmd)
}
}
private func processWebViewMessage() {
if let msg = webViewMsg,
let call = m.activeCall,
let webView = rtcWebView {
logger.debug("ActiveCallView: response \(msg.resp.respType)")
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))")
}
DispatchQueue.main.async {
call.callState = .invitationSent
call.localCapabilities = capabilities
}
}
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
@ObservedObject var call: Call
var webView: WKWebView
var body: some View {
VStack {
switch call.localMedia {
case .video:
callInfoView(call, .leading)
.foregroundColor(.white)
.opacity(0.8)
.padding()
Spacer()
HStack {
toggleAudioButton()
Spacer()
Color.clear.frame(width: 40, height: 40)
Spacer()
endCallButton()
Spacer()
if call.videoEnabled {
flipCameraButton()
} else {
Color.clear.frame(width: 40, height: 40)
}
Spacer()
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)
}
private func callInfoView(_ call: Call, _ alignment: Alignment) -> some View {
VStack {
Text(call.contact.chatViewName)
.lineLimit(1)
.font(.title)
.frame(maxWidth: .infinity, alignment: alignment)
Group {
Text(call.callState.text)
HStack {
Text(call.encryptionStatus)
if let connInfo = call.connectionInfo {
// Text("(") + Text(connInfo.text) + Text(", \(connInfo.protocolText))")
Text("(") + Text(connInfo.text) + Text(")")
}
}
}
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: alignment)
}
}
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)
.foregroundColor(.white)
.opacity(0.85)
} else {
Color.clear.frame(width: 40, height: 40)
}
}
private func callButton(_ imageName: String, size: CGFloat, _ perform: @escaping () -> Void) -> some View {
Button {
perform()
} label: {
Image(systemName: imageName)
.resizable()
.scaledToFit()
.frame(maxWidth: size, maxHeight: size)
}
}
}
struct ActiveCallOverlay_Previews: PreviewProvider {
static var previews: some View {
Group{
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .video), webView: WKWebView())
.background(.black)
ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callkitUUID: UUID(), callState: .offerSent, localMedia: .audio), webView: WKWebView())
.background(.black)
}
}
}