Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Calls/Individual/CallOfferHandler.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
public import LibSignalClient
import SignalRingRTC

public class CallOfferHandlerImpl {
    private let identityManager: any OWSIdentityManager
    private let notificationPresenter: NotificationPresenter
    private let profileManager: any ProfileManager
    private let tsAccountManager: any TSAccountManager

    public init(
        identityManager: any OWSIdentityManager,
        notificationPresenter: NotificationPresenter,
        profileManager: any ProfileManager,
        tsAccountManager: any TSAccountManager,
    ) {
        self.identityManager = identityManager
        self.notificationPresenter = notificationPresenter
        self.profileManager = profileManager
        self.tsAccountManager = tsAccountManager
    }

    public struct PartialResult {
        public let identityKeys: CallIdentityKeys
        public let offerMediaType: TSRecentCallOfferType
        public let thread: TSContactThread
        public let localDeviceId: DeviceId
    }

    public func insertMissedCallInteraction(
        for callId: UInt64,
        in thread: TSContactThread,
        outcome: RPRecentCallType,
        callType: TSRecentCallOfferType,
        sentAtTimestamp: UInt64,
        tx: DBWriteTransaction,
    ) {
        let callEventInserter = CallEventInserter(
            thread: thread,
            callId: callId,
            offerMediaType: callType,
            sentAtTimestamp: sentAtTimestamp,
        )
        callEventInserter.createOrUpdate(callType: outcome, tx: tx)
    }

    public func startHandlingOffer(
        caller: Aci,
        sourceDevice: DeviceId,
        localIdentity: OWSIdentity,
        callId: UInt64,
        callType: SSKProtoCallMessageOfferType,
        sentAtTimestamp: UInt64,
        tx: DBWriteTransaction,
    ) -> PartialResult? {
        let thread = TSContactThread.getOrCreateThread(
            withContactAddress: SignalServiceAddress(caller),
            transaction: tx,
        )

        let offerMediaType: TSRecentCallOfferType
        switch callType {
        case .offerAudioCall:
            offerMediaType = .audio
        case .offerVideoCall:
            offerMediaType = .video
        }

        func insertMissedCallInteraction(outcome: RPRecentCallType, tx: DBWriteTransaction) {
            return self.insertMissedCallInteraction(
                for: callId,
                in: thread,
                outcome: outcome,
                callType: offerMediaType,
                sentAtTimestamp: sentAtTimestamp,
                tx: tx,
            )
        }

        guard
            tsAccountManager.registrationState(tx: tx).isRegistered,
            let localDeviceId = tsAccountManager.storedDeviceId(tx: tx).ifValid
        else {
            Logger.warn("user is not registered, skipping call.")
            insertMissedCallInteraction(outcome: .incomingMissed, tx: tx)
            return nil
        }

        let untrustedIdentity = identityManager.untrustedIdentityForSending(
            to: SignalServiceAddress(caller),
            untrustedThreshold: nil,
            tx: tx,
        )
        if let untrustedIdentity {
            Logger.warn("missed a call due to untrusted identity")

            let notificationInfo = CallNotificationInfo(
                groupingId: UUID(),
                thread: thread,
                caller: caller,
            )

            switch untrustedIdentity.verificationState {
            case .verified, .defaultAcknowledged:
                owsFailDebug("shouldn't have missed a call due to untrusted identity if the identity is verified")
                let sentAtTimestamp = Date(millisecondsSince1970: sentAtTimestamp)
                self.notificationPresenter.notifyUserOfMissedCall(
                    notificationInfo: notificationInfo,
                    offerMediaType: offerMediaType,
                    sentAt: sentAtTimestamp,
                    tx: tx,
                )
            case .default:
                self.notificationPresenter.notifyUserOfMissedCallBecauseOfNewIdentity(
                    notificationInfo: notificationInfo,
                    tx: tx,
                )
            case .noLongerVerified:
                self.notificationPresenter.notifyUserOfMissedCallBecauseOfNoLongerVerifiedIdentity(
                    notificationInfo: notificationInfo,
                    tx: tx,
                )
            }

            insertMissedCallInteraction(outcome: .incomingMissedBecauseOfChangedIdentity, tx: tx)
            return nil
        }

        guard let identityKeys = identityManager.getCallIdentityKeys(remoteAci: caller, tx: tx) else {
            Logger.warn("missing identity keys, skipping call.")
            insertMissedCallInteraction(outcome: .incomingMissed, tx: tx)
            return nil
        }

        guard allowsInboundCalls(from: caller, tx: tx) else {
            Logger.info("Ignoring call offer from \(caller) due to insufficient permissions.")

            // Send the need permission message to the caller, so they know why we rejected their call.
            switch localIdentity {
            case .aci:
                _ = CallHangupSender.sendHangup(
                    thread: thread,
                    callId: callId,
                    hangupType: .hangupNeedPermission,
                    localDeviceId: localDeviceId.uint32Value,
                    remoteDeviceId: sourceDevice.uint32Value,
                    tx: tx,
                )
            case .pni:
                // Don't respond if they sent the offer to our PNI.
                break
            }

            // Store the call as a missed call for the local user. They will see it in the conversation
            // along with the message request dialog. When they accept the dialog, they can call back
            // or the caller can try again.
            insertMissedCallInteraction(outcome: .incomingMissed, tx: tx)
            return nil
        }

        return PartialResult(
            identityKeys: identityKeys,
            offerMediaType: offerMediaType,
            thread: thread,
            localDeviceId: localDeviceId,
        )
    }

