Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/AppSettings/Linked Devices/BackupRestoreProgressModal.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Lottie
import SignalServiceKit
import SignalUI
import SwiftUI

// MARK: View Model

class BackupRestoreProgressViewModel: ObservableObject {

    @Published var didTapCancel: Bool = false
    @Published var taskProgress: Float = 0
    @Published var isIndeterminate = true
    @Published var canBeCancelled: Bool = false
    @Published var backupTask: Task<Void, Never>?
    @Published var downloadProgress: (totalByteCount: UInt64, downloadedByteCount: UInt64)?

#if DEBUG
    @Published var progressSourceLabel: String?
#endif

    var cancelButtonEnabled: Bool {
        backupTask != nil && canBeCancelled && !didTapCancel
    }

    var progress: Float {
        didTapCancel ? 0 : taskProgress
    }

    private var waitForLinkingTimeoutTimer: Timer?
    private var didTimeoutWaitForLinking = false

    fileprivate func updateProgress(progress: Float, canBeCancelled: Bool) {
        withAnimation(.smooth) {
            self.taskProgress = progress
        }
        self.canBeCancelled = canBeCancelled
    }

    func updateBackupRestoreProgress(progress: OWSSequentialProgress<BackupRestoreProgressPhase>) {
        // This seems to help with the Lottie bug mentioned below
        objectWillChange.send()

        self.isIndeterminate = progress.completedUnitCount == 0

        updateProgress(
            progress: progress.percentComplete,
            canBeCancelled: false,
        )

#if DEBUG
        progressSourceLabel = progress.currentStep.rawValue
#endif

        if
            let downloadSource = progress.progressForChild(label: AttachmentDownloads.downloadProgressLabel),
            downloadSource.completedUnitCount > 0,
            !downloadSource.isFinished
        {
            self.downloadProgress = (downloadSource.totalUnitCount, downloadSource.completedUnitCount)
        } else {
            self.downloadProgress = nil
        }
    }

    func updatePrimaryLinkingProgress(progress: OWSSequentialProgress<PrimaryLinkNSyncProgressPhase>) {
        // This seems to help with the Lottie bug mentioned below
        objectWillChange.send()

        let canBeCancelled: Bool
        if didTimeoutWaitForLinking {
            // If enough time has passed, allow cancelling
            // regardless of state.
            canBeCancelled = true
        } else {
            canBeCancelled = progress
                .progress(for: .waitingForLinking)?
                .isFinished
                ?? false
        }

        if let waitingForLinking = progress.progress(for: .waitingForLinking) {
            self.isIndeterminate = !waitingForLinking.isFinished
        } else {
            self.isIndeterminate = false
        }

        updateProgress(
            progress: progress.percentComplete,
            canBeCancelled: canBeCancelled,
        )

        if canBeCancelled {
            waitForLinkingTimeoutTimer?.invalidate()
            waitForLinkingTimeoutTimer = nil
        } else if !(waitForLinkingTimeoutTimer?.isValid ?? false) {
            waitForLinkingTimeoutTimer = Timer.scheduledTimer(
                withTimeInterval: 60,
                repeats: false,
            ) { [weak self] _ in
                self?.didTimeoutWaitForLinking = true
                self?.canBeCancelled = true
            }
        }

#if DEBUG
        progressSourceLabel = progress.currentStep.rawValue
#endif

        self.downloadProgress = nil
    }

    func cancel() {
        backupTask?.cancel()
        withAnimation(.smooth(duration: 0.2)) {
            didTapCancel = true
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
            self?.isIndeterminate = true
        }
    }
}

// MARK: Hosting Controller

class BackupRestoreProgressModal: HostingController<BackupRestoreProgressView>, LinkAndSyncProgressUI {

    var shouldSuppressNotifications: Bool { true }

    let viewModel = BackupRestoreProgressViewModel()

    var backupTask: Task<Void, Never>? {
        get { viewModel.backupTask }
        set { viewModel.backupTask = newValue }
    }

    init(style: BackupRestoreProgressView.Style) {
        super.init(wrappedView: BackupRestoreProgressView(
            style: style,
            viewModel: viewModel,
        ))

        self.modalPresentationStyle = .overFullScreen
        self.modalTransitionStyle = .crossDissolve

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appDidBackground),
            name: .OWSApplicationDidEnterBackground,
            object: nil,
        )
    }

    @objc
    func appDidBackground() {
        guard
            viewModel.canBeCancelled,
            viewModel.backupTask != nil
        else {
            return
        }
        Logger.error("Backgrounded app while link'n'syncing")
        viewModel.cancel()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .Signal.backdrop
    }

    @MainActor
    func completeAndDismiss() async {
        viewModel.updateProgress(progress: 1, canBeCancelled: false)
        try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
        await withCheckedContinuation { continuation in
            dismiss(animated: true) {
                continuation.resume()
            }
        }
    }
}

