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

import GRDB

public enum BackupAttachmentDownloadQueueMode {
    case fullsize
    case thumbnail
}

public enum BackupAttachmentDownloadQueueStatus: Equatable, Sendable {
    /// The queue is running, and attachments are downloading.
    case running

    /// Downloads may be available, but are pending user action to start running.
    case suspended

    /// There's nothing to download.
    case empty

    /// Must be registered and the app ready to download.
    case notRegisteredAndReady
    /// Wifi is required for downloads, but not available.
    case noWifiReachability
    /// Internet access is required for downloads, but not available.
    case noReachability
    /// The device has low battery.
    case lowBattery
    /// The device is in low power mode.
    case lowPowerMode
    /// There is not enough disk space to finish downloading.
    /// Note that we require a minimum amount of space and will continue
    /// greedily downloading until this minimum is reached even if we know
    /// ahead of time we will hit the threshold before finishing.
    /// Does not apply to upload.
    case lowDiskSpace
    /// The app is running in the background.
    case appBackgrounded
}

public extension Notification.Name {
    static func backupAttachmentDownloadQueueStatusDidChange(mode: BackupAttachmentDownloadQueueMode) -> Notification.Name {
        switch mode {
        case .fullsize:
            return Notification.Name(rawValue: "BackupAttachmentDownloadQueueStatusDidChange_fullsize")
        case .thumbnail:
            return Notification.Name(rawValue: "BackupAttachmentDownloadQueueStatusDidChange_thumbnail")
        }
    }
}

// MARK: -

/// Grab one of these when starting a job; use it to mark success or failure
/// This takes a (black box) snapshot of state when the download began so that
/// when we respond to success or errors we apply them appropriately based
/// on state at start of the job, not at the end.
public protocol BackupAttachmentDownloadQueueStatusToken {}

// MARK: -

/// Tracks and reports the status of the Backup attachment download queue.
///
/// `@MainActor`-isolated because most of the inputs are themselves isolated.
///
/// - SeeAlso `BackupAttachmentDownloadTracker`
@MainActor
public protocol BackupAttachmentDownloadQueueStatusManager {

    /// The current status of the download queue.
    /// - Important
    /// Only returns meaningful values once `beginObservingIfNecessary` has been called.
    func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus

    /// The current status of the download queue, and a token.
    /// - Important
    /// Only returns meaningful values once `beginObservingIfNecessary` has been called.
    func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (BackupAttachmentDownloadQueueStatus, BackupAttachmentDownloadQueueStatusToken)

    /// Begin observing status updates, if necessary.
    func beginObservingIfNecessary(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus

    /// Checks if the error should change the status (e.g. out of disk space errors should stop subsequent downloads)
    /// Returns nil if the error has no effect on the status (though note the status may be changed for any other concurrent
    /// reason unrelated to the error).
    nonisolated func jobDidExperienceError(
        _ error: Error,
        token: BackupAttachmentDownloadQueueStatusToken,
        mode: BackupAttachmentDownloadQueueMode,
    ) async -> BackupAttachmentDownloadQueueStatus?

    nonisolated func jobDidSucceed(
        token: BackupAttachmentDownloadQueueStatusToken,
        mode: BackupAttachmentDownloadQueueMode,
    ) async

    /// Synchronously returns the minimum required disk space for downloads.
    nonisolated func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64

    /// Check available disk space, optionally clearing in-memory state
    /// regarding past "out of space" errors.
    func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool)

    /// Call when the download queue is emptied.
    func didEmptyQueue(for mode: BackupAttachmentDownloadQueueMode)

    func setIsMainAppAndActiveOverride(_ newValue: Bool)
}

// MARK: -

@MainActor
class BackupAttachmentDownloadQueueStatusManagerImpl: BackupAttachmentDownloadQueueStatusManager {

    func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus {
        return state.asQueueStatus(mode: mode, dateProvider: dateProvider)
    }

    func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (BackupAttachmentDownloadQueueStatus, BackupAttachmentDownloadQueueStatusToken) {
        return (
            state.asQueueStatus(mode: mode, dateProvider: dateProvider),
            BackupAttachmentDownloadQueueStatusTokenImpl(lastNetworkOr5xxErrorTime: state.lastNetworkOr5xxErrorTime),
        )
    }

