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

import SignalServiceKit

/// Reponsible for "disabling Backups": making the relevant API calls and
/// managing state.
final class BackupDisablingManager {
    /// Side-effects of disabling Backups as relates to the user's AEP.
    enum AEPSideEffect {
        /// Store the given new AEP once disabling is complete.
        case rotate(newAEP: AccountEntropyPool)
    }

    private enum StoreKeys {
        static let aepBeingRotated = "aepBeingRotated"
        static let remoteDisablingFailed = "remoteDisablingFailed"
    }

    private let accountEntropyPoolManager: AccountEntropyPoolManager
    private let authCredentialStore: AuthCredentialStore
    private let backupAttachmentCoordinator: BackupAttachmentCoordinator
    private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager
    private let backupAttachmentDownloadStore: BackupAttachmentDownloadStore
    private let backupCDNCredentialStore: BackupCDNCredentialStore
    private let backupExportJobStore: BackupExportJobStore
    private let backupKeyService: BackupKeyService
    private let backupListMediaManager: BackupListMediaManager
    private let backupPlanManager: BackupPlanManager
    private let backupSettingsStore: BackupSettingsStore
    private let clvBackupExportProgressViewStore: CLVBackupExportProgressView.Store
    private let db: DB
    private let kvStore: KeyValueStore
    private let logger: PrefixedLogger
    private let taskQueue: ConcurrentTaskQueue
    private let tsAccountManager: TSAccountManager

    init(
        accountEntropyPoolManager: AccountEntropyPoolManager,
        authCredentialStore: AuthCredentialStore,
        backupAttachmentCoordinator: BackupAttachmentCoordinator,
        backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager,
        backupAttachmentDownloadStore: BackupAttachmentDownloadStore,
        backupCDNCredentialStore: BackupCDNCredentialStore,
        backupExportJobStore: BackupExportJobStore,
        backupKeyService: BackupKeyService,
        backupListMediaManager: BackupListMediaManager,
        backupPlanManager: BackupPlanManager,
        backupSettingsStore: BackupSettingsStore,
        clvBackupExportProgressViewStore: CLVBackupExportProgressView.Store,
        db: DB,
        tsAccountManager: TSAccountManager,
    ) {
        self.accountEntropyPoolManager = accountEntropyPoolManager
        self.authCredentialStore = authCredentialStore
        self.backupAttachmentCoordinator = backupAttachmentCoordinator
        self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager
        self.backupAttachmentDownloadStore = backupAttachmentDownloadStore
        self.backupCDNCredentialStore = backupCDNCredentialStore
        self.backupExportJobStore = backupExportJobStore
        self.backupKeyService = backupKeyService
        self.backupListMediaManager = backupListMediaManager
        self.backupPlanManager = backupPlanManager
        self.backupSettingsStore = backupSettingsStore
        self.clvBackupExportProgressViewStore = clvBackupExportProgressViewStore
        self.db = db
        self.kvStore = KeyValueStore(collection: "BackupDisablingManager")
        self.logger = PrefixedLogger(prefix: "[Backups]")
        self.taskQueue = ConcurrentTaskQueue(concurrentLimit: 1)
        self.tsAccountManager = tsAccountManager
    }

    // MARK: -

    /// Disable Backups for the current user. `BackupPlan` is immediately set to
    /// `.disabling` locally, with disabling-remotely kicked off asynchronously.
    ///
    /// - Note
    /// Emptying the download queue, either by completing or skipping downloads
    /// of offloaded media, is a prerequisite to disabling Backups.
    ///
    /// - Parameter aepSideEffect
    /// The desired side-effect of disabling Backups on the user's AEP, if any.
    ///
    /// - Returns
    /// The current status of downloading offloaded media. To learn the result
    /// of disabling remotely, callers should wait for `BackupPlan` to become
    /// `.disabled` and then consult ``disableRemotelyFailed(tx:)``.
    func startDisablingBackups(
        aepSideEffect: AEPSideEffect?,
    ) async -> BackupAttachmentDownloadQueueStatus {
        logger.info("Disabling Backups...")

        await db.awaitableWrite { tx in
            switch backupPlanManager.backupPlan(tx: tx) {
            case .disabling:
                owsFail("Unexpectedly attempted to start disabling, but already disabling!")
            case .disabled, .free, .paid, .paidExpiringSoon, .paidAsTester:
                break
            }

            backupPlanManager.setBackupPlan(.disabling, tx: tx)

            switch aepSideEffect {
            case nil:
                break
            case .rotate(let newAEP):
                // Persist the new AEP in this class' KVStore temporarily.
                // Once we're done disabling, we'll save it officially.
                kvStore.setString(newAEP.rawString, key: StoreKeys.aepBeingRotated, transaction: tx)
            }
        }

        logger.info("Backups set locally as disabling. Starting async disabling work...")
        Task {
            await disableRemotelyIfNecessary()
        }

        // We may have just made the download queue non-empty. Ensure we wait
        // for the status manager to start observing, so its reported status
        // picks up that fact. Kick off thumbnails async; fullsize returns here.
        Task { [backupAttachmentDownloadQueueStatusManager] in
            await backupAttachmentDownloadQueueStatusManager.beginObservingIfNecessary(for: .thumbnail)
        }
        return await backupAttachmentDownloadQueueStatusManager.beginObservingIfNecessary(for: .fullsize)
    }