// MARK: SwiftUI View

struct BackupRestoreProgressView: View {
    @Environment(\.appearanceTransitionState) private var appearanceTransitionState

    enum Style {
        case linkAndSync
        case backupRestore
    }

    fileprivate var style: Style
    @ObservedObject fileprivate var viewModel: BackupRestoreProgressViewModel

    @State private var indeterminateProgressIsPlaying = false
    private var loopMode: LottieLoopMode {
        viewModel.isIndeterminate ? .loop : .playOnce
    }

    private var indeterminateProgressShouldShow: Bool {
        // We want to wait for the indeterminate spinner animation to finish
        // before the actual progress is shown to make it look smoother, but if
        // the progress finishes entirely before that, immediately show 100%.
        indeterminateProgressIsPlaying && viewModel.progress < 1
    }

    // If the first portion fills very quickly before the view is visible,
    // we still want to animate it from 0.
    private var progressToShow: Float {
        switch appearanceTransitionState {
        case .appearing:
            0
        case .cancelled, .finished, .none:
            indeterminateProgressShouldShow ? 0 : viewModel.progress
        }
    }

    private var byteCountFormat: ByteCountFormatStyle {
        .byteCount(style: .decimal, allowedUnits: [.mb, .gb], spellsOutZero: false)
    }

    private var progressString: String {
        switch style {
        case .linkAndSync:
            percentCompleteString
        case .backupRestore:
            if progressToShow.isZero {
                OWSLocalizedString(
                    "BACKUP_RESTORE_MODAL_PREPARING_SUBTITLE",
                    comment: "Subtitle for a progress spinner on a modal when waiting for a backup restore to start",
                )
            } else if let downloadProgress = viewModel.downloadProgress {
                String.nonPluralLocalizedStringWithFormat(
                    OWSLocalizedString(
                        "BACKUP_RESTORE_MODAL_DOWNLOAD_PROGRESS_SUBTITLE",
                        comment: "Subtitle for a progress spinner on a modal tracking active downloading. Embeds 1:{{ the amount downloaded as a file size, e.g. 100 MB }}; 2:{{ the total amount to download as a file size, e.g. 1 GB }}; 3:{{ the amount downloaded as a percentage, e.g. 10% }}.",
                    ),
                    downloadProgress.downloadedByteCount.formatted(byteCountFormat),
                    downloadProgress.totalByteCount.formatted(byteCountFormat),
                    progressToShow.formatted(.owsPercent()),
                )
            } else {
                percentCompleteString
            }
        }
    }