    func beginObservingIfNecessary(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus {
        observeDeviceAndLocalStatesIfNecessary()
        return currentStatus(for: mode)
    }

    nonisolated func jobDidExperienceError(
        _ error: Error,
        token: BackupAttachmentDownloadQueueStatusToken,
        mode: BackupAttachmentDownloadQueueMode,
    ) async -> BackupAttachmentDownloadQueueStatus? {
        // We care about out of disk space errors for downloads.
        if (error as NSError).code == NSFileWriteOutOfSpaceError {
            // Return nil to avoid having to thread-hop to the main thread just to get
            // the current status when we know it won't change due to this error.
            return await MainActor.run {
                return downloadDidExperienceOutOfSpaceError(mode: mode)
            }
        } else if error.isNetworkFailureOrTimeout {
            return await MainActor.run {
                return downloadDidExperienceNetworkOr5xxError(mode: mode, token: token)
            }
        } else {
            return nil
        }
    }

    nonisolated func jobDidSucceed(
        token: BackupAttachmentDownloadQueueStatusToken,
        mode: BackupAttachmentDownloadQueueMode,
    ) async {
        guard (token as? BackupAttachmentDownloadQueueStatusTokenImpl)?.lastNetworkOr5xxErrorTime != nil else {
            return
        }
        await MainActor.run {
            self.resetNetworkErrorRetriesAfterSuccess(token: token)
        }
    }

    func didEmptyQueue(for mode: BackupAttachmentDownloadQueueMode) {
        switch mode {
        case .thumbnail:
            state.isThumbnailQueueEmpty = true
        case .fullsize:
            state.isFullsizeQueueEmpty = true

            // We were temporarily doing downloads over cellular, but we're done
            // and shouldn't keep allowing cellular.
            Task {
                await db.awaitableWrite { tx in
                    backupSettingsStore.setShouldAllowBackupDownloadsOnCellular(false, tx: tx)
                }
            }
        }

        if state.isThumbnailQueueEmpty == true, state.isFullsizeQueueEmpty == true {
            stopObservingDeviceAndLocalStates()
        }
    }

    nonisolated func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 {
        return getRequiredDiskSpace()
    }

    func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) {
        state.availableDiskSpace = getAvailableDiskSpace()

        if clearPreviousOutOfSpaceErrors {
            state.downloadDidExperienceOutOfSpaceError = false
        }
    }

    func setIsMainAppAndActiveOverride(_ newValue: Bool) {
        state.isMainAppAndActiveOverride = newValue
    }

    // MARK: - Init

    private let appContext: AppContext
    private let appReadiness: AppReadiness
    private let backupAttachmentDownloadStore: BackupAttachmentDownloadStore
    private let backupSettingsStore: BackupSettingsStore
    private var batteryLevelMonitor: DeviceBatteryLevelMonitor?
    private let dateProvider: DateProvider
    private let db: DB
    private let deviceBatteryLevelManager: (any DeviceBatteryLevelManager)?
    private let reachabilityManager: SSKReachabilityManager
    private nonisolated let remoteConfigManager: RemoteConfigManager
    private let tsAccountManager: TSAccountManager

    init(
        appContext: AppContext,
        appReadiness: AppReadiness,
        backupAttachmentDownloadStore: BackupAttachmentDownloadStore,
        backupSettingsStore: BackupSettingsStore,
        dateProvider: @escaping DateProvider,
        db: DB,
        deviceBatteryLevelManager: (any DeviceBatteryLevelManager)?,
        reachabilityManager: SSKReachabilityManager,
        remoteConfigManager: RemoteConfigManager,
        tsAccountManager: TSAccountManager,
    ) {
        self.appContext = appContext
        self.appReadiness = appReadiness
        self.backupAttachmentDownloadStore = backupAttachmentDownloadStore
        self.backupSettingsStore = backupSettingsStore
        self.dateProvider = dateProvider
        self.db = db
        self.deviceBatteryLevelManager = deviceBatteryLevelManager
        self.reachabilityManager = reachabilityManager
        self.remoteConfigManager = remoteConfigManager
        self.tsAccountManager = tsAccountManager

        self.state = State(
            isFullsizeQueueEmpty: nil,
            isThumbnailQueueEmpty: nil,
            areDownloadsSuspended: nil,
            isMainApp: appContext.isMainApp,
            isAppReady: false,
            isRegistered: nil,
            shouldAllowBackupDownloadsOnCellular: nil,
            isWifiReachable: nil,
            isReachable: nil,
            batteryLevel: nil,
            isLowPowerMode: nil,
            availableDiskSpace: nil,
            requiredDiskSpace: nil,
            downloadDidExperienceOutOfSpaceError: false,
            isMainAppAndActive: appContext.isMainAppAndActive,
        )
        self.fullsizeQueueStatus = state.asQueueStatus(mode: .fullsize, dateProvider: dateProvider)
        self.thumbnailQueueStatus = state.asQueueStatus(mode: .thumbnail, dateProvider: dateProvider)

        appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { [weak self] in
            self?.appReadinessDidChange()
        }
    }

