// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import YYImage import Combine import CallKit import GRDB import WebRTC import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit import SessionUtilitiesKit import SessionSnodeKit public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { @objc static let isEnabled = true // MARK: - Metadata Properties public let uuid: String public let callId: UUID // This is for CallKit let sessionId: String let mode: CallMode var audioMode: AudioMode public let webRTCSession: WebRTCSession let isOutgoing: Bool var remoteSDP: RTCSessionDescription? = nil var callInteractionId: Int64? var answerCallAction: CXAnswerCallAction? = nil let contactName: String let profilePicture: UIImage let animatedProfilePicture: YYImage? // MARK: - Control lazy public var videoCapturer: RTCVideoCapturer = { return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource) }() var isRemoteVideoEnabled = false { didSet { remoteVideoStateDidChange?(isRemoteVideoEnabled) } } var isMuted = false { willSet { if newValue { webRTCSession.mute() } else { webRTCSession.unmute() } } } var isVideoEnabled = false { willSet { if newValue { webRTCSession.turnOnVideo() } else { webRTCSession.turnOffVideo() } } } // MARK: - Audio I/O mode enum AudioMode { case earpiece case speaker case headphone case bluetooth } // MARK: - Call State Properties var connectingDate: Date? { didSet { stateDidChange?() resetTimeoutTimerIfNeeded() hasStartedConnectingDidChange?() } } var connectedDate: Date? { didSet { stateDidChange?() hasConnectedDidChange?() } } var endDate: Date? { didSet { stateDidChange?() hasEndedDidChange?() } } // Not yet implemented var isOnHold = false { didSet { stateDidChange?() } } // MARK: - State Change Callbacks var stateDidChange: (() -> Void)? var hasStartedConnectingDidChange: (() -> Void)? var hasConnectedDidChange: (() -> Void)? var hasEndedDidChange: (() -> Void)? var remoteVideoStateDidChange: ((Bool) -> Void)? var hasStartedReconnecting: (() -> Void)? var hasReconnected: (() -> Void)? // MARK: - Derived Properties public var hasStartedConnecting: Bool { get { return connectingDate != nil } set { connectingDate = newValue ? Date() : nil } } public var hasConnected: Bool { get { return connectedDate != nil } set { connectedDate = newValue ? Date() : nil } } public var hasEnded: Bool { get { return endDate != nil } set { endDate = newValue ? Date() : nil } } var timeOutTimer: Timer? = nil var didTimeout = false var duration: TimeInterval { guard let connectedDate = connectedDate else { return 0 } if let endDate = endDate { return endDate.timeIntervalSince(connectedDate) } return Date().timeIntervalSince(connectedDate) } var reconnectTimer: Timer? = nil // MARK: - Initialization init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false) { self.sessionId = sessionId self.uuid = uuid self.callId = UUID() self.mode = mode self.audioMode = .earpiece self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid) self.isOutgoing = outgoing let avatarData: Data? = ProfileManager.profileAvatar(db, id: sessionId) self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact) self.profilePicture = avatarData .map { UIImage(data: $0) } .defaulting(to: PlaceholderIcon.generate(seed: sessionId, text: self.contactName, size: 300)) self.animatedProfilePicture = avatarData .map { data in switch data.guessedImageFormat { case .gif, .webp: return YYImage(data: data) default: return nil } } WebRTCSession.current = self.webRTCSession self.webRTCSession.delegate = self if AppEnvironment.shared.callManager.currentCall == nil { AppEnvironment.shared.callManager.currentCall = self } else { SNLog("[Calls] A call is ongoing.") } } func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) { guard case .answer = mode else { SessionCallManager.reportFakeCall(info: "Call not in answer mode") return } setupTimeoutTimer() AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in completion(error) } } public func didReceiveRemoteSDP(sdp: RTCSessionDescription) { guard Thread.isMainThread else { DispatchQueue.main.async { self.didReceiveRemoteSDP(sdp: sdp) } return } SNLog("[Calls] Did receive remote sdp.") remoteSDP = sdp if hasStartedConnecting { webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally } } // MARK: - Actions public func startSessionCall(_ db: Database) { let sessionId: String = self.sessionId let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing) guard case .offer = mode, let messageInfoData: Data = try? JSONEncoder().encode(messageInfo), let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { return } let webRTCSession: WebRTCSession = self.webRTCSession let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs() let message: CallMessage = CallMessage( uuid: self.uuid, kind: .preOffer, sdps: [], sentTimestampMs: UInt64(timestampMs) ) let interaction: Interaction? = try? Interaction( messageUuid: self.uuid, threadId: sessionId, authorId: getUserHexEncodedPublicKey(db), variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), timestampMs: timestampMs ) .inserted(db) self.callInteractionId = interaction?.id try? webRTCSession .sendPreOffer( db, message: message, interactionId: interaction?.id, in: thread ) // Start the timeout timer for the call .handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() }) .flatMap { _ in webRTCSession.sendOffer(to: thread) } .sinkUntilComplete() } func answerSessionCall() { guard case .answer = mode else { return } hasStartedConnecting = true if let sdp = remoteSDP { webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally } } func answerSessionCallInBackground(action: CXAnswerCallAction) { answerCallAction = action self.answerSessionCall() } func endSessionCall() { guard !hasEnded else { return } let sessionId: String = self.sessionId webRTCSession.hangUp() Storage.shared.writeAsync { [weak self] db in try self?.webRTCSession.endCall(db, with: sessionId) } hasEnded = true } // MARK: - Call Message Handling public func updateCallMessage(mode: EndCallMode) { guard let callInteractionId: Int64 = callInteractionId else { return } let duration: TimeInterval = self.duration let hasStartedConnecting: Bool = self.hasStartedConnecting Storage.shared.writeAsync( updates: { db in guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else { return } let updateToMissedIfNeeded: () throws -> () = { let missedCallInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed) guard let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( CallMessage.MessageInfo.self, from: infoMessageData ), messageInfo.state == .incoming, let missedCallInfoData: Data = try? JSONEncoder().encode(missedCallInfo) else { return } _ = try interaction .with(body: String(data: missedCallInfoData, encoding: .utf8)) .saved(db) } let shouldMarkAsRead: Bool = try { if duration > 0 { return true } if hasStartedConnecting { return true } switch mode { case .local: try updateToMissedIfNeeded() return true case .remote, .unanswered: try updateToMissedIfNeeded() return false case .answeredElsewhere: return true } }() guard shouldMarkAsRead, let threadVariant: SessionThread.Variant = try? SessionThread .filter(id: interaction.threadId) .select(.variant) .asRequest(of: SessionThread.Variant.self) .fetchOne(db) else { return } try Interaction.markAsRead( db, interactionId: interaction.id, threadId: interaction.threadId, threadVariant: threadVariant, includingOlder: false, trySendReadReceipt: false ) }, completion: { _, _ in SessionCallManager.suspendDatabaseIfCallEndedInBackground() } ) } // MARK: - Renderer func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) { webRTCSession.attachRemoteRenderer(renderer) } func removeRemoteVideoRenderer(_ renderer: RTCVideoRenderer) { webRTCSession.removeRemoteRenderer(renderer) } func attachLocalVideoRenderer(_ renderer: RTCVideoRenderer) { webRTCSession.attachLocalRenderer(renderer) } func removeLocalVideoRenderer(_ renderer: RTCVideoRenderer) { webRTCSession.removeLocalRenderer(renderer) } // MARK: - Delegate public func webRTCIsConnected() { self.invalidateTimeoutTimer() self.reconnectTimer?.invalidate() guard !self.hasConnected else { hasReconnected?() return } self.hasConnected = true self.answerCallAction?.fulfill() } public func isRemoteVideoDidChange(isEnabled: Bool) { isRemoteVideoEnabled = isEnabled } public func didReceiveHangUpSignal() { self.hasEnded = true DispatchQueue.main.async { if let currentBanner = IncomingCallBanner.current { currentBanner.dismiss() } if let callVC = CurrentAppContext().frontmostViewController() as? CallVC { callVC.handleEndCallMessage() } if let miniCallView = MiniCallView.current { miniCallView.dismiss() } AppEnvironment.shared.callManager.reportCurrentCallEnded(reason: .remoteEnded) } } public func dataChannelDidOpen() { // Send initial video status if (isVideoEnabled) { webRTCSession.turnOnVideo() } else { webRTCSession.turnOffVideo() } } public func reconnectIfNeeded() { setupTimeoutTimer() hasStartedReconnecting?() guard isOutgoing else { return } tryToReconnect() } private func tryToReconnect() { reconnectTimer?.invalidate() guard Environment.shared?.reachabilityManager.isReachable == true else { reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in self.tryToReconnect() } return } let sessionId: String = self.sessionId let webRTCSession: WebRTCSession = self.webRTCSession guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else { return } webRTCSession .sendOffer(to: thread, isRestartingICEConnection: true) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() } // MARK: - Timeout public func setupTimeoutTimer() { invalidateTimeoutTimer() let timeInterval: TimeInterval = 60 timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in self.didTimeout = true AppEnvironment.shared.callManager.endCall(self) { error in self.timeOutTimer = nil } } } public func resetTimeoutTimerIfNeeded() { if self.timeOutTimer == nil { return } setupTimeoutTimer() } public func invalidateTimeoutTimer() { timeOutTimer?.invalidate() timeOutTimer = nil } }