Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Network/API/Requests/AccountAttributes/AccountAttributesUpdaterImpl.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation

public class AccountAttributesUpdaterImpl: AccountAttributesUpdater {
    private let accountAttributesGenerator: AccountAttributesGenerator
    private let appReadiness: AppReadiness
    private let cronStore: CronStore
    private let dateProvider: DateProvider
    private let db: any DB
    private let kvStore: KeyValueStore
    private let networkManager: NetworkManager
    private let profileManager: ProfileManager
    private let svrLocalStorage: SVRLocalStorage
    private let syncManager: SyncManagerProtocol
    private let tsAccountManager: TSAccountManager

    private enum Constants {
        // We must refresh our registration recovery password periodically. We
        // typically do this when updating to a new version, but we want to refresh
        // it after 14 days if we haven't upgraded.
        static let periodicRefreshInterval: TimeInterval = 14 * .day
    }

    public init(
        accountAttributesGenerator: AccountAttributesGenerator,
        appReadiness: AppReadiness,
        cron: Cron,
        dateProvider: @escaping DateProvider,
        db: any DB,
        networkManager: NetworkManager,
        profileManager: ProfileManager,
        svrLocalStorage: SVRLocalStorage,
        syncManager: SyncManagerProtocol,
        tsAccountManager: TSAccountManager,
    ) {
        self.accountAttributesGenerator = accountAttributesGenerator
        self.appReadiness = appReadiness
        self.cronStore = CronStore(uniqueKey: .updateAttributes)
        self.dateProvider = dateProvider
        self.db = db
        self.kvStore = KeyValueStore(collection: "AccountAttributesUpdater")
        self.networkManager = networkManager
        self.profileManager = profileManager
        self.svrLocalStorage = svrLocalStorage
        self.syncManager = syncManager
        self.tsAccountManager = tsAccountManager
        self.registerForCron(cron)
    }

    private func registerForCron(_ cron: Cron) {
        cron.scheduleFrequently(
            mustBeRegistered: true,
            mustBeConnected: true,
            operation: { () throws -> Bool in
                let updateConfig = self.db.read { tx -> UpdateConfig? in
                    guard let updateConfig = self.updateConfig(tx: tx) else {
                        return nil
                    }
                    // We update periodically (according to Cron), whenever the capabilities
                    // change (useful during testing or if capabilities are influenced by
                    // RemoteConfig, DB migrations, etc.), and whenever requested explicitly.
                    let shouldUpdate: Bool = (
                        updateConfig.updateRequestToken != nil
                            || Date() >= self.cronStore.mostRecentDate(tx: tx).addingTimeInterval(Constants.periodicRefreshInterval)
                            || updateConfig.capabilities.requestParameters != self.oldCapabilities(tx: tx),
                    )
                    return shouldUpdate ? updateConfig : nil
                }
                guard let updateConfig else {
                    return false
                }
                try await self.updateAccountAttributes(updateConfig: updateConfig, authedAccount: .implicit())
                return true
            },
            handleResult: { result in
                switch result {
                case .success(false), .failure(is CancellationError):
                    break
                case .success(true):
                    // Handled by updateAccountAttributes.
                    break
                case .failure(let error):
                    Logger.warn("account attributes hit terminal error; stopping for now: \(error)")
                    await self.db.awaitableWrite(block: self.updateMostRecentDate(tx:))
                }
            },
        )
    }

    private func updateMostRecentDate(tx: DBWriteTransaction) {
        self.cronStore.setMostRecentDate(Date(), jitter: Constants.periodicRefreshInterval / 20, tx: tx)
    }

    public func updateAccountAttributes(authedAccount: AuthedAccount) async throws {
        let updateConfig = await db.awaitableWrite { tx -> UpdateConfig? in
            self.kvStore.setData(
                Randomness.generateRandomBytes(16),
                key: Keys.latestUpdateRequestToken,
                transaction: tx,
            )
            return self.updateConfig(tx: tx)
        }
        guard let updateConfig else {
            return
        }
        try await self.updateAccountAttributes(updateConfig: updateConfig, authedAccount: authedAccount)
    }