    // MARK: - Private

    private struct State {
        var isFullsizeQueueEmpty: Bool?
        var isThumbnailQueueEmpty: Bool?

        var areDownloadsSuspended: Bool?

        var isMainApp: Bool
        var isAppReady: Bool
        var isRegistered: Bool?

        var shouldAllowBackupDownloadsOnCellular: Bool?
        var isWifiReachable: Bool?
        var isReachable: Bool?

        // Value from 0 to 1
        var batteryLevel: Float?
        var isLowPowerMode: Bool?

        // Both in bytes
        var availableDiskSpace: UInt64?
        var requiredDiskSpace: UInt64?
        var downloadDidExperienceOutOfSpaceError: Bool

        var isMainAppAndActive: Bool
        var isMainAppAndActiveOverride: Bool = false

        var networkOr5xxErrorCount = 0
        var lastNetworkOr5xxErrorTime: Date?

        init(
            isFullsizeQueueEmpty: Bool?,
            isThumbnailQueueEmpty: Bool?,
            areDownloadsSuspended: Bool?,
            isMainApp: Bool,
            isAppReady: Bool,
            isRegistered: Bool?,
            shouldAllowBackupDownloadsOnCellular: Bool?,
            isWifiReachable: Bool?,
            isReachable: Bool?,
            batteryLevel: Float?,
            isLowPowerMode: Bool?,
            availableDiskSpace: UInt64?,
            requiredDiskSpace: UInt64?,
            downloadDidExperienceOutOfSpaceError: Bool,
            isMainAppAndActive: Bool,
        ) {
            self.isFullsizeQueueEmpty = isFullsizeQueueEmpty
            self.isThumbnailQueueEmpty = isThumbnailQueueEmpty
            self.areDownloadsSuspended = areDownloadsSuspended
            self.isMainApp = isMainApp
            self.isAppReady = isAppReady
            self.isRegistered = isRegistered
            self.shouldAllowBackupDownloadsOnCellular = shouldAllowBackupDownloadsOnCellular
            self.isWifiReachable = isWifiReachable
            self.isReachable = isReachable
            self.batteryLevel = batteryLevel
            self.isLowPowerMode = isLowPowerMode
            self.availableDiskSpace = availableDiskSpace
            self.requiredDiskSpace = requiredDiskSpace
            self.downloadDidExperienceOutOfSpaceError = downloadDidExperienceOutOfSpaceError
            self.isMainAppAndActive = isMainAppAndActive
        }

        func asQueueStatus(
            mode: BackupAttachmentDownloadQueueMode,
            dateProvider: DateProvider,
        ) -> BackupAttachmentDownloadQueueStatus {

            switch mode {
            case .fullsize:
                if isFullsizeQueueEmpty == true {
                    return .empty
                }
            case .thumbnail:
                if isThumbnailQueueEmpty == true {
                    return .empty
                }
            }

            guard
                isMainApp,
                isAppReady,
                isRegistered == true
            else {
                return .notRegisteredAndReady
            }

            if areDownloadsSuspended == true {
                return .suspended
            }

            if downloadDidExperienceOutOfSpaceError {
                return .lowDiskSpace
            }

            if
                let availableDiskSpace,
                let requiredDiskSpace,
                availableDiskSpace < requiredDiskSpace
            {
                return .lowDiskSpace
            }

            if
                shouldAllowBackupDownloadsOnCellular != true,
                isWifiReachable != true
            {
                return .noWifiReachability
            }

            if isReachable != true {
                return .noReachability
            }

            if let batteryLevel, batteryLevel < 0.1 {
                return .lowBattery
            }

            if isLowPowerMode == true {
                return .lowPowerMode
            }

            if !isMainAppAndActive, !isMainAppAndActiveOverride {
                return .appBackgrounded
            }

            if let lastNetworkOr5xxErrorTime {
                let restartTime = BackupAttachmentDownloadQueueStatusManagerImpl.queueRestartTimeAfterNetworkError(
                    at: lastNetworkOr5xxErrorTime,
                    failureCount: networkOr5xxErrorCount,
                )
                if dateProvider() <= restartTime {
                    return .noReachability
                }
            }

            return .running
        }
    }

