session-ios/Session/Calls/Call Management/SessionCall.swift

466 lines
14 KiB
Swift
Raw Normal View History

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import Combine
import CallKit
import GRDB
2021-10-28 08:02:41 +02:00
import WebRTC
import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
2021-10-28 08:02:41 +02:00
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
2021-12-14 03:57:25 +01:00
@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
2021-11-15 02:22:31 +01:00
var audioMode: AudioMode
public let webRTCSession: WebRTCSession
2021-11-09 06:05:23 +01:00
let isOutgoing: Bool
2021-11-03 05:31:50 +01:00
var remoteSDP: RTCSessionDescription? = nil
var callInteractionId: Int64?
var answerCallAction: CXAnswerCallAction? = nil
2021-10-28 08:02:41 +02:00
let contactName: String
let profilePicture: UIImage
let animatedProfilePicture: YYImage?
// MARK: - Control
2021-11-03 05:31:50 +01:00
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
2021-11-09 06:05:23 +01:00
2021-11-15 02:22:31 +01:00
enum AudioMode {
case earpiece
case speaker
case headphone
case bluetooth
}
// MARK: - Call State Properties
2021-10-28 08:02:41 +02:00
var connectingDate: Date? {
didSet {
stateDidChange?()
resetTimeoutTimerIfNeeded()
2021-10-28 08:02:41 +02:00
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
2021-10-28 08:02:41 +02:00
var stateDidChange: (() -> Void)?
var hasStartedConnectingDidChange: (() -> Void)?
var hasConnectedDidChange: (() -> Void)?
var hasEndedDidChange: (() -> Void)?
2021-11-03 05:31:50 +01:00
var remoteVideoStateDidChange: ((Bool) -> Void)?
var hasStartedReconnecting: (() -> Void)?
var hasReconnected: (() -> Void)?
2021-10-28 08:02:41 +02:00
// MARK: - Derived Properties
public var hasStartedConnecting: Bool {
2021-10-28 08:02:41 +02:00
get { return connectingDate != nil }
set { connectingDate = newValue ? Date() : nil }
}
public var hasConnected: Bool {
2021-10-28 08:02:41 +02:00
get { return connectedDate != nil }
set { connectedDate = newValue ? Date() : nil }
}
public var hasEnded: Bool {
2021-10-28 08:02:41 +02:00
get { return endDate != nil }
set { endDate = newValue ? Date() : nil }
}
2021-11-11 06:51:54 +01:00
2022-03-24 05:05:00 +01:00
var timeOutTimer: Timer? = nil
2021-11-11 06:51:54 +01:00
var didTimeout = false
2021-10-28 08:02:41 +02:00
var duration: TimeInterval {
guard let connectedDate = connectedDate else {
return 0
}
2021-11-09 06:05:23 +01:00
if let endDate = endDate {
return endDate.timeIntervalSince(connectedDate)
}
2021-10-28 08:02:41 +02:00
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()
2021-10-28 08:02:41 +02:00
self.mode = mode
2021-11-15 02:22:31 +01:00
self.audioMode = .earpiece
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
2021-11-09 06:05:23 +01:00
self.isOutgoing = outgoing
let avatarData: Data? = ProfileManager.profileAvatar(db, id: sessionId)
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
self.profilePicture = avatarData
Fixed a number of reported bugs, some cleanup, added animated profile support Added support for animated profile images (no ability to crop/resize) Updated the message trimming to only remove messages if the open group has 2000 messages or more Updated the message trimming setting to default to be on Updated the ContextMenu to fade out the snapshot as well (looked buggy if the device had even minor lag) Updated the ProfileManager to delete and re-download invalid avatar images (and updated the conversation screen to reload when avatars complete downloading) Updated the message request notification logic so it will show notifications when receiving a new message request as long as the user has read all the old ones (previously the user had to accept/reject all the old ones) Fixed a bug where the "trim open group messages" toggle was accessing UI off the main thread Fixed a bug where the "Chats" settings screen had a close button instead of a back button Fixed a bug where the 'viewsToMove' for the reply UI was inconsistent in some places Fixed an issue where the ProfileManager was doing all of it's validation (and writing to disk) within the database write closure which would block database writes unnecessarily Fixed a bug where a message request wouldn't be identified as such just because it wasn't visible in the conversations list Fixed a bug where opening a message request notification would result in the message request being in the wrong state (also wouldn't insert the 'MessageRequestsViewController' into the hierarchy) Fixed a bug where the avatar image wouldn't appear beside incoming closed group message in some situations cases Removed an error log that was essentially just spam Remove the logic to delete old profile images when calling save on a Profile (wouldn't get called if the row was modified directly and duplicates GarbageCollection logic) Remove the logic to send a notification when calling save on a Profile (wouldn't get called if the row was modified directly) Tweaked the message trimming description to be more accurate Cleaned up some duplicate logic used to determine if a notification should be shown Cleaned up some onion request logic (was passing the version info in some cases when not needed) Moved the push notification notify API call into the PushNotificationAPI class for consistency
2022-07-08 09:53:48 +02:00
.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
}
}
2021-11-08 05:09:45 +01:00
WebRTCSession.current = self.webRTCSession
2021-11-03 05:31:50 +01:00
self.webRTCSession.delegate = self
2021-11-09 01:53:38 +01:00
if AppEnvironment.shared.callManager.currentCall == nil {
AppEnvironment.shared.callManager.currentCall = self
}
else {
2021-11-09 01:53:38 +01:00
SNLog("[Calls] A call is ongoing.")
}
2021-10-28 08:02:41 +02:00
}
2021-11-03 05:31:50 +01:00
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
guard case .answer = mode else {
SessionCallManager.reportFakeCall(info: "Call not in answer mode")
return
}
setupTimeoutTimer()
2021-10-28 08:02:41 +02:00
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
2021-11-03 05:31:50 +01:00
completion(error)
}
}
public func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.didReceiveRemoteSDP(sdp: sdp)
}
return
}
2022-04-05 08:35:09 +02:00
SNLog("[Calls] Did receive remote sdp.")
2021-11-03 05:31:50 +01:00
remoteSDP = sdp
if hasStartedConnecting {
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
2021-10-28 08:02:41 +02:00
}
}
// 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()
2021-10-28 08:02:41 +02:00
}
2021-11-10 04:31:02 +01:00
func answerSessionCall() {
2021-11-03 05:31:50 +01:00
guard case .answer = mode else { return }
2021-10-28 08:02:41 +02:00
hasStartedConnecting = true
2021-11-03 05:31:50 +01:00
if let sdp = remoteSDP {
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
2021-11-03 05:31:50 +01:00
}
2021-10-28 08:02:41 +02:00
}
2021-11-10 04:31:02 +01:00
func answerSessionCallInBackground(action: CXAnswerCallAction) {
answerCallAction = action
self.answerSessionCall()
}
2021-10-28 08:02:41 +02:00
func endSessionCall() {
guard !hasEnded else { return }
let sessionId: String = self.sessionId
2021-11-10 04:31:02 +01:00
webRTCSession.hangUp()
Storage.shared.writeAsync { [weak self] db in
try self?.webRTCSession.endCall(db, with: sessionId)
2021-10-28 08:02:41 +02:00
}
2021-10-28 08:02:41 +02:00
hasEnded = true
}
2021-11-03 05:31:50 +01:00
// 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()
}
)
2021-11-09 06:05:23 +01:00
}
// MARK: - Renderer
2021-11-03 05:31:50 +01:00
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.attachRemoteRenderer(renderer)
}
2021-11-09 06:05:23 +01:00
func removeRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.removeRemoteRenderer(renderer)
}
2021-11-03 05:31:50 +01:00
func attachLocalVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.attachLocalRenderer(renderer)
}
func removeLocalVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.removeLocalRenderer(renderer)
}
// MARK: - Delegate
2021-11-03 05:31:50 +01:00
public func webRTCIsConnected() {
self.invalidateTimeoutTimer()
2022-03-25 06:29:52 +01:00
self.reconnectTimer?.invalidate()
guard !self.hasConnected else {
hasReconnected?()
return
}
2021-11-03 05:31:50 +01:00
self.hasConnected = true
self.answerCallAction?.fulfill()
2021-11-03 05:31:50 +01:00
}
public func isRemoteVideoDidChange(isEnabled: Bool) {
isRemoteVideoEnabled = isEnabled
}
2021-11-10 04:31:02 +01:00
public func didReceiveHangUpSignal() {
self.hasEnded = true
2021-11-10 04:31:02 +01:00
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)
}
}
2021-11-03 05:31:50 +01:00
public func dataChannelDidOpen() {
// Send initial video status
if (isVideoEnabled) {
webRTCSession.turnOnVideo()
} else {
webRTCSession.turnOffVideo()
}
}
2022-03-24 05:05:00 +01:00
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
2022-03-24 05:05:00 +01:00
public func setupTimeoutTimer() {
invalidateTimeoutTimer()
let timeInterval: TimeInterval = 60
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
2022-03-24 05:05:00 +01:00
self.didTimeout = true
2022-03-24 05:05:00 +01:00
AppEnvironment.shared.callManager.endCall(self) { error in
self.timeOutTimer = nil
}
}
}
public func resetTimeoutTimerIfNeeded() {
if self.timeOutTimer == nil { return }
setupTimeoutTimer()
}
2022-03-24 05:05:00 +01:00
public func invalidateTimeoutTimer() {
timeOutTimer?.invalidate()
timeOutTimer = nil
}
2021-10-28 08:02:41 +02:00
}