    public func scheduleAccountAttributesUpdate(authedAccount: AuthedAccount, tx: DBWriteTransaction) {
        self.kvStore.setData(
            Randomness.generateRandomBytes(16),
            key: Keys.latestUpdateRequestToken,
            transaction: tx,
        )
        let updateConfig = self.updateConfig(tx: tx)
        guard let updateConfig else {
            return
        }
        tx.addSyncCompletion {
            Task {
                try await self.updateAccountAttributes(updateConfig: updateConfig, authedAccount: authedAccount)
            }
        }
    }

    private struct UpdateConfig {
        var registrationState: TSRegistrationState
        var updateRequestToken: Data?
        var capabilities: AccountAttributes.Capabilities
    }

    private func updateConfig(tx: DBReadTransaction) -> UpdateConfig? {
        let registrationState = self.tsAccountManager.registrationState(tx: tx)
        guard registrationState.isRegistered else {
            return nil
        }

        // has non-nil value if isRegistered is true.
        let hasBackedUpMasterKey = self.svrLocalStorage.getIsMasterKeyBackedUp(tx)
        let capabilities = AccountAttributes.Capabilities(hasSVRBackups: hasBackedUpMasterKey)
        let lastAttributeRequestToken = self.kvStore.getData(Keys.latestUpdateRequestToken, transaction: tx)

        return UpdateConfig(
            registrationState: registrationState,
            updateRequestToken: lastAttributeRequestToken,
            capabilities: capabilities,
        )
    }

    private func oldCapabilities(tx: DBReadTransaction) -> [String: NSNumber]? {
        return self.kvStore.getDictionary(
            Keys.lastUpdateDeviceCapabilities,
            keyClass: NSString.self,
            objectClass: NSNumber.self,
            transaction: tx,
        ) as [String: NSNumber]?
    }

    /// Performs a single attempt to update the account attributes.
    ///
    /// This method assumes we have a priori knowledge that an update is
    /// required; callers must check whether or not an update is required.
    private func updateAccountAttributes(updateConfig: UpdateConfig, authedAccount: AuthedAccount) async throws {
        let request: TSRequest
        if updateConfig.registrationState.isPrimaryDevice == true {
            let attributes = try db.read { tx in
                return try accountAttributesGenerator
                    .generateForPrimary(capabilities: updateConfig.capabilities, tx: tx)
            }
            request = AccountAttributesRequestFactory(tsAccountManager: tsAccountManager)
                .updatePrimaryDeviceAttributesRequest(attributes, auth: authedAccount.chatServiceAuth)
        } else {
            request = AccountAttributesRequestFactory(tsAccountManager: tsAccountManager)
                .updateLinkedDeviceCapabilitiesRequest(updateConfig.capabilities, auth: authedAccount.chatServiceAuth)
        }
        _ = try await networkManager.asyncRequest(request)

        await db.awaitableWrite { tx in
            self.updateMostRecentDate(tx: tx)
            self.kvStore.setObject(
                updateConfig.capabilities.requestParameters as [NSString: NSNumber] as NSDictionary,
                key: Keys.lastUpdateDeviceCapabilities,
                transaction: tx,
            )
            // Clear the update request unless a new update has been requested
            // while this update was in flight.
            if
                let updateRequestToken = updateConfig.updateRequestToken,
                updateRequestToken == self.kvStore.getData(Keys.latestUpdateRequestToken, transaction: tx)
            {
                self.kvStore.removeValue(forKey: Keys.latestUpdateRequestToken, transaction: tx)
            }
        }

        // Fetch our profile (unclear why), but ignore the result and any errors
        // because this is a best-effort fetch.
        _ = try? await profileManager.fetchLocalUsersProfile(authedAccount: authedAccount)

        // Primary devices should sync their configuration whenever they
        // update their account attributes.
        if updateConfig.registrationState.isRegisteredPrimaryDevice {
            self.syncManager.sendConfigurationSyncMessage()
        }
    }

    private enum Keys {
        static let latestUpdateRequestToken = "latestUpdateRequestDate"
        static let lastUpdateDeviceCapabilities = "lastUpdateDeviceCapabilities"
    }
}