    /// Attempts to remotely disable Backups, if necessary. For example, a
    /// previous launch may have attempted but failed to remotely disable
    /// Backups.
    func disableRemotelyIfNecessary() async {
        await taskQueue.runWithoutTaskCancellationHandler {
            await _disableRemotelyIfNecessary()
        }
    }

    /// Whether a previous remote-disabling attempt failed terminally.
    func disableRemotelyFailed(tx: DBReadTransaction) -> Bool {
        switch backupPlanManager.backupPlan(tx: tx) {
        case .disabled:
            return kvStore.hasValue(StoreKeys.remoteDisablingFailed, transaction: tx)
        case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
            return false
        }
    }

    // MARK: -

    /// Disables remotely, if necessary. Network errors are retried-with-backoff
    /// indefinitely.
    private func _disableRemotelyIfNecessary() async {
        let needsDisablingRemotely = db.read { tx in
            switch backupPlanManager.backupPlan(tx: tx) {
            case .disabling: true
            case .disabled, .free, .paid, .paidExpiringSoon, .paidAsTester: false
            }
        }

        guard needsDisablingRemotely else {
            return
        }

        logger.info("Waiting for downloads before disabling...")
        await _waitForBackupAttachmentDownloads()
        logger.info("Done waiting for downloads.")

        let successfullyDisabledRemotely: Bool
        do {
            let (localIdentifiers, isRegisteredPrimaryDevice) = db.read { tx in
                return (
                    tsAccountManager.localIdentifiers(tx: tx),
                    tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
                )
            }

            if let localIdentifiers, isRegisteredPrimaryDevice {
                logger.info("Disabling Backups remotely...")
                try await Retry.performWithIndefiniteNetworkRetries {
                    try await backupKeyService.deleteBackupKey(
                        localIdentifiers: localIdentifiers,
                        auth: .implicit(),
                        logger: logger,
                    )
                }

                logger.info("Successfully disabled Backups remotely!")
                successfullyDisabledRemotely = true
            } else {
                logger.warn("Cannot disable Backups while unregistered!")
                successfullyDisabledRemotely = false
            }
        } catch {
            logger.error("Failed to disable Backups remotely! \(error)")
            successfullyDisabledRemotely = false
        }

        await db.awaitableWrite { tx in
            if successfullyDisabledRemotely {
                kvStore.removeValue(forKey: StoreKeys.remoteDisablingFailed, transaction: tx)
            } else {
                kvStore.setBool(true, key: StoreKeys.remoteDisablingFailed, transaction: tx)
            }

            backupPlanManager.setBackupPlan(.disabled, tx: tx)

            // Wipe these, which are now outdated.
            backupSettingsStore.resetLastBackupDetails(tx: tx)
            backupSettingsStore.resetShouldAllowBackupUploadsOnCellular(tx: tx)
            backupExportJobStore.wipe(tx: tx)
            backupListMediaManager.wipe(tx: tx)

            // Reset the Backups banners, in case we later reenable Backups.
            clvBackupExportProgressViewStore.setIsHidden(false, tx: tx)
            backupAttachmentDownloadStore.resetDidDismissDownloadCompleteBanner(tx: tx)

            // With Backups disabled, these credentials are no longer valid
            // and are no longer safe to use.
            authCredentialStore.removeAllBackupAuthCredentials(tx: tx)
            backupCDNCredentialStore.wipe(tx: tx)

            if let aepBeingRotatedString = kvStore.getString(StoreKeys.aepBeingRotated, transaction: tx) {
                logger.warn("Rotating AEP after disabling Backups!")

                accountEntropyPoolManager.setAccountEntropyPool(
                    newAccountEntropyPool: try! AccountEntropyPool(key: aepBeingRotatedString),
                    disablePIN: false,
                    tx: tx,
                )
            }
        }

        logger.info("Successfully disabled Backups locally!")
    }

    private func _waitForBackupAttachmentDownloads() async {
        func countsAsComplete(_ status: BackupAttachmentDownloadQueueStatus) -> Bool {
            switch status {
            case .suspended, .empty, .notRegisteredAndReady:
                return true
            case .running, .noWifiReachability, .noReachability, .lowBattery, .lowPowerMode, .lowDiskSpace, .appBackgrounded:
                return false
            }
        }

        if countsAsComplete(await backupAttachmentDownloadQueueStatusManager.beginObservingIfNecessary(for: .fullsize)) {
            return
        }

        for await _ in NotificationCenter.default.notifications(named: .backupAttachmentDownloadQueueStatusDidChange(mode: .fullsize)) {
            if countsAsComplete(await backupAttachmentDownloadQueueStatusManager.currentStatus(for: .fullsize)) {
                break
            }
        }
    }
}

// MARK: -

private extension Retry {
    static func performWithIndefiniteNetworkRetries(block: () async throws -> Void) async throws {
        try await Retry.performWithBackoff(
            maxAttempts: .max,
            maxAverageBackoff: 2 * .minute,
            isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
        ) {
            try await block()
        }
    }
}