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

import SignalServiceKit

/// Manages async streams of `DownloadUpdate`s, which represent the state and
/// progress of Backup Attachment downloads.
///
/// - SeeAlso `BackupAttachmentDownloadQueueStatusManager`
/// - SeeAlso `BackupAttachmentDownloadProgress`
///
/// - SeeAlso ``BackupAttachmentUploadTracker``
final class BackupAttachmentDownloadTracker {
    struct DownloadUpdate: Equatable {
        enum State: Equatable {
            case empty
            case running
            case suspended
            case pausedLowBattery
            case pausedLowPowerMode
            case pausedNeedsWifi
            case pausedNeedsInternet
            case outOfDiskSpace(bytesRequired: UInt64)
            case notRegisteredAndReady
        }

        let state: State
        var bytesDownloaded: UInt64 { progress.completedUnitCount }
        var totalBytesToDownload: UInt64 { progress.totalUnitCount }
        var percentageDownloaded: Float { progress.percentComplete }

        private let progress: OWSProgress

        init(state: State, bytesDownloaded: UInt64, totalBytesToDownload: UInt64) {
            self.init(state: state, progress: OWSProgress(
                completedUnitCount: bytesDownloaded,
                totalUnitCount: totalBytesToDownload,
            ))
        }

        fileprivate init(state: State, progress: OWSProgress) {
            self.state = state
            self.progress = progress
        }

        static func ==(lhs: DownloadUpdate, rhs: DownloadUpdate) -> Bool {
            return lhs.state == rhs.state && lhs.percentageDownloaded == rhs.percentageDownloaded
        }
    }

    private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager
    private let backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress

    init(
        backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager,
        backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress,
    ) {
        self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager
        self.backupAttachmentDownloadProgress = backupAttachmentDownloadProgress
    }

    func updates() -> AsyncStream<DownloadUpdate> {
        return AsyncStream { continuation in
            let tracker = Tracker(
                backupAttachmentDownloadQueueStatusManager: backupAttachmentDownloadQueueStatusManager,
                backupAttachmentDownloadProgress: backupAttachmentDownloadProgress,
                continuation: continuation,
            )

            tracker.start()

            continuation.onTermination = { reason in
                switch reason {
                case .cancelled:
                    tracker.stop()
                case .finished:
                    owsFailDebug("How did we finish? We should've canceled first.")
                @unknown default:
                    owsFailDebug("Unexpected continuation termination reason: \(reason)")
                    tracker.stop()
                }
            }
        }
    }
}

// MARK: -

private class Tracker {
    typealias DownloadUpdate = BackupAttachmentDownloadTracker.DownloadUpdate

    private struct State {
        var lastReportedDownloadProgress: OWSProgress = .zero
        var lastReportedDownloadQueueStatus: BackupAttachmentDownloadQueueStatus?

        var downloadQueueStatusObserver: NotificationCenter.Observer?
        var downloadProgressObserver: BackupAttachmentDownloadProgress.Observer?

        let streamContinuation: AsyncStream<DownloadUpdate>.Continuation
    }

    private let backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager
    private let backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress
    private let state: SeriallyAccessedState<State>

    init(
        backupAttachmentDownloadQueueStatusManager: BackupAttachmentDownloadQueueStatusManager,
        backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress,
        continuation: AsyncStream<DownloadUpdate>.Continuation,
    ) {
        self.backupAttachmentDownloadQueueStatusManager = backupAttachmentDownloadQueueStatusManager
        self.backupAttachmentDownloadProgress = backupAttachmentDownloadProgress
        self.state = SeriallyAccessedState(State(streamContinuation: continuation))
    }

