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

public import LibSignalClient

public protocol BackupPlanManager {
    /// See ``BackupSettingsStore/backupPlan(tx:)``. API passed-through for
    /// convenience of callers using this type.
    func backupPlan(tx: DBReadTransaction) -> BackupPlan

    /// Set the current `BackupPlan` via data from Storage Service.
    ///
    /// - Important
    /// Must only be called on linked devices!
    func setBackupPlan(
        fromStorageService backupLevel: LibSignalClient.BackupLevel?,
        tx: DBWriteTransaction,
    )

    /// Set the current `BackupPlan`.
    ///
    /// - Important
    /// Must only be called on primary devices!
    func setBackupPlan(_ plan: BackupPlan, tx: DBWriteTransaction)
}

extension Notification.Name {
    public static let backupPlanChanged = Notification.Name("BackupSettings.backupPlanChanged")
}

// MARK: -

class BackupPlanManagerImpl: BackupPlanManager {

    private let backupAttachmentDownloadStore: BackupAttachmentDownloadStore
    private let backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress
    private let backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore
    private let backupAttachmentUploadProgress: BackupAttachmentUploadProgress
    private let backupSettingsStore: BackupSettingsStore
    private let dateProvider: DateProvider
    private let logger: PrefixedLogger
    private let tsAccountManager: TSAccountManager

    init(
        backupAttachmentDownloadStore: BackupAttachmentDownloadStore,
        backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress,
        backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore,
        backupAttachmentUploadProgress: BackupAttachmentUploadProgress,
        backupSettingsStore: BackupSettingsStore,
        dateProvider: @escaping DateProvider,
        tsAccountManager: TSAccountManager,
    ) {
        self.backupAttachmentDownloadStore = backupAttachmentDownloadStore
        self.backupAttachmentDownloadProgress = backupAttachmentDownloadProgress
        self.backupAttachmentUploadEraStore = backupAttachmentUploadEraStore
        self.backupAttachmentUploadProgress = backupAttachmentUploadProgress
        self.backupSettingsStore = backupSettingsStore
        self.dateProvider = dateProvider
        self.logger = PrefixedLogger(prefix: "[Backups]")
        self.tsAccountManager = tsAccountManager
    }

    // MARK: -

    func backupPlan(tx: DBReadTransaction) -> BackupPlan {
        return backupSettingsStore.backupPlan(tx: tx)
    }

    // MARK: -

    func setBackupPlan(fromStorageService backupLevel: BackupLevel?, tx: DBWriteTransaction) {
        guard
            let registeredState = try? tsAccountManager.registeredState(tx: tx),
            !registeredState.isPrimary
        else {
            owsFailDebug("Attempting to set backupPlan from Storage Service, but not a linked device!")
            return
        }

        let oldBackupPlan = backupPlan(tx: tx)

        let newBackupPlan: BackupPlan
        switch backupLevel {
        case nil:
            newBackupPlan = .disabled
        case .free:
            newBackupPlan = .free
        case .paid:
            // Linked devices don't support optimizeLocalStorage; default off.
            newBackupPlan = .paid(optimizeLocalStorage: false)
        }

        guard oldBackupPlan != newBackupPlan else {
            return
        }

        logger.info("Setting BackupPlan via Storage Service! \(oldBackupPlan) -> \(newBackupPlan)")

        backupSettingsStore.setBackupPlan(newBackupPlan, tx: tx)

        switch backupLevel {
        case nil:
            configureDownloadsForDisablingBackups(tx: tx)
        case .free, .paid:
            break
        }
    }

    // MARK: -

    func setBackupPlan(_ newBackupPlan: BackupPlan, tx: DBWriteTransaction) {
        let oldBackupPlan = backupPlan(tx: tx)

        guard oldBackupPlan != newBackupPlan else {
            logger.warn("Attempting to set BackupPlan to existing value: aborting. \(oldBackupPlan)")
            return
        }

        logger.info("Setting BackupPlan! \(oldBackupPlan) -> \(newBackupPlan)")

        backupSettingsStore.setBackupPlan(newBackupPlan, tx: tx)

        backupAttachmentUploadProgress.backupPlanDidChange(
            oldBackupPlan: oldBackupPlan,
            newBackupPlan: newBackupPlan,
            tx: tx,
        )

        rotateUploadEraIfNecessary(
            oldBackupPlan: oldBackupPlan,
            newBackupPlan: newBackupPlan,
            tx: tx,
        )

        configureDownloadsForBackupPlanChange(
            oldPlan: oldBackupPlan,
            newPlan: newBackupPlan,
            tx: tx,
        )

        switch newBackupPlan {
        case .disabled, .disabling, .free:
            // Media tier capacity is only a paid tier concept; reset our local
            // knowledge of having run out of space when we become non-paid tier.
            // If we become paid tier again, we will rediscover that we are out
            // of space when we try and upload and get an error from the server.
            backupSettingsStore.setHasConsumedMediaTierCapacity(false, tx: tx)
        case .paid, .paidExpiringSoon, .paidAsTester:
            break
        }

        if oldBackupPlan != newBackupPlan {
            tx.addSyncCompletion {
                NotificationCenter.default.post(name: .backupPlanChanged, object: nil)
                Task {
                    // This is run after the write transaction that updates the backup plan
                    // completes.  This allows the download progress observer to recalculate
                    // the new state of pending downloads and allow progress to be displayed,
                    // even if the queue may not be started yet.
                    try? await self.backupAttachmentDownloadProgress.beginObserving()
                }
            }
        }
    }

