You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/Session/Calls/Call Management/SessionCall.swift

474 lines
15 KiB
Swift

// 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 disappearingMessagesConfiguration = try? thread.disappearingMessagesConfiguration.fetchOne(db)?.forcedWithDisappearAfterReadIfNeeded()
let message: CallMessage = CallMessage(
uuid: self.uuid,
kind: .preOffer,
sdps: [],
sentTimestampMs: UInt64(timestampMs)
)
.with(disappearingMessagesConfiguration)
let interaction: Interaction? = try? Interaction(
messageUuid: self.uuid,
threadId: sessionId,
authorId: getUserHexEncodedPublicKey(db),
variant: .infoCall,
body: String(data: messageInfoData, encoding: .utf8),
timestampMs: timestampMs,
expiresInSeconds: message.expiresInSeconds,
expiresStartedAtMs: message.expiresStartedAtMs
)
.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() }
guard Singleton.hasAppContext else { return }
if let callVC = Singleton.appContext.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
}
}