    private var state: State {
        didSet {
            fullsizeQueueStatus = state.asQueueStatus(mode: .fullsize, dateProvider: dateProvider)
            thumbnailQueueStatus = state.asQueueStatus(mode: .thumbnail, dateProvider: dateProvider)
        }
    }

    private var fullsizeQueueStatus: BackupAttachmentDownloadQueueStatus {
        didSet {
            if oldValue != fullsizeQueueStatus {
                notifyStatusDidChange(for: .fullsize)
            }
        }
    }

    private var thumbnailQueueStatus: BackupAttachmentDownloadQueueStatus {
        didSet {
            if oldValue != thumbnailQueueStatus {
                notifyStatusDidChange(for: .thumbnail)
            }
        }
    }

    private func notifyStatusDidChange(for mode: BackupAttachmentDownloadQueueMode) {
        NotificationCenter.default.postOnMainThread(
            name: .backupAttachmentDownloadQueueStatusDidChange(mode: mode),
            object: nil,
        )
    }

    // MARK: State Observation

    private func observeDeviceAndLocalStatesIfNecessary() {
        // For change logic, treat nil as empty (if nil, observation is unstarted)
        let wasQueueEmpty: Bool
        if
            let wasFullsizeQueueEmpty = state.isFullsizeQueueEmpty,
            let wasThumbnailQueueEmpty = state.isThumbnailQueueEmpty
        {
            wasQueueEmpty = wasFullsizeQueueEmpty && wasThumbnailQueueEmpty
        } else {
            wasQueueEmpty = true
        }

        let (
            isFullsizeQueueEmpty,
            isThumbnailQueueEmpty,
            areDownloadsSuspended,
        ) = db.read { tx in
            return (
                !backupAttachmentDownloadStore.hasAnyReadyDownloads(
                    isThumbnail: false,
                    tx: tx,
                ),
                !backupAttachmentDownloadStore.hasAnyReadyDownloads(
                    isThumbnail: true,
                    tx: tx,
                ),
                backupSettingsStore.isBackupAttachmentDownloadQueueSuspended(tx: tx),
            )

        }
        state.isFullsizeQueueEmpty = isFullsizeQueueEmpty
        state.isThumbnailQueueEmpty = isThumbnailQueueEmpty
        state.areDownloadsSuspended = areDownloadsSuspended

        let isQueueEmpty = isFullsizeQueueEmpty && isThumbnailQueueEmpty

        // Only observe if the queue is non-empty, so as to not waste resources;
        // for example, by telling the OS we want battery level updates.
        if isQueueEmpty, !wasQueueEmpty {
            stopObservingDeviceAndLocalStates()
        } else if !isQueueEmpty, wasQueueEmpty {
            observeDeviceAndLocalStates()
        }
    }