    // MARK: -

    private func rotateUploadEraIfNecessary(
        oldBackupPlan: BackupPlan,
        newBackupPlan: BackupPlan,
        tx: DBWriteTransaction,
    ) {
        func isPaidPlan(_ backupPlan: BackupPlan) -> Bool {
            switch backupPlan {
            case .disabled, .disabling, .free: false
            case .paid, .paidExpiringSoon, .paidAsTester: true
            }
        }

        if !isPaidPlan(oldBackupPlan), isPaidPlan(newBackupPlan) {
            // If we're becoming a paid-tier user, we should rotate the upload
            // era to ensure we run a list-media and discover any necessary
            // uploads.
            backupAttachmentUploadEraStore.rotateUploadEra(tx: tx)
        }
    }

    // MARK: -

    private func configureDownloadsForBackupPlanChange(
        oldPlan: BackupPlan,
        newPlan: BackupPlan,
        tx: DBWriteTransaction,
    ) {
        switch (oldPlan, newPlan) {
        case
            (.disabling, .disabling),
            (.disabled, .disabled),
            (.free, .free):
            // No change.
            return

        case
            (.disabling, .free),
            (.disabling, .paid),
            (.disabling, .paidExpiringSoon),
            (.disabling, .paidAsTester),
            (.disabled, .disabling):
            owsFailDebug("Unexpected BackupPlan transition: \(oldPlan) -> \(newPlan)")
            return

        case (.free, .disabling):
            // While in free tier, we may have been continuing downloads
            // from when you were previously paid tier. But that was nice
            // to have; now that we're disabling backups cancel them all.
            backupAttachmentDownloadStore.markAllReadyIneligible(tx: tx)
            backupAttachmentDownloadStore.deleteAllDone(tx: tx)

        case
            let (.paid(optimizeLocalStorage), .disabling),
            let (.paidExpiringSoon(optimizeLocalStorage), .disabling),
            let (.paidAsTester(optimizeLocalStorage), .disabling):
            backupAttachmentDownloadStore.deleteAllDone(tx: tx)
            // Unsuspend; this is the user opt-in to trigger downloads.
            backupSettingsStore.setIsBackupDownloadQueueSuspended(false, tx: tx)
            if optimizeLocalStorage {
                // If we had optimize enabled, make anything ineligible (offloaded
                // attachments) now eligible.
                backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
            }

        case (_, .disabled):
            configureDownloadsForDisablingBackups(tx: tx)

        case (.disabled, .free):
            backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
            // Suspend the queue so the user has to explicitly opt-in to download.
            backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)

        case
            let (.disabled, .paid(optimizeStorage)),
            let (.disabled, .paidExpiringSoon(optimizeStorage)),
            let (.disabled, .paidAsTester(optimizeStorage)):
            backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
            // Suspend the queue so the user has to explicitly opt-in to download.
            backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
            if optimizeStorage {
                // Unclear how you would go straight from disabled to optimize
                // enabled, but just go through the motions of both state changes
                // as if they'd happened independently.
                configureDownloadsForDidEnableOptimizeStorage(tx: tx)
            }

        case
            let (.paid(wasOptimizeLocalStorageEnabled), .free),
            let (.paidExpiringSoon(wasOptimizeLocalStorageEnabled), .free),
            let (.paidAsTester(wasOptimizeLocalStorageEnabled), .free):
            // We explicitly do nothing going from paid to free; we want to continue
            // any downloads that were already running (so we take advantage of the
            // media tier cdn TTL being longer than paid subscription lifetime) but
            // also not schedule (or un-suspend) if we weren't already downloading.
            // But if optimization was on, its now implicitly off, so handle that.
            if wasOptimizeLocalStorageEnabled {
                configureDownloadsForDidDisableOptimizeStorage(tx: tx)
            }

        case
            let (.free, .paid(optimizeStorage)),
            let (.free, .paidExpiringSoon(optimizeStorage)),
            let (.free, .paidAsTester(optimizeStorage)):
            // We explicitly do nothing when going from free to paid; any state
            // changes that will happen will be triggered by list media request
            // handling which will always run at the start of a new upload era.
            // But if we somehow went straight from free to optimize enabled,
            // handle that state transition.
            if optimizeStorage {
                owsFailDebug("Going from free or disabled directly to optimize enabled shouldn't be allowed?")
                configureDownloadsForDidEnableOptimizeStorage(tx: tx)
            }

        case
            // Downloads don't care if expiring soon or not
            let (.paid(oldOptimize), .paid(newOptimize)),
            let (.paid(oldOptimize), .paidExpiringSoon(newOptimize)),
            let (.paid(oldOptimize), .paidAsTester(newOptimize)),
            let (.paidExpiringSoon(oldOptimize), .paid(newOptimize)),
            let (.paidExpiringSoon(oldOptimize), .paidExpiringSoon(newOptimize)),
            let (.paidExpiringSoon(oldOptimize), .paidAsTester(newOptimize)),
            let (.paidAsTester(oldOptimize), .paid(newOptimize)),
            let (.paidAsTester(oldOptimize), .paidExpiringSoon(newOptimize)),
            let (.paidAsTester(oldOptimize), .paidAsTester(newOptimize)):
            if oldOptimize == newOptimize {
                // Nothing changed.
                break
            } else if newOptimize {
                configureDownloadsForDidEnableOptimizeStorage(tx: tx)
            } else {
                configureDownloadsForDidDisableOptimizeStorage(tx: tx)
            }
        }
    }

    private func configureDownloadsForDisablingBackups(tx: DBWriteTransaction) {
        // When we disable, we mark everything ineligible and delete all
        // done rows. If we ever re-enable, we will mark those rows
        // ready again.
        backupAttachmentDownloadStore.deleteAllDone(tx: tx)
        backupAttachmentDownloadStore.markAllReadyIneligible(tx: tx)
        // This doesn't _really_ do anything, since we don't run the queue
        // when disabled anyway, but may as well suspend.
        backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
    }

    private func configureDownloadsForDidEnableOptimizeStorage(tx: DBWriteTransaction) {
        // When we turn on optimization, make all media tier fullsize downloads
        // from the queue that are past the optimization threshold ineligible.
        // If we downloaded them we'd offload them immediately anyway.
        // This isn't 100% necessary; after all something 29 days old today will be
        // 30 days old tomorrow, so the queue runner will gracefully handle old
        // downloads at run-time anyway. But its more efficient to do in bulk.
        let threshold = dateProvider().ows_millisecondsSince1970 - Attachment.offloadingThresholdMs
        backupAttachmentDownloadStore.markAllMediaTierFullsizeDownloadsIneligible(
            olderThan: threshold,
            tx: tx,
        )
        // Un-suspend; when optimization is enabled we always auto-download
        // the stuff that is eligible (newer attachments).
        backupSettingsStore.setIsBackupDownloadQueueSuspended(false, tx: tx)
        // Reset the progress counter.
        backupAttachmentDownloadStore.deleteAllDone(tx: tx)
    }

    private func configureDownloadsForDidDisableOptimizeStorage(tx: DBWriteTransaction) {
        // When we turn _off_ optimization, we want to make ready all the media tier downloads,
        // but suspend the queue so we don't immediately start downloading.
        backupAttachmentDownloadStore.markAllIneligibleReady(tx: tx)
        // Suspend the queue; the user has to explicitly opt in to downloads
        // after optimization is disabled.
        backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)

        // Reset the download banner so we show it again if the user dismissed.
        backupAttachmentDownloadStore.resetDidDismissDownloadCompleteBanner(tx: tx)
    }
}

// MARK: -

#if TESTABLE_BUILD

class MockBackupPlanManager: BackupPlanManager {
    var backupPlanMock: BackupPlan?
    func backupPlan(tx: DBReadTransaction) -> BackupPlan {
        backupPlanMock ?? .disabled
    }

    func setBackupPlan(fromStorageService backupLevel: BackupLevel?, tx: DBWriteTransaction) {
        owsFail("Not implemented!")
    }

    func setBackupPlan(_ plan: BackupPlan, tx: DBWriteTransaction) {
        backupPlanMock = plan
    }
}

#endif