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

import GRDB
import LibSignalClient

public final class BackupArchiveInteractionStore {

    private let interactionStore: InteractionStore

    init(interactionStore: InteractionStore) {
        self.interactionStore = interactionStore
    }

    // MARK: Per type inserts

    func insert(
        _ interaction: TSIncomingMessage,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        senderAci: Aci?,
        wasRead: Bool,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        try insert(
            interaction: interaction,
            in: thread,
            chatId: chatId,
            senderAci: senderAci,
            wasRead: wasRead,
            context: context,
        )
    }

    func insert(
        _ interaction: TSOutgoingMessage,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        try insert(
            interaction: interaction,
            in: thread,
            chatId: chatId,
            // Outgoing messages are sent by local aci
            senderAci: context.recipientContext.localIdentifiers.aci,
            // Outgoing messages are implicitly read.
            wasRead: true,
            context: context,
        )
    }

    func insert(
        _ interaction: TSInfoMessage,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        // Info messages are always "directionless", and consequently their
        // "read" is not backed up. Treat them as read.
        let wasRead = true
        interaction.wasRead = wasRead

        try insert(
            interaction: interaction,
            in: thread,
            chatId: chatId,
            // No sender for info messages
            senderAci: nil,
            wasRead: wasRead,
            context: context,
        )
    }

    func insert(
        _ interaction: TSErrorMessage,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        // Error messages are always "directionless", and consequently their
        // "read" state is not backed up. Treat them as read.
        let wasRead = true
        interaction.wasRead = wasRead

        try insert(
            interaction: interaction,
            in: thread,
            chatId: chatId,
            // No sender for error messages
            senderAci: nil,
            wasRead: wasRead,
            context: context,
        )
    }

    /// Caller aci can be nil for legacy calls made by e164 accounts before
    /// the introduction of acis.
    func insert(
        _ interaction: TSCall,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        callerAci: Aci?,
        wasRead: Bool,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        try insert(
            interaction: interaction,
            in: thread,
            chatId: chatId,
            senderAci: callerAci,
            wasRead: wasRead,
            context: context,
        )
    }

    /// StartedCallAci can be nil if who started the call is unknown.
    func insert(
        _ interaction: OWSGroupCallMessage,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        startedCallAci: Aci?,
        wasRead: Bool,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        try insert(
            interaction: interaction,
            in: thread,
            chatId: chatId,
            senderAci: startedCallAci,
            wasRead: wasRead,
            context: context,
        )
    }

    // MARK: - Insert

    private func insert(
        interaction: TSInteraction,
        in thread: BackupArchive.ChatThread,
        chatId: BackupArchive.ChatId,
        senderAci: Aci?,
        wasRead: Bool,
        context: BackupArchive.ChatItemRestoringContext,
    ) throws {
        guard interaction.shouldBeSaved else {
            owsFailDebug("Unsaveable interaction in a backup?")
            return
        }
        if let message = interaction as? TSOutgoingMessage {
            message.updateStoredMessageState()
        }

        let shouldAppearInInbox = interaction.shouldAppearInInbox(
            groupUpdateItemsBuilder: { infoMessage in
                // In a backups context, _all_ info message group updates are precomputed.
                // We can assume this in this builder override.
                switch infoMessage.groupUpdateMetadata(
                    localIdentifiers: context.recipientContext.localIdentifiers,
                ) {
                case .precomputed(let wrapper):
                    return wrapper.updateItems
                default:
                    return nil
                }
            },
        )

        // Note: We do not insert restored messages into the MessageSendLog.
        // This means if we get a retry request for a message we sent pre-backup
        // and restore, we'll only send back a Null message. (Until such a day
        // when resends use the interactions table and not MSL at all).

        try insertInteractionWithDirectSQLiteCalls(interaction, database: context.tx.database)
        interaction.updateRowId(context.tx.database.lastInsertedRowID)

        guard let interactionRowId = interaction.sqliteRowId else {
            throw OWSAssertionError("Missing row id after insertion!")
        }

        if shouldAppearInInbox {
            context.chatContext.updateLastVisibleInteractionRowId(
                interactionRowId: interactionRowId,
                wasRead: wasRead,
                chatId: chatId,
            )
        }

        // If we are in a group and the sender has an aci,
        // track the sent timestamp. Note we may not have
        // a sender for e.g. group update messages, along with
        // other cases of lost/missing/legacy information.
        // This is best-effort rather than guaranteed.
        if let senderAci {
            switch thread.threadType {
            case .contact:
                break
            case .groupV2(let groupThread):
                context.chatContext.updateGroupMemberLastInteractionTimestamp(
                    groupThread: groupThread,
                    chatId: chatId,
                    senderAci: senderAci,
                    timestamp: interaction.timestamp,
                )
            }
        }
    }