    func start() {
        state.enqueueUpdate { @MainActor [self] _state in
            let (downloadQueueStatusObserver, downloadQueueStatus) = observeDownloadQueueStatus()
            let downloadProgressObserver = await observeDownloadProgress()

            _state.downloadQueueStatusObserver = downloadQueueStatusObserver
            _state.lastReportedDownloadQueueStatus = downloadQueueStatus

            _state.downloadProgressObserver = downloadProgressObserver

            // We don't need to yield an "initial" update here, since the
            // download progress observer we just added will shortly be called
            // back with an initial value.
        }
    }

    func stop() {
        state.enqueueUpdate { [self] _state in
            if let downloadQueueStatusObserver = _state.downloadQueueStatusObserver {
                NotificationCenter.default.removeObserver(downloadQueueStatusObserver)
            }

            if let downloadProgressObserver = _state.downloadProgressObserver {
                await backupAttachmentDownloadProgress.removeObserver(downloadProgressObserver)
            }

            _state.streamContinuation.finish()
        }
    }

    // MARK: -

    @MainActor
    private func observeDownloadQueueStatus() -> (
        NotificationCenter.Observer,
        BackupAttachmentDownloadQueueStatus,
    ) {
        let observer = NotificationCenter.default.addObserver(
            name: .backupAttachmentDownloadQueueStatusDidChange(mode: .fullsize),
        ) { [weak self] _ in
            guard let self else { return }

            handleDownloadQueueStatusUpdate()
        }

        return (
            observer,
            backupAttachmentDownloadQueueStatusManager.beginObservingIfNecessary(for: .fullsize),
        )
    }

    @MainActor
    private func handleDownloadQueueStatusUpdate() {
        let queueStatus = backupAttachmentDownloadQueueStatusManager.currentStatus(for: .fullsize)

        state.enqueueUpdate { [self] _state in
            _state.lastReportedDownloadQueueStatus = queueStatus
            yieldCurrentDownloadUpdate(state: _state)
        }
    }

    // MARK: -

    private func observeDownloadProgress() async -> BackupAttachmentDownloadProgressObserver {
        return await backupAttachmentDownloadProgress.addObserver { [weak self] progressUpdate in
            guard let self else { return }

            handleDownloadProgressUpdate(progressUpdate)
        }
    }

    private func handleDownloadProgressUpdate(_ downloadProgress: OWSProgress) {
        state.enqueueUpdate { [self] _state in
            _state.lastReportedDownloadProgress = downloadProgress
            yieldCurrentDownloadUpdate(state: _state)
        }
    }

    // MARK: -

    private func yieldCurrentDownloadUpdate(state: State) {
        let streamContinuation = state.streamContinuation
        let lastReportedDownloadProgress = state.lastReportedDownloadProgress

        guard let lastReportedDownloadQueueStatus = state.lastReportedDownloadQueueStatus else {
            return
        }

        let downloadUpdateState: DownloadUpdate.State
        switch lastReportedDownloadQueueStatus {
        case .appBackgrounded:
            // Don't emit an update when the app is backgrounded, so callers are
            // left with the last update before backgrounding.
            return
        case .empty:
            downloadUpdateState = .empty
        case .running:
            downloadUpdateState = .running
        case .suspended:
            downloadUpdateState = .suspended
        case .noWifiReachability:
            downloadUpdateState = .pausedNeedsWifi
        case .noReachability:
            downloadUpdateState = .pausedNeedsInternet
        case .lowBattery:
            downloadUpdateState = .pausedLowBattery
        case .lowPowerMode:
            downloadUpdateState = .pausedLowPowerMode
        case .lowDiskSpace:
            downloadUpdateState = .outOfDiskSpace(bytesRequired: max(
                lastReportedDownloadProgress.remainingUnitCount,
                backupAttachmentDownloadQueueStatusManager.minimumRequiredDiskSpaceToCompleteDownloads(),
            ))
        case .notRegisteredAndReady:
            downloadUpdateState = .notRegisteredAndReady
        }

        streamContinuation.yield(DownloadUpdate(
            state: downloadUpdateState,
            progress: lastReportedDownloadProgress,
        ))
    }
}