    private var percentCompleteString: String {
        String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_PROGRESS_PERCENT",
                comment: "On a progress modal indicating the percent complete the sync process is. Embeds {{ formatted percentage }}",
            ),
            progressToShow.formatted(.owsPercent()),
        )
    }

    private var showIndeterminateProgress: Bool {
        switch appearanceTransitionState {
        case .none, .appearing, .cancelled:
            false
        case .finished:
            viewModel.isIndeterminate || indeterminateProgressShouldShow
        }
    }

    private var title: String {
        switch style {
        case .linkAndSync:
            linkAndSyncTitle
        case .backupRestore:
            OWSLocalizedString(
                "BACKUP_RESTORE_MODAL_TITLE",
                comment: "Title for a progress spinner on a modal when restoring messages",
            )
        }
    }

    private var linkAndSyncTitle: String {
        if viewModel.didTapCancel {
            OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_PROGRESS_TILE_CANCELLING",
                comment: "Title for a progress modal that would be indicating the sync progress while it's cancelling that sync",
            )
        } else if indeterminateProgressShouldShow || appearanceTransitionState != .finished {
            OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE_PREPARING",
                comment: "Title for a progress modal indicating the sync progress while it's preparing for upload",
            )
        } else {
            OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_PROGRESS_TITLE",
                comment: "Title for a progress modal indicating the sync progress",
            )
        }
    }

    var body: some View {
        switch style {
        case .linkAndSync:
            progressView
                .frame(maxWidth: .infinity)
                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 32))
                .padding(.horizontal, 60)
        case .backupRestore:
            progressView
                .frame(maxWidth: .infinity)
                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 32))
                .padding(.horizontal, 68)
                .padding(.vertical, 44)
        }
    }

    var progressView: some View {
        VStack(spacing: 0) {
            ZStack {
                CircleProgressView(progress: progressToShow)
                    .animation(.smooth, value: appearanceTransitionState)
                    .animation(.smooth, value: indeterminateProgressShouldShow)

                if showIndeterminateProgress {
                    LottieView(animation: .named("circular_indeterminate"))
                        .playing(loopMode: loopMode)
                        .animationDidFinish { completed in
                            guard completed else { return }
                            indeterminateProgressIsPlaying = false
                        }
                        .onAppear {
                            indeterminateProgressIsPlaying = true
                        }
                }
            }
            .padding(.top, 12)
            .padding(.bottom, 20)
            .onChange(of: viewModel.isIndeterminate) { isIndeterminate in
                guard !isIndeterminate else { return }
                // There is a seemingly rng bug where the Lottie
                // view doesn't properly respond to the change of
                // loopMode, leading to .animationDidFinish never
                // being called. The animation is a bit over one
                // second, so if it's not done after two seconds,
                // force hide it.
                DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                    self.indeterminateProgressIsPlaying = false
                }
            }

            Text(title)
                .font(.headline)
                .padding(.bottom, 8)
                .animation(.none, value: title)

            Text(progressString)
                .font(.subheadline.monospacedDigit())
                .animation(.none, value: viewModel.progress)
                .padding(.bottom, 2)

            if style == .linkAndSync {
                Text(OWSLocalizedString(
                    "LINK_NEW_DEVICE_SYNC_PROGRESS_DO_NOT_CLOSE_APP",
                    comment: "On a progress modal",
                ))
                .font(.subheadline)
                .foregroundStyle(Color.Signal.secondaryLabel)
                .padding(.bottom, 36)

                Button(CommonStrings.cancelButton) {
                    viewModel.cancel()
                }
                .disabled(!viewModel.cancelButtonEnabled)
                .font(.body.weight(.semibold))
            }

#if DEBUG
            Text("DEBUG: " + (viewModel.progressSourceLabel ?? "none"))
                .padding(.top)
                .foregroundStyle(Color.Signal.quaternaryLabel)
                .animation(.none, value: viewModel.progressSourceLabel)
#endif
        }
        .padding(.horizontal, 26)
        .padding(.vertical, 28)
    }

    private struct CircleProgressView: View {
        var progress: Float

        var body: some View {
            ZStack {
                Circle()
                    .stroke(lineWidth: 4)
                    .foregroundStyle(Color.Signal.tertiaryLabel)

                Circle()
                    .trim(from: 0, to: CGFloat(progress))
                    .rotation(.degrees(-90))
                    .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
                    .foregroundStyle(Color.Signal.accent)
            }
            .frame(width: 48, height: 48)
            .padding(2)
        }
    }
}

// MARK: Previews

#if DEBUG

@available(iOS 17, *)
func simulateProgress(for source: OWSProgressSource) async throws {
    for _ in 0..<(source.totalUnitCount / 2) {
        source.incrementCompletedUnitCount(by: 2)
        try await Task.sleep(for: .milliseconds(50))
    }

    source.incrementCompletedUnitCount(by: source.totalUnitCount)
}

@MainActor
@available(iOS 17, *)
private func setupDemoProgressBackupRestore(
    modal: BackupRestoreProgressModal,
    instantComplete: Bool,
) async throws {
    let progress = await OWSSequentialProgress<BackupRestoreProgressPhase>.createSink { progress in
        modal.viewModel.updateBackupRestoreProgress(progress: progress)
    }

    let download = await progress.child(for: .downloadingBackup)
        .addSource(withLabel: "download", unitCount: 10_000_000)

    let importingBackupProgress = await progress.child(for: .importingBackup).addSource(
        withLabel: BackupRestoreProgressPhase.importingBackup.rawValue,
        unitCount: BackupRestoreProgressPhase.importingBackup.progressUnitCount,
    )

    let finishingProgress = await progress.child(for: .finishing).addSource(
        withLabel: BackupRestoreProgressPhase.finishing.rawValue,
        unitCount: BackupRestoreProgressPhase.finishing.progressUnitCount,
    )

    try await Task.sleep(for: .milliseconds(700))

    if instantComplete {
        try await Task.sleep(for: .seconds(1))
        await modal.completeAndDismiss()
        return
    }

    while download.completedUnitCount < 10_000_000 {
        download.incrementCompletedUnitCount(by: 100_000)
        try await Task.sleep(for: .milliseconds(100))
    }

    try await simulateProgress(for: importingBackupProgress)

    try await Task.sleep(for: .milliseconds(500))

    finishingProgress.incrementCompletedUnitCount(by: BackupRestoreProgressPhase.finishing.progressUnitCount)

    await modal.completeAndDismiss()
}