    private func allowsInboundCalls(from caller: Aci, tx: DBReadTransaction) -> Bool {
        // If the thread is in our whitelist, then we've either trusted it manually
        // or it's a chat with someone in our system contacts.
        return profileManager.isUser(inProfileWhitelist: SignalServiceAddress(caller), transaction: tx)
    }
}

public struct CallIdentityKeys {
    public let localIdentityKey: IdentityKey
    public let contactIdentityKey: IdentityKey
}

extension OWSIdentityManager {
    public func getCallIdentityKeys(
        remoteAci: Aci,
        tx: DBReadTransaction,
    ) -> CallIdentityKeys? {
        guard let localIdentityKey = identityKeyPair(for: .aci, tx: tx)?.keyPair.identityKey else {
            owsFailDebug("missing localIdentityKey")
            return nil
        }
        guard let contactIdentityKey = try? identityKey(for: remoteAci, tx: tx) else {
            owsFailDebug("missing contactIdentityKey")
            return nil
        }
        return CallIdentityKeys(localIdentityKey: localIdentityKey, contactIdentityKey: contactIdentityKey)
    }
}

public enum CallHangupSender {
    /// - parameter localDeviceId: The localDeviceId or 0 if it's not relevant.
    /// - parameter remoteDeviceId: The remoteDeviceId or nil if it's not relevant.
    public static func sendHangup(
        thread: TSContactThread,
        callId: UInt64,
        hangupType: SSKProtoCallMessageHangupType,
        localDeviceId: UInt32,
        remoteDeviceId: UInt32?,
        tx: DBWriteTransaction,
    ) -> Promise<Void> {
        let hangupBuilder = SSKProtoCallMessageHangup.builder(id: callId)

        hangupBuilder.setType(hangupType)

        if hangupType != .hangupNormal {
            // deviceId is optional and only used when indicated by a hangup due to
            // a call being accepted elsewhere.
            hangupBuilder.setDeviceID(localDeviceId)
        }

        let hangupMessage: SSKProtoCallMessageHangup
        do {
            hangupMessage = try hangupBuilder.build()
        } catch {
            owsFailDebug("Couldn't build hangup message.")
            return Promise(error: error)
        }

        let callMessage = OutgoingCallMessage(
            thread: thread,
            messageType: .hangupMessage(hangupMessage),
            destinationDeviceId: remoteDeviceId,
            tx: tx,
        )
        let preparedMessage = PreparedOutgoingMessage.preprepared(
            transientMessageWithoutAttachments: callMessage,
        )
        return ThreadUtil.enqueueMessagePromise(
            message: preparedMessage,
            limitToCurrentProcessLifetime: true,
            isHighPriority: true,
            transaction: tx,
        )
    }
}