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/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift

360 lines
15 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import AVFAudio
import GRDB
import WebRTC
import SessionUtilitiesKit
import SessionSnodeKit
// MARK: - Log.Category
public extension Log.Category {
static let calls: Log.Category = .create("Calls", defaultLevel: .info)
}
// MARK: - MessageReceiver
extension MessageReceiver {
public static func handleCallMessage(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: CallMessage,
using dependencies: Dependencies
) throws {
// Only support calls from contact threads
guard threadVariant == .contact else { return }
switch message.kind {
case .preOffer: try MessageReceiver.handleNewCallMessage(db, message: message, using: dependencies)
case .offer: MessageReceiver.handleOfferCallMessage(db, message: message, using: dependencies)
case .answer: MessageReceiver.handleAnswerCallMessage(db, message: message, using: dependencies)
case .provisionalAnswer: break // TODO: [CALLS] Implement
case let .iceCandidates(sdpMLineIndexes, sdpMids):
dependencies[singleton: .callManager].handleICECandidates(
message: message,
sdpMLineIndexes: sdpMLineIndexes,
sdpMids: sdpMids
)
case .endCall: MessageReceiver.handleEndCallMessage(db, message: message, using: dependencies)
}
}
// MARK: - Specific Handling
private static func handleNewCallMessage(
_ db: Database,
message: CallMessage,
using dependencies: Dependencies
) throws {
Log.info(.calls, "Received pre-offer message with uuid: \(message.uuid).")
// Determine whether the app is active based on the prefs rather than the UIApplication state to avoid
// requiring main-thread execution
let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive]
// It is enough just ignoring the pre offers, other call messages
// for this call would be dropped because of no Session call instance
guard
dependencies[singleton: .appContext].isMainApp,
let sender: String = message.sender,
(try? Contact
.filter(id: sender)
.select(.isApproved)
.asRequest(of: Bool.self)
.fetchOne(db))
.defaulting(to: false)
else { return }
guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else {
// Add missed call message for call offer messages from more than one minute
Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)")
if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed, using: dependencies) {
let thread: SessionThread = try SessionThread.upsert(
db,
id: sender,
variant: .contact,
values: .existingOrDefault,
using: dependencies
)
if !interaction.wasRead {
dependencies[singleton: .notificationsManager].notifyUser(
db,
forIncomingCall: interaction,
in: thread,
applicationState: (isMainAppActive ? .active : .background)
)
}
}
return
}
guard db[.areCallsEnabled] && Permissions.microphone == .granted else {
let state: CallMessage.MessageInfo.State = (db[.areCallsEnabled] ? .permissionDeniedMicrophone : .permissionDenied)
Log.info(.calls, "Microphone permission is \(AVAudioSession.sharedInstance().recordPermission)")
if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: state, using: dependencies) {
let thread: SessionThread = try SessionThread.upsert(
db,
id: sender,
variant: .contact,
values: .existingOrDefault,
using: dependencies
)
if !interaction.wasRead {
dependencies[singleton: .notificationsManager].notifyUser(
db,
forIncomingCall: interaction,
in: thread,
applicationState: (isMainAppActive ? .active : .background)
)
}
// Trigger the missed call UI if needed
NotificationCenter.default.post(
name: .missedCall,
object: nil,
userInfo: [ Notification.Key.senderId.rawValue: sender ]
)
}
return
}
// Ignore pre offer message after the same call instance has been generated
if let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall, currentCall.uuid == message.uuid {
Log.info(.calls, "Ignoring pre-offer message for call[\(currentCall.uuid)] instance because it is already active.")
return
}
guard dependencies[singleton: .callManager].currentCall == nil else {
try MessageReceiver.handleIncomingCallOfferInBusyState(db, message: message, using: dependencies)
return
}
let interaction: Interaction? = try MessageReceiver.insertCallInfoMessage(db, for: message, using: dependencies)
// Handle UI
dependencies[singleton: .callManager].showCallUIForCall(
caller: sender,
uuid: message.uuid,
mode: .answer,
interactionId: interaction?.id
)
}
private static func handleOfferCallMessage(_ db: Database, message: CallMessage, using dependencies: Dependencies) {
Log.info(.calls, "Received offer message.")
// Ensure we have a call manager before continuing
guard
let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall,
currentCall.uuid == message.uuid,
let sdp: String = message.sdps.first
else { return }
let sdpDescription: RTCSessionDescription = RTCSessionDescription(type: .offer, sdp: sdp)
currentCall.didReceiveRemoteSDP(sdp: sdpDescription)
}
private static func handleAnswerCallMessage(
_ db: Database,
message: CallMessage,
using dependencies: Dependencies
) {
Log.info(.calls, "Received answer message.")
guard
dependencies[singleton: .callManager].currentWebRTCSessionMatches(callId: message.uuid),
var currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall,
currentCall.uuid == message.uuid,
let sender: String = message.sender
else { return }
guard sender != dependencies[cache: .general].sessionId.hexString else {
guard currentCall.mode == .answer && !currentCall.hasStartedConnecting else { return }
dependencies[singleton: .callManager].dismissAllCallUI()
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .answeredElsewhere)
return
}
guard let sdp: String = message.sdps.first else { return }
let sdpDescription: RTCSessionDescription = RTCSessionDescription(type: .answer, sdp: sdp)
currentCall.hasStartedConnecting = true
currentCall.didReceiveRemoteSDP(sdp: sdpDescription)
dependencies[singleton: .callManager].handleAnswerMessage(message)
}
private static func handleEndCallMessage(
_ db: Database,
message: CallMessage,
using dependencies: Dependencies
) {
Log.info(.calls, "Received end call message.")
guard
dependencies[singleton: .callManager].currentWebRTCSessionMatches(callId: message.uuid),
let currentCall: CurrentCallProtocol = dependencies[singleton: .callManager].currentCall,
currentCall.uuid == message.uuid,
!currentCall.hasEnded,
let sender: String = message.sender
else { return }
dependencies[singleton: .callManager].dismissAllCallUI()
dependencies[singleton: .callManager].reportCurrentCallEnded(
reason: (sender == dependencies[cache: .general].sessionId.hexString ?
.declinedElsewhere :
.remoteEnded
)
)
}
// MARK: - Convenience
public static func handleIncomingCallOfferInBusyState(
_ db: Database,
message: CallMessage,
using dependencies: Dependencies
) throws {
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed)
guard
let caller: String = message.sender,
let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo),
!SessionThread.isMessageRequest(
db,
threadId: caller,
userSessionId: dependencies[cache: .general].sessionId
),
let thread: SessionThread = try SessionThread.fetchOne(db, id: caller)
else { return }
Log.info(.calls, "Sending end call message because there is an ongoing call.")
let messageSentTimestampMs: Int64 = (
message.sentTimestampMs.map { Int64($0) } ??
dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
)
_ = try Interaction(
serverHash: message.serverHash,
messageUuid: message.uuid,
threadId: thread.id,
threadVariant: thread.variant,
authorId: caller,
variant: .infoCall,
body: String(data: messageInfoData, encoding: .utf8),
timestampMs: messageSentTimestampMs,
wasRead: dependencies.mutate(cache: .libSession) { cache in
cache.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: (messageSentTimestampMs * 1000),
userSessionId: dependencies[cache: .general].sessionId,
openGroup: nil
)
},
expiresInSeconds: message.expiresInSeconds,
expiresStartedAtMs: message.expiresStartedAtMs,
using: dependencies
)
.inserted(db)
try MessageSender
.preparedSend(
db,
message: CallMessage(
uuid: message.uuid,
kind: .endCall,
sdps: [],
sentTimestampMs: nil // Explicitly nil as it's a separate message from above
)
.with(try? thread.disappearingMessagesConfiguration
.fetchOne(db)?
.forcedWithDisappearAfterReadIfNeeded()
),
to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant),
namespace: try Message.Destination
.from(db, threadId: thread.id, threadVariant: thread.variant)
.defaultNamespace,
interactionId: nil, // Explicitly nil as it's a separate message from above
fileIds: [],
using: dependencies
)
.send(using: dependencies)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete()
}
@discardableResult public static func insertCallInfoMessage(
_ db: Database,
for message: CallMessage,
state: CallMessage.MessageInfo.State? = nil,
using dependencies: Dependencies
) throws -> Interaction? {
guard (
try? Interaction
.filter(Interaction.Columns.variant == Interaction.Variant.infoCall)
.filter(Interaction.Columns.messageUuid == message.uuid)
.isEmpty(db)
).defaulting(to: false)
else { throw MessageReceiverError.duplicatedCall }
guard
let sender: String = message.sender,
!SessionThread.isMessageRequest(
db,
threadId: sender,
userSessionId: dependencies[cache: .general].sessionId
),
let thread: SessionThread = try SessionThread.fetchOne(db, id: sender)
else { return nil }
let userSessionId: SessionId = dependencies[cache: .general].sessionId
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(
state: state.defaulting(
to: (sender == userSessionId.hexString ?
.outgoing :
.incoming
)
)
)
let timestampMs: Int64 = (
message.sentTimestampMs.map { Int64($0) } ??
dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
)
guard let messageInfoData: Data = try? JSONEncoder(using: dependencies).encode(messageInfo) else {
return nil
}
return try Interaction(
serverHash: message.serverHash,
messageUuid: message.uuid,
threadId: thread.id,
threadVariant: thread.variant,
authorId: sender,
variant: .infoCall,
body: String(data: messageInfoData, encoding: .utf8),
timestampMs: timestampMs,
wasRead: dependencies.mutate(cache: .libSession) { cache in
cache.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: (timestampMs * 1000),
userSessionId: userSessionId,
openGroup: nil
)
},
expiresInSeconds: message.expiresInSeconds,
expiresStartedAtMs: message.expiresStartedAtMs,
using: dependencies
)
.inserted(db)
}
}