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

import GRDB
import SignalServiceKit

/// Responsible for migrating "group update info message" to store group updates
/// in the most "modern" format, contrasted with a variety of legacy formats.
struct InfoMessageGroupUpdateMigrator {
    private enum StoreKeys {
        static let hasFinishedMigrating = "hasFinishedMigrating"
        static let lastMigratedInfoMessageRowID = "lastMigratedInfoMessageRowID"
    }

    private let db: DB
    private let kvStore: NewKeyValueStore
    private let logger: PrefixedLogger
    private let modelReadCaches: () -> ModelReadCaches
    private let tsAccountManager: () -> TSAccountManager

    init(
        db: DB,
        modelReadCaches: @escaping () -> ModelReadCaches,
        tsAccountManager: @escaping () -> TSAccountManager,
    ) {
        self.db = db
        self.kvStore = NewKeyValueStore(collection: "GroupUpdateInfoMessageMigrator")
        self.logger = PrefixedLogger(prefix: "GroupUpdateInfoMessageMigrator")
        self.modelReadCaches = modelReadCaches
        self.tsAccountManager = tsAccountManager
    }

    func needsToRun() -> Bool {
        let hasFinishedMigrating = db.read { tx in
            kvStore.fetchValue(Bool.self, forKey: StoreKeys.hasFinishedMigrating, tx: tx) ?? false
        }

        return !hasFinishedMigrating
    }

    func run() async throws(CancellationError) {
        struct InfoMessage: GRDB.FetchableRecord {
            static let databaseTableName = InteractionRecord.databaseTableName
            static let idColumn = "\(interactionColumn: .id)"
            static let infoMessageUserInfoColumn = "\(interactionColumn: .infoMessageUserInfo)"

            let rowID: Int64
            let infoMessageUserInfoBlob: Data?

            init(row: Row) {
                self.rowID = row[Self.idColumn]
                self.infoMessageUserInfoBlob = row[Self.infoMessageUserInfoColumn]
            }
        }

        struct TxContext {
            var hasFinishedMigrating: Bool
            var lastMigratedInfoMessageRowID: Int64?
            let localIdentifiers: LocalIdentifiers?
        }

        logger.info("Starting...")

        try await TimeGatedBatch.processAll(
            db: db,
            buildTxContext: { tx -> TxContext in
                let lastMigratedInfoMessageRowID = kvStore.fetchValue(
                    Int64.self,
                    forKey: StoreKeys.lastMigratedInfoMessageRowID,
                    tx: tx,
                )

                return TxContext(
                    hasFinishedMigrating: false,
                    lastMigratedInfoMessageRowID: lastMigratedInfoMessageRowID,
                    localIdentifiers: tsAccountManager().localIdentifiers(tx: tx),
                )
            },
            processBatch: { tx, context throws(CancellationError) in
                guard
                    !Task.isCancelled,
                    let localIdentifiers = context.localIdentifiers
                else {
                    // Stop the iteration, but don't record that we're done.
                    throw CancellationError()
                }

                let infoMessage: InfoMessage
                do {
                    var infoMessageQuery = """
                        SELECT \(InfoMessage.idColumn), \(InfoMessage.infoMessageUserInfoColumn) FROM \(InfoMessage.databaseTableName)
                    """
                    if let lastMigratedInfoMessageRowID = context.lastMigratedInfoMessageRowID {
                        infoMessageQuery += " WHERE \(InfoMessage.idColumn) < \(lastMigratedInfoMessageRowID)"
                    }
                    infoMessageQuery += " ORDER BY \(InfoMessage.idColumn) DESC"

                    guard let _infoMessage = try InfoMessage.fetchOne(tx.database, sql: infoMessageQuery) else {
                        // No more info messages: we're done!
                        context.hasFinishedMigrating = true
                        return .done(())
                    }

                    infoMessage = _infoMessage
                } catch {
                    logger.error("Failed to read InfoMessage from cursor: aborting migration.")
                    context.hasFinishedMigrating = true
                    return .done(())
                }

                guard
                    let infoMessageUserInfoBlob = infoMessage.infoMessageUserInfoBlob,
                    let infoMessageUserInfo = try? SDSDeserialization.unarchivedInfoDictionary(from: infoMessageUserInfoBlob)
                else {
                    // Missing or failed-to-unarchive infoMessageUserInfo: skip
                    // this interaction.
                    context.lastMigratedInfoMessageRowID = infoMessage.rowID
                    return .more
                }

                guard
                    let precomputedGroupUpdateItems = TSInfoMessage.computedGroupUpdateItems(
                        infoMessageUserInfo: infoMessageUserInfo,
                        customMessage: nil,
                        localIdentifiers: localIdentifiers,
                        tx: tx,
                    )
                else {
                    // No precomputed group update items. This may not be a
                    // group update, or a malformed one: skip it.
                    context.lastMigratedInfoMessageRowID = infoMessage.rowID
                    return .more
                }

                // This is the only key in infoMessageUserInfo that we're now
                // interested in – everything else can be discarded. There are
                // no info messages with group-update keys *and* keys for some
                // other update type, and once we have these precomputed update
                // items we don't need any of the other keys that might have
                // been present before.
                let newInfoMessageUserInfo: [InfoMessageUserInfoKey: Any] = [
                    .groupUpdateItems: TSInfoMessage.PersistableGroupUpdateItemsWrapper(precomputedGroupUpdateItems),
                ]
                let newInfoMessageUserInfoBlob = try! NSKeyedArchiver.archivedData(
                    withRootObject: newInfoMessageUserInfo,
                    requiringSecureCoding: true,
                )

                try? tx.database.execute(
                    sql: """
                        UPDATE \(InfoMessage.databaseTableName)
                        SET \(InfoMessage.infoMessageUserInfoColumn) = ?
                        WHERE \(InfoMessage.idColumn) = ?
                    """,
                    arguments: [newInfoMessageUserInfoBlob, infoMessage.rowID],
                )

                context.lastMigratedInfoMessageRowID = infoMessage.rowID
                return .more
            },
            concludeTx: { tx, context in
                // We've directly modified TSInteractions that may be cached, so
                // clear said caches.
                modelReadCaches().evacuateAllCaches()

                if let lastMigratedInfoMessageRowID = context.lastMigratedInfoMessageRowID {
                    kvStore.writeValue(
                        lastMigratedInfoMessageRowID,
                        forKey: StoreKeys.lastMigratedInfoMessageRowID,
                        tx: tx,
                    )
                }

                if context.hasFinishedMigrating {
                    kvStore.writeValue(true, forKey: StoreKeys.hasFinishedMigrating, tx: tx)
                }
            },
        )

        logger.info("Done!")
    }
}