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

import Foundation
import SignalRingRTC
import SignalServiceKit
import SignalUI

/// Ensures mutually exclusive access for call link fetches/updates.
///
/// Both "read" and "update" operations may race with one another. (For
/// example, a "read" and "update" executed in parallel may complete in
/// either order, and the "read" may or may not see the newly-updated
/// state.) This type ensures "old" reads don't clobber "new" updates.
actor CallLinkStateUpdater {
    private let authCredentialManager: any AuthCredentialManager
    private let callLinkFetcher: CallLinkFetcherImpl
    private let callLinkManager: any CallLinkManager
    private let callLinkStore: any CallLinkRecordStore
    private let callRecordDeleteManager: any CallRecordDeleteManager
    private let callRecordStore: any CallRecordStore
    private let db: any DB
    private let tsAccountManager: any TSAccountManager

    private var pendingUpdates: [Data: [CheckedContinuation<Void, Never>]]

    init(
        authCredentialManager: any AuthCredentialManager,
        callLinkFetcher: CallLinkFetcherImpl,
        callLinkManager: any CallLinkManager,
        callLinkStore: any CallLinkRecordStore,
        callRecordDeleteManager: any CallRecordDeleteManager,
        callRecordStore: any CallRecordStore,
        db: any DB,
        tsAccountManager: any TSAccountManager,
    ) {
        self.authCredentialManager = authCredentialManager
        self.callLinkFetcher = callLinkFetcher
        self.callLinkManager = callLinkManager
        self.callLinkStore = callLinkStore
        self.callRecordDeleteManager = callRecordDeleteManager
        self.callRecordStore = callRecordStore
        self.db = db
        self.tsAccountManager = tsAccountManager

        self.pendingUpdates = [:]
    }

    /// Runs `updateAndFetch` and persists the returned value.
    ///
    /// Note: The returned value is only persisted if the call link already
    /// exists. This ensures we don't create orphaned call link records that the
    /// user never joins.
    func updateExclusively(
        rootKey: CallLinkRootKey,
        updateAndFetch: (CallLinkAuthCredential) async throws -> SignalServiceKit.CallLinkState,
    ) async throws -> SignalServiceKit.CallLinkState {
        return try await _updateExclusively(rootKey: rootKey, updateAndFetch: updateAndFetch)!.get()
    }

    private enum UpdateAction {
        case update(SignalServiceKit.CallLinkState)
        case notFound
        case delete
    }

    private func _updateExclusively(
        rootKey: CallLinkRootKey,
        updateAndFetch: (CallLinkAuthCredential) async throws -> SignalServiceKit.CallLinkState?,
    ) async throws -> Result<SignalServiceKit.CallLinkState, CallLinkNotFoundError>? {
        let roomId = rootKey.deriveRoomId()

        await withCheckedContinuation { continuation in
            if pendingUpdates[roomId] == nil {
                pendingUpdates[roomId] = []
                continuation.resume()
            } else {
                pendingUpdates[roomId]!.append(continuation)
            }
        }
        defer {
            if let nextUpdate = pendingUpdates[roomId]!.first {
                pendingUpdates[roomId] = Array(pendingUpdates[roomId]!.dropFirst())
                nextUpdate.resume()
            } else {
                pendingUpdates[roomId] = nil
            }
        }

        let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
        let oldRecord = try db.read { tx -> CallLinkRecord? in
            return try callLinkStore.fetch(roomId: roomId, tx: tx)
        }
        let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: registeredState.localIdentifiers)
        let updateResult = await Result { try await updateAndFetch(authCredential) }

        let updateAction: UpdateAction
        let returnResult: Result<SignalServiceKit.CallLinkState, CallLinkNotFoundError>?

        switch updateResult {
        case .success(let callLinkState?):
            updateAction = .update(callLinkState)
            returnResult = .success(callLinkState)
        case .success(nil):
            updateAction = .delete
            returnResult = nil
        case .failure(let error as CallLinkNotFoundError):
            updateAction = .notFound
            returnResult = .failure(error)
        case .failure(let error):
            throw error
        }

        try await db.awaitableWrite { tx in
            if var newRecord = try self.callLinkStore.fetch(roomId: roomId, tx: tx) {
                if !newRecord.isDeleted {
                    switch updateAction {
                    case .update(let newState):
                        newRecord.updateState(newState)
                    case .notFound:
                        break
                    case .delete:
                        newRecord.markDeleted(atTimestampMs: Date.ows_millisecondTimestamp())
                        try self.callRecordDeleteManager.deleteCallRecords(
                            self.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: newRecord.id), limit: nil, tx: tx),
                            sendSyncMessageOnDelete: true,
                            tx: tx,
                        )
                    }
                }
                if newRecord.pendingFetchCounter == oldRecord?.pendingFetchCounter {
                    newRecord.clearNeedsFetch()
                }
                try self.callLinkStore.update(newRecord, tx: tx)
            }
        }

        return returnResult
    }

    /// Reads a call link from the server.
    ///
    /// There are two layers of errors interesting to callers: the method itself
    /// and the `Result` that's returned.
    ///
    /// This is a "state updater" object, so if the "state update" operation is
    /// successful, no error is thrown. The "state update" is successful when
    /// we're able to call `clearNeedsFetch` on the underlying CallLinkRecord.
    /// (For example, no error is thrown when the call link can't be found, but
    /// an error *is* thrown when there's no network.)
    ///
    /// Many callers will want access to the `CallLinkState`, and they can use
    /// `try readCallLink(...).get()` to gloss over this distinction.
    func readCallLink(rootKey: CallLinkRootKey) async throws -> Result<SignalServiceKit.CallLinkState, CallLinkNotFoundError> {
        return try await _updateExclusively(rootKey: rootKey, updateAndFetch: { authCredential in
            return try await callLinkFetcher.readCallLink(rootKey, authCredential: authCredential)
        })!
    }

    func deleteCallLink(rootKey: CallLinkRootKey, adminPasskey: Data) async throws {
        _ = try await _updateExclusively(
            rootKey: rootKey,
            updateAndFetch: { authCredential in
                try await callLinkManager.deleteCallLink(rootKey: rootKey, adminPasskey: adminPasskey, authCredential: authCredential)
                return nil
            },
        )
    }
}