Path: blob/main/Signal/Provisioning/UserInterface/LinkAndSyncProvisioningProgressViewController.swift
1 views
//
// 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<Void, Error>?
@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<SecondaryLinkNSyncProgressPhase>) {
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.negated
?? true
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<Void, Error>) {
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<Void, Error>? {
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<SecondaryLinkNSyncProgressPhase>.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