Signal-iOS/Signal/Provisioning/UserInterface/LinkAndSyncProvisioningProgressViewController.swift

342 lines
12 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
import SwiftUI
import SignalUI
import SignalServiceKit
// 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 progressSourceLabel: String?
#endif
var progress: Float {
didTapCancel ? 0 : taskProgress
}
func updateProgress(_ progress: OWSProgress) {
objectWillChange.send()
#if DEBUG
progressSourceLabel = progress.sourceProgresses
.lazy
.filter(\.value.isFinished.negated)
.filter({ $0.value.completedUnitCount > 0 })
.max(by: { $0.value.percentComplete < $1.value.percentComplete })?
.key
?? progressSourceLabel
#endif
let canBeCancelled = progress
.sourceProgresses[SecondaryLinkNSyncProgressPhase.waitingForBackup.rawValue]?
.isFinished
?? false
guard !didTapCancel else { return }
self.isIndeterminate = progress
.sourceProgresses[SecondaryLinkNSyncProgressPhase.waitingForBackup.rawValue]?
.isFinished.negated
?? true
if
let downloadSource = progress.sourceProgresses[AttachmentDownloads.downloadProgressLabel],
downloadSource.completedUnitCount > 0,
!downloadSource.isFinished
{
self.downloadProgress = (downloadSource.totalUnitCount, downloadSource.completedUnitCount)
} else {
self.downloadProgress = nil
}
self.isFinalizing = {
for phase in SecondaryLinkNSyncProgressPhase.allCases {
let progresses = progress.sourceProgresses.values.lazy
.filter({ $0.labels.contains(phase.rawValue) })
if progresses.contains(where: \.isFinished.negated) || progresses.isEmpty {
return false
}
}
return true
}()
withAnimation(.smooth) {
self.taskProgress = progress.percentComplete
}
self.canBeCancelled = canBeCancelled
}
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: HostingController<LinkAndSyncProvisioningProgressView> {
fileprivate var viewModel: LinkAndSyncSecondaryProgressViewModel
var linkNSyncTask: Task<Void, Error>? {
get { viewModel.linkNSyncTask }
set {
viewModel.linkNSyncTask = newValue
viewModel.didTapCancel = newValue?.isCancelled ?? false
}
}
init(viewModel: LinkAndSyncSecondaryProgressViewModel) {
self.viewModel = viewModel
super.init(wrappedView: LinkAndSyncProvisioningProgressView(viewModel: viewModel))
self.modalPresentationStyle = .fullScreen
self.modalTransitionStyle = .crossDissolve
}
}
// 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])
}
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(
format: 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(.percent.precision(.fractionLength(0)))
)
} else if !viewModel.isFinalizing {
String(
format: 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(.percent.precision(.fractionLength(0)))
)
} 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.progressSourceLabel ?? "none") + "\n\(viewModel.taskProgress)")
.padding(.top)
.foregroundStyle(Color.Signal.quaternaryLabel)
.animation(.none, value: viewModel.progressSourceLabel)
.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)
.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) {
UIApplication.shared.open(URL(string: "https://support.signal.org/hc/articles/360007320551")!)
}
.font(.footnote)
.frame(maxWidth: 412)
}
.foregroundStyle(Color.Signal.secondaryLabel)
}
.tint(Color.Signal.accent)
.padding()
.multilineTextAlignment(.center)
}
// MARK: Linear Progress View
private struct LinearProgressView: View {
var progress: Float
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.foregroundStyle(Color.Signal.secondaryFill)
Capsule()
.foregroundStyle(Color.Signal.accent)
.frame(width: geo.size.width * CGFloat(progress))
}
}
.frame(width: 360, height: 4)
}
}
}
// MARK: Previews
#if DEBUG
@available(iOS 17, *)
#Preview {
let view = LinkAndSyncProvisioningProgressViewController(viewModel: LinkAndSyncSecondaryProgressViewModel())
let progressSink = OWSProgress.createSink { progress in
Task { @MainActor in
view.viewModel.updateProgress(progress)
}
}
let task = Task { @MainActor in
let linkNSyncProgress = await progressSink.addChild(
withLabel: LocalizationNotNeeded("Link'n'sync"),
unitCount: 99
)
let postLinkNSyncProgress = await progressSink.addSource(
withLabel: LocalizationNotNeeded("Post-link'n'sync"),
unitCount: 1
)
let nonCancellableProgressSource = await linkNSyncProgress.addSource(
withLabel: SecondaryLinkNSyncProgressPhase.waitingForBackup.rawValue,
unitCount: 10
)
let downloadBackupSink = await linkNSyncProgress.addChild(
withLabel: SecondaryLinkNSyncProgressPhase.downloadingBackup.rawValue,
unitCount: 90
)
let download = await downloadBackupSink.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))
}
postLinkNSyncProgress.incrementCompletedUnitCount(by: 1)
}
view.linkNSyncTask = Task {
try? await task.value
}
return view
}
#endif