    private func observeDeviceAndLocalStates() {
        let (isRegistered, shouldAllowBackupDownloadsOnCellular) = db.read { tx in
            return (
                tsAccountManager.registrationState(tx: tx).isRegistered,
                backupSettingsStore.shouldAllowBackupDownloadsOnCellular(tx: tx),
            )
        }

        let notificationsToObserve: [(Notification.Name, Selector)] = [
            (.registrationStateDidChange, #selector(registrationStateDidChange)),
            (SSKReachability.owsReachabilityDidChange, #selector(reachabilityDidChange)),
            (.batteryLevelChanged, #selector(batteryLevelDidChange)),
            (.batteryLowPowerModeChanged, #selector(lowPowerModeDidChange)),
            (.OWSApplicationWillEnterForeground, #selector(willEnterForeground)),
            (.backupAttachmentDownloadQueueSuspensionStatusDidChange, #selector(suspensionStatusDidChange)),
            (.shouldAllowBackupDownloadsOnCellularChanged, #selector(shouldAllowBackupDownloadsOnCellularDidChange)),
            (.OWSApplicationDidEnterBackground, #selector(isMainAppAndActiveDidChange)),
            (.OWSApplicationDidBecomeActive, #selector(isMainAppAndActiveDidChange)),
        ]
        for (name, selector) in notificationsToObserve {
            NotificationCenter.default.addObserver(
                self,
                selector: selector,
                name: name,
                object: nil,
            )
        }

        // Don't worry about this changing during an app lifetime; just check it once up front.
        let requiredDiskSpace = getRequiredDiskSpace()

        self.batteryLevelMonitor = deviceBatteryLevelManager?.beginMonitoring(reason: "BackupDownloadQueue")
        self.state = State(
            isFullsizeQueueEmpty: state.isFullsizeQueueEmpty,
            isThumbnailQueueEmpty: state.isThumbnailQueueEmpty,
            areDownloadsSuspended: state.areDownloadsSuspended,
            isMainApp: appContext.isMainApp,
            isAppReady: appReadiness.isAppReady,
            isRegistered: isRegistered,
            shouldAllowBackupDownloadsOnCellular: shouldAllowBackupDownloadsOnCellular,
            isWifiReachable: reachabilityManager.isReachable(via: .wifi),
            isReachable: reachabilityManager.isReachable(via: .any),
            batteryLevel: batteryLevelMonitor?.batteryLevel,
            isLowPowerMode: deviceBatteryLevelManager?.isLowPowerModeEnabled,
            availableDiskSpace: getAvailableDiskSpace(),
            requiredDiskSpace: requiredDiskSpace,
            downloadDidExperienceOutOfSpaceError: state.downloadDidExperienceOutOfSpaceError,
            isMainAppAndActive: appContext.isMainAppAndActive,
        )
    }

    private func stopObservingDeviceAndLocalStates() {
        NotificationCenter.default.removeObserver(self)
        batteryLevelMonitor.map { deviceBatteryLevelManager?.endMonitoring($0) }
    }

    // MARK: Per state changes

    private func appReadinessDidChange() {
        state.isAppReady = appReadiness.isAppReady
    }

    @objc
    private func registrationStateDidChange() {
        state.isRegistered = db.read { tx in
            tsAccountManager.registrationState(tx: tx).isRegistered
        }
    }

    @objc
    private func reachabilityDidChange() {
        let isWifiReachable = reachabilityManager.isReachable(via: .wifi)
        let isReachable = reachabilityManager.isReachable(via: .any)

        state.isWifiReachable = isWifiReachable
        state.isReachable = isReachable

        if isWifiReachable, state.shouldAllowBackupDownloadsOnCellular == true {
            // We were temporarily doing downloads over cellular, but now we
            // have WiFi and shouldn't keep allowing cellular.
            Task {
                await db.awaitableWrite { tx in
                    backupSettingsStore.setShouldAllowBackupDownloadsOnCellular(false, tx: tx)
                }
            }
        }
    }

    @objc
    private func batteryLevelDidChange() {
        state.batteryLevel = batteryLevelMonitor?.batteryLevel
    }

    @objc
    private func lowPowerModeDidChange() {
        state.isLowPowerMode = deviceBatteryLevelManager?.isLowPowerModeEnabled
    }

    @objc
    private func suspensionStatusDidChange() {
        state.areDownloadsSuspended = db.read { tx in
            backupSettingsStore.isBackupAttachmentDownloadQueueSuspended(tx: tx)
        }
    }

    @objc
    private func shouldAllowBackupDownloadsOnCellularDidChange() {
        state.shouldAllowBackupDownloadsOnCellular = db.read { tx in
            backupSettingsStore.shouldAllowBackupDownloadsOnCellular(tx: tx)
        }
    }

    private nonisolated func getAvailableDiskSpace() -> UInt64? {
        do {
            OWSFileSystem.ensureDirectoryExists(AttachmentStream.attachmentsDirectory().path)
            return try OWSFileSystem.freeSpaceInBytes(
                forPath: AttachmentStream.attachmentsDirectory(),
            )
        } catch {
            owsFailDebug("Unable to determine disk space \(error)")
            return nil
        }
    }

    private nonisolated func getRequiredDiskSpace() -> UInt64 {
        return 500_000_000
    }

    @objc
    private func willEnterForeground() {
        // The user may have freed up disk space while we were backgrounded; use
        // this as a trigger to check so the download queue behaves accordingly.
        checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: false)
    }

    private func downloadDidExperienceOutOfSpaceError(mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus {
        // We track the error independent of fullsize vs thumbnail
        state.downloadDidExperienceOutOfSpaceError = true
        return state.asQueueStatus(mode: mode, dateProvider: dateProvider)
    }

    private class BackupAttachmentDownloadQueueStatusTokenImpl: BackupAttachmentDownloadQueueStatusToken {
        let lastNetworkOr5xxErrorTime: Date?

        init(lastNetworkOr5xxErrorTime: Date?) {
            self.lastNetworkOr5xxErrorTime = lastNetworkOr5xxErrorTime
        }
    }

    @objc
    private func isMainAppAndActiveDidChange() {
        self.state.isMainAppAndActive = appContext.isMainAppAndActive
    }

    private nonisolated static func queueRestartTimeAfterNetworkError(
        at errorDate: Date,
        failureCount: Int,
    ) -> Date {
        let delay = OWSOperation.retryIntervalForExponentialBackoff(
            failureCount: failureCount,
            minAverageBackoff: 1,
            maxAverageBackoff: .day * 5,
        )
        return errorDate.addingTimeInterval(delay)
    }

    private func downloadDidExperienceNetworkOr5xxError(
        mode: BackupAttachmentDownloadQueueMode,
        token: BackupAttachmentDownloadQueueStatusToken,
    ) -> BackupAttachmentDownloadQueueStatus {
        guard
            let token = token as? BackupAttachmentDownloadQueueStatusTokenImpl,
            state.lastNetworkOr5xxErrorTime == token.lastNetworkOr5xxErrorTime
        else {
            return state.asQueueStatus(mode: mode, dateProvider: dateProvider)
        }
        let failureCount = state.networkOr5xxErrorCount
        let errorDate = dateProvider()
        let restartDate = Self.queueRestartTimeAfterNetworkError(
            at: errorDate,
            failureCount: failureCount,
        )
        state.networkOr5xxErrorCount = failureCount + 1
        state.lastNetworkOr5xxErrorTime = errorDate
        if restartDate > dateProvider() {
            Task { [weak self, dateProvider] in
                let now = dateProvider()
                if restartDate > now {
                    try await Task.sleep(nanoseconds: NSEC_PER_SEC * UInt64(restartDate.timeIntervalSince(now)))
                }
                self?.didReachNetworkErrorRetryTime(token: BackupAttachmentDownloadQueueStatusTokenImpl(lastNetworkOr5xxErrorTime: errorDate))
            }
        }
        return state.asQueueStatus(mode: mode, dateProvider: dateProvider)
    }

    private func didReachNetworkErrorRetryTime(token: BackupAttachmentDownloadQueueStatusToken) {
        guard
            let token = token as? BackupAttachmentDownloadQueueStatusTokenImpl,
            state.lastNetworkOr5xxErrorTime == token.lastNetworkOr5xxErrorTime
        else {
            return
        }
        state.lastNetworkOr5xxErrorTime = nil
    }

    private func resetNetworkErrorRetriesAfterSuccess(token: BackupAttachmentDownloadQueueStatusToken) {
        guard
            let token = token as? BackupAttachmentDownloadQueueStatusTokenImpl,
            state.lastNetworkOr5xxErrorTime == token.lastNetworkOr5xxErrorTime
        else {
            return
        }
        state.lastNetworkOr5xxErrorTime = nil
        state.networkOr5xxErrorCount = 0
    }
}

// MARK: -

#if TESTABLE_BUILD

class MockBackupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager {
    struct BackupAttachmentDownloadQueueStatusTokenMock: BackupAttachmentDownloadQueueStatusToken {}

    var currentStatusMock: BackupAttachmentDownloadQueueStatus?
    func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus {
        currentStatusMock ?? .empty
    }

    func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (BackupAttachmentDownloadQueueStatus, BackupAttachmentDownloadQueueStatusToken) {
        (currentStatusMock ?? .empty, BackupAttachmentDownloadQueueStatusTokenMock())
    }

    func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 {
        0
    }

    func checkAvailableDiskSpace(clearPreviousOutOfSpaceErrors: Bool) {
        // Nothing
    }

    func beginObservingIfNecessary(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus {
        return currentStatus(for: mode)
    }

    func jobDidExperienceError(
        _ error: any Error,
        token: BackupAttachmentDownloadQueueStatusToken,
        mode: BackupAttachmentDownloadQueueMode,
    ) async -> BackupAttachmentDownloadQueueStatus? {
        return nil
    }

    func jobDidSucceed(
        token: BackupAttachmentDownloadQueueStatusToken,
        mode: BackupAttachmentDownloadQueueMode,
    ) async {
        // Nothing
    }

    func didEmptyQueue(for mode: BackupAttachmentDownloadQueueMode) {
        // Nothing
    }

    func setIsMainAppAndActiveOverride(_ newValue: Bool) {}
}

#endif