    /// Reuse the same SQL string, since generating the same string for each
    /// message gets expensive.
    private lazy var insertInteractionSQL: String = {
        let columnsSQL = InteractionRecord.CodingKeys.allCases.filter({ $0 != .id }).map(\.name).joined(separator: ", ")
        let valuesSQL = InteractionRecord.CodingKeys.allCases.filter({ $0 != .id }).map({ _ in "?" }).joined(separator: ", ")
        return """
        INSERT INTO \(InteractionRecord.databaseTableName) (\(columnsSQL)) \
        VALUES (\(valuesSQL))
        """
    }()

    /// Inserts the given interaction using direct `sqlite3_*` function calls,
    /// sidestepping GRDB abstractions.
    ///
    /// Profiling showed that the GRDB methods for creating `Statement` and
    /// `StatementArgument` structs for insertion were taking a shocking amount
    /// of time, primarily due to their use of Swift methods like `zip` that
    /// instantiate opaque iterator structs. To avoid that cost, this method
    /// sidesteps the GRDB abstractions and makes direct `sqlite3_*` method
    /// calls instead.
    ///
    /// In normal app functioning, the cost of those abstractions probably isn't
    /// worth managing these `sqlite3_*` methods ourselves. However, the savings
    /// over hundreds of thousands of interaction inserts during a restore are.
    private func insertInteractionWithDirectSQLiteCalls(
        _ interaction: TSInteraction,
        database: GRDB.Database,
    ) throws {
        guard let sqliteConnection = database.sqliteConnection else {
            throw OWSAssertionError("Missing SQLite connection!")
        }

        /// SQLite compiles SQL strings into its internal bytecode. The bytecode
        /// statements can be cached by SQLite, to avoid re-compiling an
        /// identical SQL string repeatedly.
        ///
        /// This line uses GRDB to make the necessary SQLite calls to compile
        /// and cache our "insert interaction" statement, which is returned as a
        /// pointer we can pass back into SQLite,, since those calls involve
        /// tricky pointer math. GRDB then holds a reference to that compiled
        /// statement pointer in a package-level cache, from which we can
        /// retrieve it.
        let cachedSqliteStatement: GRDB.SQLiteStatement = try database.cachedStatement(
            sql: insertInteractionSQL,
        ).sqliteStatement

        /// The compiled "insert interaction" SQLite statement contains `?`
        /// placeholders, which must have real values "bound" to them before the
        /// statement can be used to actually insert a database row. Those bound
        /// values are specific to each interaction being inserted; so, before
        /// we can use the cached statement we must reset any values from
        /// previous interaction inserts and bind new values.
        var sqliteReturnCode: Int32

        // Reset the cached statement.
        sqliteReturnCode = sqlite3_reset(cachedSqliteStatement)
        guard sqliteReturnCode == SQLITE_OK else {
            let errmsg = String(cString: sqlite3_errmsg(sqliteConnection)!)
            throw OWSAssertionError("Failed to reset interaction insert statement! \(errmsg)")
        }

        // Clear any previously bound arguments.
        sqliteReturnCode = sqlite3_clear_bindings(cachedSqliteStatement)
        guard sqliteReturnCode == SQLITE_OK else {
            let errmsg = String(cString: sqlite3_errmsg(sqliteConnection)!)
            throw OWSAssertionError("Failed to clear argument bindings from interaction insert statement! \(errmsg)")
        }

        // Bind new values from the current interaction.
        let args = (interaction.asRecord() as! InteractionRecord).asValues()
        var count: Int32 = 1
        for arg in args {
            defer { count += 1 }

            guard let arg else {
                continue
            }

            let code = arg.databaseValue.bind(to: cachedSqliteStatement, at: count)
            guard code == SQLITE_OK else {
                let errmsg = String(cString: sqlite3_errmsg(sqliteConnection)!)
                throw OWSAssertionError("Failed to bind argument to interaction insert statement! \(errmsg)")
            }
        }

        /// Now that we've bound values for the current interaction, we can
        /// execute the statement.
        insertLoop: while true {
            switch sqlite3_step(cachedSqliteStatement) {
            case SQLITE_DONE:
                break insertLoop
            case SQLITE_ROW, SQLITE_OK:
                break
            case let code:
                let errmsg = String(cString: sqlite3_errmsg(sqliteConnection)!)
                throw OWSAssertionError("Unexpected SQLite return code \(code) while executing interaction insert statement! \(errmsg)")
            }
        }
    }
}