// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Lottie import SafariServices import SignalServiceKit import SignalUI import SwiftUI // MARK: View Model class LinkAndSyncSecondaryProgressViewModel: ObservableObject { @Published private(set) var taskProgress: Float = 0 @Published private(set) var canBeCancelled: Bool = false @Published var isIndeterminate = true @Published var isFinalizing = false @Published var linkNSyncTask: Task? @Published var didTapCancel: Bool = false @Published var downloadProgress: (totalByteCount: UInt64, downloadedByteCount: UInt64)? #if DEBUG @Published var currentProgressStep: SecondaryLinkNSyncProgressPhase? #endif private var waitForBackupTimeoutTimer: Timer? private var didTimeoutWaitForBackup = false var progress: Float { didTapCancel ? 0 : taskProgress } func updateProgress(_ progress: OWSSequentialProgress) { objectWillChange.send() #if DEBUG currentProgressStep = progress.currentStep #endif let canBeCancelled: Bool if didTimeoutWaitForBackup { // If enough time has passed, allow cancelling // regardless of state. canBeCancelled = true } else { canBeCancelled = progress .progress(for: .waitingForBackup)? .isFinished ?? false } guard !didTapCancel else { return } self.isIndeterminate = !( progress .progress(for: .waitingForBackup)? .isFinished ?? false ) if let downloadSource = progress.progressForChild( label: AttachmentDownloads.downloadProgressLabel, ), downloadSource.completedUnitCount > 0, !downloadSource.isFinished { self.downloadProgress = (downloadSource.totalUnitCount, downloadSource.completedUnitCount) } else { self.downloadProgress = nil } self.isFinalizing = progress.isFinished withAnimation(.smooth) { // We leave a single % unfinished at the end, so it doesn't look // like we hit 100% and sit there while the UI flows to the next step. self.taskProgress = (progress.percentComplete) * 0.99 } self.canBeCancelled = canBeCancelled if canBeCancelled { waitForBackupTimeoutTimer?.invalidate() waitForBackupTimeoutTimer = nil } else if !(waitForBackupTimeoutTimer?.isValid ?? false) { waitForBackupTimeoutTimer = Timer.scheduledTimer( withTimeInterval: 60, repeats: false, ) { [weak self] _ in self?.didTimeoutWaitForBackup = true self?.canBeCancelled = true } } } func cancel(task: Task) { task.cancel() withAnimation(.smooth(duration: 0.2)) { didTapCancel = true } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self!.isIndeterminate = true } } } // MARK: Hosting Controller class LinkAndSyncProvisioningProgressViewController: ProvisioningBaseViewController, LinkAndSyncProgressUI { var shouldSuppressNotifications: Bool { true } fileprivate var viewModel: LinkAndSyncSecondaryProgressViewModel var linkNSyncTask: Task? { get { viewModel.linkNSyncTask } set { viewModel.linkNSyncTask = newValue viewModel.didTapCancel = newValue?.isCancelled ?? false } } init(provisioningController: ProvisioningController, viewModel: LinkAndSyncSecondaryProgressViewModel) { self.viewModel = viewModel super.init(provisioningController: provisioningController) modalPresentationStyle = .fullScreen modalTransitionStyle = .crossDissolve navigationItem.hidesBackButton = true NotificationCenter.default.addObserver( self, selector: #selector(appDidBackground), name: .OWSApplicationDidEnterBackground, object: nil, ) } override func viewDidLoad() { super.viewDidLoad() let linkAndSyncViewHostingContainer = HostingContainer(wrappedView: LinkAndSyncProvisioningProgressView(viewModel: viewModel)) addChild(linkAndSyncViewHostingContainer) view.addSubview(linkAndSyncViewHostingContainer.view) linkAndSyncViewHostingContainer.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ linkAndSyncViewHostingContainer.view.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), linkAndSyncViewHostingContainer.view.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor), linkAndSyncViewHostingContainer.view.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), linkAndSyncViewHostingContainer.view.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor), ]) linkAndSyncViewHostingContainer.didMove(toParent: self) } @objc func appDidBackground() { guard let linkNSyncTask = viewModel.linkNSyncTask else { return } Logger.error("Backgrounded app while link'n'syncing") viewModel.cancel(task: linkNSyncTask) Task { _ = try? await linkNSyncTask.value // Reset the whole app and force quit. If the user // exits in the middle of syncing we'll probably // crash anyway (dead10cc). SignalApp.shared.resetAppDataAndExit( keyFetcher: SSKEnvironment.shared.databaseStorageRef.keyFetcher, ) } } } // MARK: SwiftUI View struct LinkAndSyncProvisioningProgressView: View { @ObservedObject fileprivate var viewModel: LinkAndSyncSecondaryProgressViewModel @State private var indeterminateProgressShouldShow = false private var showIndeterminateProgress: Bool { viewModel.isIndeterminate || indeterminateProgressShouldShow } private var loopMode: LottieLoopMode { viewModel.isIndeterminate ? .loop : .playOnce } private var progressToShow: Float { indeterminateProgressShouldShow ? 0 : viewModel.progress } private var byteCountFormat: ByteCountFormatStyle { .byteCount(style: .decimal, allowedUnits: [.mb, .gb], spellsOutZero: false) } private var subtitle: 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, !viewModel.isFinalizing { OWSLocalizedString( "LINKING_SYNCING_PREPARING_TO_DOWNLOAD", comment: "Progress label when the message loading has not yet started during the device linking process", ) } else if let downloadProgress = viewModel.downloadProgress { String.nonPluralLocalizedStringWithFormat( OWSLocalizedString( "LINK_NEW_DEVICE_SYNC_DOWNLOAD_PROGRESS", comment: "Progress label showing the download progress of a linked device sync. Embeds {{ formatted downloaded size (such as megabytes), formatted total download size, formatted percentage }}", ), downloadProgress.downloadedByteCount.formatted(byteCountFormat), downloadProgress.totalByteCount.formatted(byteCountFormat), progressToShow.formatted(.owsPercent()), ) } else if !viewModel.isFinalizing { 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()), ) } else { OWSLocalizedString( "LINKING_SYNCING_FINALIZING", comment: "Progress label when the message loading has nearly completed during the device linking process", ) } } var body: some View { VStack(spacing: 0) { Spacer() Text(OWSLocalizedString( "LINKING_SYNCING_MESSAGES_TITLE", comment: "Title shown when loading messages during linking process", )) .font(.title2.bold()) .foregroundStyle(Color.Signal.label) .padding(.bottom, 24) ZStack { LinearProgressView(progress: progressToShow) .animation(.smooth, value: indeterminateProgressShouldShow) if showIndeterminateProgress { LottieView(animation: .named("linear_indeterminate")) .playing(loopMode: loopMode) .animationDidFinish { completed in guard completed else { return } indeterminateProgressShouldShow = false } .onAppear { indeterminateProgressShouldShow = true } } } .padding(.bottom, 12) .onChange(of: viewModel.isIndeterminate) { isIndeterimate in // See LinkAndSyncProgressModal.swift DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.indeterminateProgressShouldShow = false } } Group { Text(verbatim: subtitle) .font(.footnote.monospacedDigit()) .padding(.bottom, 22) .animation(.none, value: subtitle) Text(OWSLocalizedString( "LINKING_SYNCING_TIMING_INFO", comment: "Label below the progress bar when loading messages during linking process", )) .font(.subheadline) } .foregroundStyle(Color.Signal.secondaryLabel) #if DEBUG Text("DEBUG: " + (viewModel.currentProgressStep?.rawValue ?? "none") + "\n\(viewModel.taskProgress)") .padding(.top) .foregroundStyle(Color.Signal.quaternaryLabel) .animation(.none, value: viewModel.currentProgressStep) .animation(.none, value: viewModel.taskProgress) #endif Spacer() if let linkNSyncTask = viewModel.linkNSyncTask { Button(CommonStrings.cancelButton) { viewModel.cancel(task: linkNSyncTask) } .opacity(viewModel.canBeCancelled ? 1 : 0) .disabled(!viewModel.canBeCancelled || viewModel.didTapCancel) .buttonStyle(Registration.UI.MediumSecondaryButtonStyle()) .padding(.bottom, 56) } Group { SignalSymbol.lock.text(dynamicTypeBaseSize: 20) .padding(.bottom, 6) Text(OWSLocalizedString( "LINKING_SYNCING_FOOTER", comment: "Footer text when loading messages during linking process.", )) .appendLink(CommonStrings.learnMore) { let vc = SFSafariViewController(url: URL.Support.linkedDevices) CurrentAppContext().frontmostViewController()?.present(vc, animated: true) } .font(.footnote) } .foregroundStyle(Color.Signal.secondaryLabel) } .tint(Color.Signal.accent) .multilineTextAlignment(.center) } } // MARK: Previews #if DEBUG @available(iOS 17, *) #Preview { let view = LinkAndSyncProvisioningProgressView(viewModel: LinkAndSyncSecondaryProgressViewModel()) let task = Task { @MainActor in let progressSink = await OWSSequentialProgress.createSink { progress in await MainActor.run { view.viewModel.updateProgress(progress) } } let nonCancellableProgressSource = await progressSink.child(for: .waitingForBackup).addSource( withLabel: SecondaryLinkNSyncProgressPhase.waitingForBackup.rawValue, unitCount: 10, ) let download = await progressSink.child(for: .downloadingBackup) .addSource(withLabel: "download", unitCount: 10_000_000) try? await Task.sleep(for: .seconds(1)) while nonCancellableProgressSource.completedUnitCount < 10 { nonCancellableProgressSource.incrementCompletedUnitCount(by: 1) try? await Task.sleep(for: .milliseconds(100)) } while download.completedUnitCount < 10_000_000 { download.incrementCompletedUnitCount(by: 100_000) try await Task.sleep(for: .milliseconds(100)) } } view.viewModel.linkNSyncTask = Task { try? await task.value } return view } #endif