@MainActor
@available(iOS 17, *)
private func setupDemoProgress(
    modal: BackupRestoreProgressModal,
    slowLinking: Bool,
) async throws {
    let progress = await OWSSequentialProgress<PrimaryLinkNSyncProgressPhase>.createSink { progress in
        modal.viewModel.updatePrimaryLinkingProgress(progress: progress)
    }

    let waitForLinkingProgress = await progress.child(for: .waitingForLinking).addSource(
        withLabel: PrimaryLinkNSyncProgressPhase.waitingForLinking.rawValue,
        unitCount: PrimaryLinkNSyncProgressPhase.waitingForLinking.progressUnitCount,
    )
    let exportingBackupProgress = await progress.child(for: .exportingBackup).addSource(
        withLabel: PrimaryLinkNSyncProgressPhase.exportingBackup.rawValue,
        unitCount: PrimaryLinkNSyncProgressPhase.exportingBackup.progressUnitCount,
    )
    let uploadingBackupProgress = await progress.child(for: .uploadingBackup).addSource(
        withLabel: PrimaryLinkNSyncProgressPhase.uploadingBackup.rawValue,
        unitCount: PrimaryLinkNSyncProgressPhase.uploadingBackup.progressUnitCount,
    )
    let markUploadedProgress = await progress.child(for: .finishing).addSource(
        withLabel: PrimaryLinkNSyncProgressPhase.finishing.rawValue,
        unitCount: PrimaryLinkNSyncProgressPhase.finishing.progressUnitCount,
    )

    if slowLinking {
        try await Task.sleep(for: .milliseconds(700))
    } else {
        try await Task.sleep(for: .milliseconds(100))
    }

    waitForLinkingProgress.incrementCompletedUnitCount(by: PrimaryLinkNSyncProgressPhase.waitingForLinking.progressUnitCount)

    if slowLinking {
        try await Task.sleep(for: .milliseconds(700))
    } else {
        try await Task.sleep(for: .milliseconds(100))
    }

    try await simulateProgress(for: exportingBackupProgress)
    try await simulateProgress(for: uploadingBackupProgress)

    try await Task.sleep(for: .milliseconds(500))

    try Task.checkCancellation()
    markUploadedProgress.incrementCompletedUnitCount(by: PrimaryLinkNSyncProgressPhase.finishing.progressUnitCount)

    await modal.completeAndDismiss()
}

@MainActor
@available(iOS 17, *)
func demoTask(
    modal: BackupRestoreProgressModal,
    slowLinking: Bool,
) -> Task<Void, Never> {
    Task {
        do {
            try await setupDemoProgress(
                modal: modal,
                slowLinking: slowLinking,
            )
        } catch {
            try? await Task.detached {
                try await Task.sleep(for: slowLinking ? .seconds(3) : .milliseconds(500))
            }.value
            modal.dismiss(animated: true)
        }
    }
}

@available(iOS 17, *)
#Preview("Slow linking") {
    SheetPreviewViewController(animateFirstAppearance: true) {
        let modal = BackupRestoreProgressModal(style: .linkAndSync)
        modal.backupTask = demoTask(modal: modal, slowLinking: true)
        return modal
    }
}

@available(iOS 17, *)
#Preview("Fast linking") {
    SheetPreviewViewController(animateFirstAppearance: true) {
        let modal = BackupRestoreProgressModal(style: .linkAndSync)
        modal.backupTask = demoTask(modal: modal, slowLinking: false)
        return modal
    }
}

@available(iOS 17, *)
#Preview("Backup restore") {
    SheetPreviewViewController(animateFirstAppearance: true) {
        let modal = BackupRestoreProgressModal(style: .backupRestore)
        modal.backupTask = Task {
            try? await setupDemoProgressBackupRestore(modal: modal, instantComplete: false)
        }
        return modal
    }
}

@available(iOS 17, *)
#Preview("Backup restore - instant complete") {
    SheetPreviewViewController(animateFirstAppearance: true) {
        let modal = BackupRestoreProgressModal(style: .backupRestore)
        modal.backupTask = Task {
            try? await setupDemoProgressBackupRestore(modal: modal, instantComplete: true)
        }
        return modal
    }
}
#endif