Signal-iOS/Signal/Backups/BackupSettingsViewController.swift
2025-08-15 20:08:48 -07:00

2424 lines
97 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
import SignalServiceKit
import SignalUI
import StoreKit
import SwiftUI
class BackupSettingsViewController:
HostingController<BackupSettingsView>,
BackupSettingsViewModel.ActionsDelegate
{
enum OnLoadAction {
case presentWelcomeToBackupsSheet
}
private let accountKeyStore: AccountKeyStore
private let backupAttachmentDownloadTracker: BackupSettingsAttachmentDownloadTracker
private let backupAttachmentUploadTracker: BackupSettingsAttachmentUploadTracker
private let backupDisablingManager: BackupDisablingManager
private let backupEnablingManager: BackupEnablingManager
private let backupExportJobRunner: BackupExportJobRunner
private let backupPlanManager: BackupPlanManager
private let backupSettingsStore: BackupSettingsStore
private let backupSubscriptionManager: BackupSubscriptionManager
private let db: DB
private let deviceSleepManager: DeviceSleepManager
private let svr: SecureValueRecovery
private let tsAccountManager: TSAccountManager
private let onLoadAction: OnLoadAction?
private let viewModel: BackupSettingsViewModel
private var externalEventObservationTasks: [Task<Void, Never>] = []
convenience init(
onLoadAction: OnLoadAction?,
) {
guard let deviceSleepManager = DependenciesBridge.shared.deviceSleepManager else {
owsFail("Unexpectedly missing DeviceSleepManager in main app!")
}
self.init(
onLoadAction: onLoadAction,
accountKeyStore: DependenciesBridge.shared.accountKeyStore,
backupAttachmentDownloadProgress: DependenciesBridge.shared.backupAttachmentDownloadProgress,
backupAttachmentDownloadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusReporter,
backupAttachmentUploadProgress: DependenciesBridge.shared.backupAttachmentUploadProgress,
backupAttachmentUploadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentUploadQueueStatusReporter,
backupDisablingManager: DependenciesBridge.shared.backupDisablingManager,
backupEnablingManager: AppEnvironment.shared.backupEnablingManager,
backupExportJobRunner: DependenciesBridge.shared.backupExportJobRunner,
backupPlanManager: DependenciesBridge.shared.backupPlanManager,
backupSettingsStore: BackupSettingsStore(),
backupSubscriptionManager: DependenciesBridge.shared.backupSubscriptionManager,
db: DependenciesBridge.shared.db,
deviceSleepManager: deviceSleepManager,
svr: DependenciesBridge.shared.svr,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
)
}
init(
onLoadAction: OnLoadAction?,
accountKeyStore: AccountKeyStore,
backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress,
backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter,
backupAttachmentUploadProgress: BackupAttachmentUploadProgress,
backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter,
backupDisablingManager: BackupDisablingManager,
backupEnablingManager: BackupEnablingManager,
backupExportJobRunner: BackupExportJobRunner,
backupPlanManager: BackupPlanManager,
backupSettingsStore: BackupSettingsStore,
backupSubscriptionManager: BackupSubscriptionManager,
db: DB,
deviceSleepManager: DeviceSleepManager,
svr: SecureValueRecovery,
tsAccountManager: TSAccountManager
) {
owsPrecondition(
db.read { tsAccountManager.registrationState(tx: $0).isPrimaryDevice == true },
"Unsafe to let a linked device access Backup Settings!"
)
self.accountKeyStore = accountKeyStore
self.backupAttachmentDownloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: backupAttachmentDownloadQueueStatusReporter,
backupAttachmentDownloadProgress: backupAttachmentDownloadProgress
)
self.backupAttachmentUploadTracker = BackupSettingsAttachmentUploadTracker(
backupAttachmentUploadQueueStatusReporter: backupAttachmentUploadQueueStatusReporter,
backupAttachmentUploadProgress: backupAttachmentUploadProgress
)
self.backupDisablingManager = backupDisablingManager
self.backupEnablingManager = backupEnablingManager
self.backupExportJobRunner = backupExportJobRunner
self.backupPlanManager = backupPlanManager
self.backupSettingsStore = backupSettingsStore
self.backupSubscriptionManager = backupSubscriptionManager
self.db = db
self.deviceSleepManager = deviceSleepManager
self.svr = svr
self.tsAccountManager = tsAccountManager
self.onLoadAction = onLoadAction
self.viewModel = db.read { tx in
let viewModel = BackupSettingsViewModel(
backupSubscriptionLoadingState: .loading,
backupPlan: backupPlanManager.backupPlan(tx: tx),
failedToDisableBackupsRemotely: backupDisablingManager.disableRemotelyFailed(tx: tx),
latestBackupExportProgressUpdate: nil,
latestBackupAttachmentDownloadUpdate: nil,
latestBackupAttachmentUploadUpdate: nil,
lastBackupDate: backupSettingsStore.lastBackupDate(tx: tx),
lastBackupSizeBytes: backupSettingsStore.lastBackupSizeBytes(tx: tx),
shouldAllowBackupUploadsOnCellular: backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx)
)
return viewModel
}
super.init(wrappedView: BackupSettingsView(viewModel: viewModel))
setUpTitleViewWithBadge(
title: OWSLocalizedString(
"BACKUPS_SETTINGS_TITLE",
comment: "Title for the 'Backup' settings menu."
),
badgeText: CommonStrings.betaLabel
)
OWSTableViewController2.removeBackButtonText(viewController: self)
viewModel.actionsDelegate = self
}
override func viewDidLoad() {
switch onLoadAction {
case nil:
break
case .presentWelcomeToBackupsSheet:
presentWelcomeToBackupsSheet()
}
}
override func viewWillAppear(_ animated: Bool) {
startExternalEventObservation()
// Reload the view model, as state may have changed while we weren't
// visible.
reloadViewModel()
}
override func viewDidDisappear(_ animated: Bool) {
stopExternalEventObservation()
}
// MARK: -
private func titleLabel(text: String) -> UILabel {
let titleLabel = UILabel()
titleLabel.text = text
titleLabel.textColor = Theme.primaryTextColor
titleLabel.font = UIFont.dynamicTypeHeadlineClamped
titleLabel.numberOfLines = 0
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.textAlignment = .center
titleLabel.translatesAutoresizingMaskIntoConstraints = false
return titleLabel
}
private func badgeView(text: String) -> UIView {
let badgeLabel = UILabel()
badgeLabel.text = text
badgeLabel.font = UIFont.dynamicTypeCaption1.bold()
badgeLabel.textColor = UIColor(Color.Signal.label)
badgeLabel.textAlignment = .center
badgeLabel.translatesAutoresizingMaskIntoConstraints = false
// Use a container for the grey background so it adjusts
// for different text length when localized.
let badgeContainer = UIView()
badgeContainer.backgroundColor = UIColor(Color.Signal.secondaryFill)
badgeContainer.layer.cornerRadius = 12
badgeContainer.addSubview(badgeLabel)
NSLayoutConstraint.activate([
badgeLabel.leadingAnchor.constraint(equalTo: badgeContainer.leadingAnchor, constant: 8),
badgeLabel.trailingAnchor.constraint(equalTo: badgeContainer.trailingAnchor, constant: -8),
badgeLabel.topAnchor.constraint(equalTo: badgeContainer.topAnchor, constant: 4),
badgeLabel.bottomAnchor.constraint(equalTo: badgeContainer.bottomAnchor, constant: -4)
])
return badgeContainer
}
private func setUpTitleViewWithBadge(title: String, badgeText: String) {
let titleLabel = titleLabel(text: title)
let badgeLabel = badgeView(text: badgeText)
let stack = UIStackView(arrangedSubviews: [titleLabel, badgeLabel])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
let titleContainer = UIView()
titleContainer.addSubview(stack)
NSLayoutConstraint.activate([
titleLabel.leadingAnchor.constraint(equalTo: titleContainer.leadingAnchor),
titleLabel.centerYAnchor.constraint(equalTo: titleContainer.centerYAnchor),
badgeLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8),
badgeLabel.centerYAnchor.constraint(equalTo: titleContainer.centerYAnchor),
badgeLabel.trailingAnchor.constraint(equalTo: titleContainer.trailingAnchor)
])
navigationItem.titleView = titleContainer
}
// MARK: -
private func startExternalEventObservation() {
guard externalEventObservationTasks.isEmpty else {
return
}
externalEventObservationTasks = [
Task { [weak self, backupExportJobRunner] in
await self?.preventDeviceSleepDuringNonNilUpdates(
updateStream: backupExportJobRunner.updates(),
label: "Export",
) { [weak self] exportProgressUpdate in
guard let self else { return }
viewModel.latestBackupExportProgressUpdate = exportProgressUpdate
}
},
Task { [weak self, backupAttachmentDownloadTracker] in
await self?.preventDeviceSleepDuringNonNilUpdates(
updateStream: backupAttachmentDownloadTracker.updates(),
label: "Downloads",
) { [weak self] downloadUpdate in
guard let self else { return }
viewModel.latestBackupAttachmentDownloadUpdate = downloadUpdate
}
},
Task { [weak self, backupAttachmentUploadTracker] in
await self?.preventDeviceSleepDuringNonNilUpdates(
updateStream: backupAttachmentUploadTracker.updates(),
label: "Uploads",
) { [weak self] uploadUpdate in
guard let self else { return }
viewModel.latestBackupAttachmentUploadUpdate = uploadUpdate
}
},
Task.detached { [weak self] in
for await _ in NotificationCenter.default.notifications(
named: .backupPlanChanged
) {
await MainActor.run { [weak self] in
guard let self else { return }
_backupPlanDidChange()
}
}
},
Task.detached { [weak self] in
for await _ in NotificationCenter.default.notifications(
named: .shouldAllowBackupUploadsOnCellularChanged
) {
await MainActor.run { [weak self] in
guard let self else { return }
_shouldAllowBackupUploadsOnCellularDidChange()
}
}
},
]
}
private func stopExternalEventObservation() {
externalEventObservationTasks.forEach { $0.cancel() }
externalEventObservationTasks = []
}
/// Prevent device sleep when the given `updateStream` is producing non-nil
/// updates. This is appropriate when said updates result in us displaying
/// UX, such as a progress bar, for which we want to prevent sleep.
@MainActor
private func preventDeviceSleepDuringNonNilUpdates<T>(
updateStream: AsyncStream<T?>,
label: String,
onUpdate: (T?) -> Void
) async {
// Caller-retained as long as sleep-blocking is required.
var deviceSleepBlock: DeviceSleepBlockObject?
for await update in updateStream {
if update != nil {
deviceSleepBlock = deviceSleepBlock ?? {
let newSleepBlock = DeviceSleepBlockObject(blockReason: "BackupSettings: \(label)")
deviceSleepManager.addBlock(blockObject: newSleepBlock)
return newSleepBlock
}()
} else {
deviceSleepBlock
.take()
.map { deviceSleepManager.removeBlock(blockObject: $0) }
}
onUpdate(update)
}
if let deviceSleepBlock {
deviceSleepManager.removeBlock(blockObject: deviceSleepBlock)
}
}
// MARK: -
private func reloadViewModel() {
// Notably, we don't actively try and reload any of "latest update"
// properties, since when we start listening to the update streams (see
// `externalEventObservationTasks`) the latest update is yielded
// immediately.
db.read { tx in
viewModel.backupPlan = backupPlanManager.backupPlan(tx: tx)
viewModel.failedToDisableBackupsRemotely = backupDisablingManager.disableRemotelyFailed(tx: tx)
viewModel.lastBackupDate = backupSettingsStore.lastBackupDate(tx: tx)
viewModel.lastBackupSizeBytes = backupSettingsStore.lastBackupSizeBytes(tx: tx)
viewModel.shouldAllowBackupUploadsOnCellular = backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx)
}
loadBackupSubscription()
}
private func _backupPlanDidChange() {
reloadViewModel()
// If we just disabled Backups locally but recorded a failure disabling
// remotely, show an action sheet. (We'll also show that we failed to
// disable remotely in BackupSettings.)
switch viewModel.backupPlan {
case .disabled where viewModel.failedToDisableBackupsRemotely:
showDisablingBackupsFailedSheet()
case .disabled, .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
break
}
}
private func _shouldAllowBackupUploadsOnCellularDidChange() {
db.read { tx in
viewModel.shouldAllowBackupUploadsOnCellular = backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx)
}
}
// MARK: - BackupSettingsViewModel.ActionsDelegate
fileprivate func enableBackups(
implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?
) {
// TODO: [Backups] Show the rest of the onboarding flow.
Task {
if let planSelection = implicitPlanSelection {
await _enableBackups(
fromViewController: self,
planSelection: planSelection
)
} else {
await showChooseBackupPlan(initialPlanSelection: nil)
}
}
}
@MainActor
private func showChooseBackupPlan(
initialPlanSelection: ChooseBackupPlanViewController.PlanSelection?
) async {
let chooseBackupPlanViewController: ChooseBackupPlanViewController
do throws(OWSAssertionError) {
chooseBackupPlanViewController = try await .load(
fromViewController: self,
initialPlanSelection: initialPlanSelection,
onConfirmPlanSelectionBlock: { [weak self] chooseBackupPlanViewController, planSelection in
Task { [weak self] in
guard let self else { return }
await _enableBackups(
fromViewController: chooseBackupPlanViewController,
planSelection: planSelection
)
}
}
)
} catch {
return
}
navigationController?.pushViewController(
chooseBackupPlanViewController,
animated: true
)
}
@MainActor
private func _enableBackups(
fromViewController: UIViewController,
planSelection: ChooseBackupPlanViewController.PlanSelection
) async {
do throws(BackupEnablingManager.DisplayableError) {
try await backupEnablingManager.enableBackups(
fromViewController: fromViewController,
planSelection: planSelection
)
} catch {
OWSActionSheets.showActionSheet(
message: error.localizedActionSheetMessage,
fromViewController: fromViewController,
)
return
}
navigationController?.popToViewController(self, animated: true) { [self] in
presentWelcomeToBackupsSheet()
}
}
private func presentWelcomeToBackupsSheet() {
let welcomeToBackupsSheet = HeroSheetViewController(
hero: .image(.backupsSubscribed),
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE",
comment: "Title for a sheet shown after the user enables backups."
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
comment: "Message for a sheet shown after the user enables backups."
),
primary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_PRIMARY_BUTTON",
comment: "Title for the primary button for a sheet shown after the user enables backups."
),
action: { _ in
self.viewModel.performManualBackup()
self.dismiss(animated: true)
}
)),
secondary: .button(.dismissing(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_SECONDARY_BUTTON",
comment: "Title for the secondary button for a sheet shown after the user enables backups."
),
style: .secondary
))
)
present(welcomeToBackupsSheet, animated: true)
}
// MARK: -
fileprivate func disableBackups() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_CONFIRMATION_ACTION_SHEET_TITLE",
comment: "Title for an action sheet confirming the user wants to disable Backups."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_CONFIRMATION_ACTION_SHEET_MESSAGE",
comment: "Message for an action sheet confirming the user wants to disable Backups."
)
)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_CONFIRMATION_ACTION_SHEET_CONFIRM",
comment: "Title for a button in an action sheet confirming the user wants to disable Backups."
),
style: .destructive,
handler: { _ in
Task { [weak self] in
guard let self else { return }
await _disableBackups()
}
},
))
actionSheet.addAction(.cancel)
presentActionSheet(actionSheet)
}
@MainActor
private func _disableBackups() async {
guard db.read(block: { tx in
tsAccountManager.localIdentifiers(tx: tx) != nil
}) else {
OWSActionSheets.showActionSheet(
message: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_ERROR_NOT_REGISTERED",
comment: "Message shown in an action sheet when the user tries to disable Backups, but is not registered."
),
fromViewController: self
)
return
}
// Start disabling Backups, which may result in us starting
// downloads. When disabling completes, we'll be notified via
// `BackupPlan` going from `.disabling` to `.disabled`.
let currentDownloadQueueStatus = await backupDisablingManager.startDisablingBackups()
switch currentDownloadQueueStatus {
case .empty, .suspended, .notRegisteredAndReady, .appBackgrounded:
break
case .running, .noWifiReachability, .noReachability, .lowBattery, .lowDiskSpace:
OWSActionSheets.showActionSheet(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_DOWNLOADS_STARTED_ACTION_SHEET_TITLE",
comment: "Title shown in an action sheet when the user disables Backups, explaining that their media is downloading first."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_DOWNLOADS_STARTED_ACTION_SHEET_MESSAGE",
comment: "Message shown in an action sheet when the user disables Backups, explaining that their media is downloading first."
),
fromViewController: self
)
}
}
private func showDisablingBackupsFailedSheet() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_ERROR_GENERIC_ERROR_ACTION_SHEET_TITLE",
comment: "Title shown in an action sheet indicating we failed to delete the user's Backup due to an unexpected error."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_ERROR_GENERIC_ERROR_ACTION_SHEET_MESSAGE",
comment: "Message shown in an action sheet indicating we failed to delete the user's Backup due to an unexpected error."
),
)
actionSheet.addAction(.contactSupport(
emailFilter: .backupDisableFailed,
fromViewController: self
))
actionSheet.addAction(.okay)
presentActionSheet(actionSheet)
}
// MARK: -
private lazy var loadBackupSubscriptionQueue = SerialTaskQueue()
fileprivate func loadBackupSubscription() {
loadBackupSubscriptionQueue.enqueue { @MainActor [self] in
withAnimation {
viewModel.backupSubscriptionLoadingState = .loading
}
let newLoadingState: BackupSettingsViewModel.BackupSubscriptionLoadingState
do {
let backupSubscription = try await _loadBackupSubscription()
newLoadingState = .loaded(backupSubscription)
} catch let error where error.isNetworkFailureOrTimeout {
newLoadingState = .networkError
} catch {
newLoadingState = .genericError
}
withAnimation {
viewModel.backupSubscriptionLoadingState = newLoadingState
}
}
}
private func _loadBackupSubscription() async throws -> BackupSettingsViewModel.BackupSubscriptionLoadingState.LoadedBackupSubscription {
var currentBackupPlan = db.read { backupPlanManager.backupPlan(tx: $0) }
switch currentBackupPlan {
case .free:
return .free
case .paidAsTester:
return .paidButFreeForTesters
case .disabling, .disabled, .paid, .paidExpiringSoon:
break
}
guard
let backupSubscription = try await backupSubscriptionManager
.fetchAndMaybeDowngradeSubscription()
else {
return .free
}
// The subscription fetch may have updated our local Backup plan.
currentBackupPlan = db.read { backupPlanManager.backupPlan(tx: $0) }
switch currentBackupPlan {
case .free:
return .free
case .disabling, .disabled, .paid, .paidExpiringSoon, .paidAsTester:
break
}
let endOfCurrentPeriod = Date(timeIntervalSince1970: backupSubscription.endOfCurrentPeriod)
if backupSubscription.cancelAtEndOfPeriod {
if endOfCurrentPeriod.isAfterNow {
return .paidButExpiring(expirationDate: endOfCurrentPeriod)
} else {
return .paidButExpired(expirationDate: endOfCurrentPeriod)
}
}
return .paid(
price: backupSubscription.amount,
renewalDate: endOfCurrentPeriod
)
}
// MARK: -
fileprivate func upgradeFromFreeToPaidPlan() {
Task {
await showChooseBackupPlan(initialPlanSelection: .free)
}
}
fileprivate func manageOrCancelPaidPlan() {
guard let windowScene = view.window?.windowScene else {
owsFailDebug("Missing window scene!")
return
}
Task {
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
owsFailDebug("Failed to show manage-subscriptions view! \(error)")
}
// Reload the BackupPlan, since our subscription may now be in a
// different state (e.g., set to not renew).
loadBackupSubscription()
}
}
fileprivate func managePaidPlanAsTester() {
Task {
await showChooseBackupPlan(initialPlanSelection: .paid)
}
}
// MARK: -
fileprivate func performManualBackup() {
Task { [weak self, backupExportJobRunner] in
do throws(BackupExportJobError) {
try await backupExportJobRunner.run()
} catch {
switch error {
case .cancellationError, .needsWifi, .networkRequestError:
Logger.warn("Failed to perform manual backup! \(error)")
case .backupError, .backupKeyError, .unregistered:
owsFailDebug("Failed to perform manual backup! \(error)")
}
self?.showSheetForBackupExportJobError(error)
}
guard let self else { return }
db.read { tx in
self.viewModel.lastBackupDate = self.backupSettingsStore.lastBackupDate(tx: tx)
self.viewModel.lastBackupSizeBytes = self.backupSettingsStore.lastBackupSizeBytes(tx: tx)
}
}
}
fileprivate func cancelManualBackup() {
backupExportJobRunner.cancelIfRunning()
}
private func showSheetForBackupExportJobError(_ error: BackupExportJobError) {
let actionSheet: ActionSheetController
switch error {
case .cancellationError:
return
case .needsWifi:
actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_NEED_WIFI_TITLE",
comment: "Title for an action sheet explaining that performing a backup failed because WiFi is required."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_NEED_WIFI_MESSAGE",
comment: "Message for an action sheet explaining that performing a backup failed because WiFi is required."
),
)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_NEED_WIFI_ACTION",
comment: "Title for a button in an action sheet allowing users to perform a backup, ignoring that WiFi is required."
),
handler: { [weak self] _ in
guard let self else { return }
setShouldAllowBackupUploadsOnCellular(true)
performManualBackup()
}
))
actionSheet.addAction(.cancel)
case .networkRequestError:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_NETWORK_ERROR",
comment: "Message for an action sheet explaining that performing a backup failed with a network error."
)
)
actionSheet.addAction(.okay)
case .unregistered, .backupKeyError, .backupError:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_GENERIC_ERROR",
comment: "Message for an action sheet explaining that performing a backup failed with a generic error."
)
)
actionSheet.addAction(.contactSupport(
emailFilter: .backupExportFailed,
fromViewController: self
))
actionSheet.addAction(.okay)
}
presentActionSheet(actionSheet)
}
// MARK: -
fileprivate func setShouldAllowBackupUploadsOnCellular(_ newShouldAllowBackupUploadsOnCellular: Bool) {
db.write { tx in
backupSettingsStore.setShouldAllowBackupUploadsOnCellular(newShouldAllowBackupUploadsOnCellular, tx: tx)
}
}
// MARK: -
fileprivate func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) {
do {
let isPaidPlanTester: Bool = try db.writeWithRollbackIfThrows { tx in
let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
let newBackupPlan: BackupPlan
let isPaidPlanTester: Bool
switch currentBackupPlan {
case .disabled, .disabling, .free:
owsFailDebug("Shouldn't be setting Optimize Local Storage: \(currentBackupPlan)")
return false
case .paid:
newBackupPlan = .paid(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = false
case .paidExpiringSoon:
newBackupPlan = .paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = false
case .paidAsTester:
newBackupPlan = .paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = true
}
try backupPlanManager.setBackupPlan(newBackupPlan, tx: tx)
return isPaidPlanTester
}
// If disabling Optimize Local Storage, offer to start downloads now.
if !newOptimizeLocalStorage {
showDownloadOffloadedMediaSheet()
} else if isPaidPlanTester {
showOffloadedMediaForTestersWarningSheet(onAcknowledge: {})
}
} catch {
owsFailDebug("Failed to set Optimize Local Storage: \(error)")
return
}
}
private func showDownloadOffloadedMediaSheet() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_DOWNLOAD_SHEET_TITLE",
comment: "Title for an action sheet allowing users to download their offloaded media."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_DOWNLOAD_SHEET_MESSAGE",
comment: "Message for an action sheet allowing users to download their offloaded media."
),
)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_DOWNLOAD_SHEET_NOW_ACTION",
comment: "Action in an action sheet allowing users to download their offloaded media now."
),
handler: { [weak self] _ in
guard let self else { return }
db.write { tx in
self.backupSettingsStore.setIsBackupDownloadQueueSuspended(false, tx: tx)
}
}
))
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_DOWNLOAD_SHEET_LATER_ACTION",
comment: "Action in an action sheet allowing users to download their offloaded media later."
),
handler: { _ in }
))
presentActionSheet(actionSheet)
}
private func showOffloadedMediaForTestersWarningSheet(
onAcknowledge: @escaping () -> Void,
) {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_TITLE",
comment: "Title for an action sheet warning users who are testers about the Optimize Local Storage feature."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_MESSAGE",
comment: "Message for an action sheet warning users who are testers about the Optimize Local Storage feature."
),
)
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.okButton,
handler: { _ in
onAcknowledge()
},
))
presentActionSheet(actionSheet)
}
// MARK: -
fileprivate func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan) {
if isSuspended {
switch backupPlan {
case .disabled, .disabling, .free, .paid:
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
case .paidAsTester:
showOffloadedMediaForTestersWarningSheet(onAcknowledge: { [self] in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
})
case .paidExpiringSoon:
let warningSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads.",
)
)
warningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_ACTION_SKIP",
comment: "Title for an action in a sheet warning the user about skipping downloads.",
),
style: .destructive,
handler: { [self] _ in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
}
))
warningSheet.addAction(ActionSheetAction(
title: CommonStrings.learnMore,
handler: { _ in
CurrentAppContext().open(
URL(string: "https://support.signal.org/hc/articles/360007059752")!,
completion: nil
)
}
))
warningSheet.addAction(.cancel)
presentActionSheet(warningSheet)
}
} else {
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(false, tx: tx)
}
}
}
fileprivate func setShouldAllowBackupDownloadsOnCellular() {
db.write { tx in
backupSettingsStore.setShouldAllowBackupDownloadsOnCellular(tx: tx)
}
}
// MARK: -
fileprivate func showViewBackupKey() {
Task { await _showViewBackupKey() }
}
@MainActor
private func _showViewBackupKey() async {
guard let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }) else {
return
}
guard let authSuccess = await LocalDeviceAuthentication().performBiometricAuth() else {
return
}
let recordKeyViewController = BackupRecordKeyViewController(
aepMode: .current(aep, authSuccess),
options: [.replaceNavBarBackWithDoneButton, .showCreateNewKeyButton],
onCreateNewKeyPressed: { [weak self] recordKeyViewController in
guard let self else { return }
// If appropriate, the warning sheet will let the user continue
// in a "create new AEP" flow.
showCreateNewBackupKeyWarningSheet(fromViewController: recordKeyViewController)
},
onCompletion: { [weak self] recordKeyViewController in
guard let self else { return }
let keepKeySafeSheet = BackupKeepKeySafeSheet(
onContinue: { [weak self] in
guard let self else { return }
navigationController?.popToViewController(self, animated: true)
},
onSeeKeyAgain: {
// The sheet is already dismissed, and the topmost
// view is a BackupRecordKeyViewController.
}
)
recordKeyViewController.present(
keepKeySafeSheet,
animated: true
)
}
)
navigationController?.pushViewController(recordKeyViewController, animated: true)
}
private func showCreateNewBackupKeyWarningSheet(
fromViewController: BackupRecordKeyViewController,
) {
let currentBackupPlan = db.read { tx in
backupSettingsStore.backupPlan(tx: tx)
}
// Only allow creating a new Backup Key if Backups are already disabled.
let primary: HeroSheetViewController.Button
let secondary: HeroSheetViewController.Button?
switch currentBackupPlan {
case .disabled:
primary = HeroSheetViewController.Button(
title: CommonStrings.continueButton,
action: { sheet in
sheet.dismiss(animated: true) { [weak self] in
guard let self else { return }
showRecordNewBackupKey()
}
}
)
secondary = .dismissing(
title: CommonStrings.cancelButton,
style: .secondary,
)
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
primary = .dismissing(title: CommonStrings.cancelButton)
secondary = nil
}
let warningSheet = HeroSheetViewController(
hero: .image(.backupsKey),
title: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning users about creating a new Backup Key."
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_BODY",
comment: "Body for a sheet warning users about creating a new Backup Key."
),
primary: .button(primary),
secondary: secondary.map { .button($0) },
)
fromViewController.present(warningSheet, animated: true)
}
private func showRecordNewBackupKey() {
let newCandidateAEP = AccountEntropyPool()
let recordKeyViewController = BackupRecordKeyViewController(
aepMode: .newCandidate(newCandidateAEP),
options: [.showContinueButton],
onCompletion: { [weak self] _ in
guard let self else { return }
showConfirmNewBackupKey(newCandidateAEP: newCandidateAEP)
}
)
navigationController?.pushViewController(recordKeyViewController, animated: true)
}
private func showConfirmNewBackupKey(newCandidateAEP: AccountEntropyPool) {
let confirmKeyViewController = BackupConfirmKeyViewController(
aep: newCandidateAEP,
onContinue: { [weak self] in
guard let self else { return }
db.write { tx in
self.setAEPAndRotateMRBK(newCandidateAEP: newCandidateAEP, tx: tx)
}
// Pop all the way back to Backup Settings.
navigationController?.popToViewController(self, animated: true) {
self.presentToast(text: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_SUCCESS_TOAST",
comment: "Toast shown when a new Backup Key has been created successfully."
))
}
},
onSeeKeyAgain: { [weak self] in
guard let self else { return }
// Popping drops us back on the BackupRecordKeyViewController.
navigationController?.popViewController(animated: true)
}
)
navigationController?.pushViewController(confirmKeyViewController, animated: true)
}
/// Sets the given AEP, and rotates the MRBK. A big deal approach with
/// great caution.
private func setAEPAndRotateMRBK(
newCandidateAEP: AccountEntropyPool,
tx: DBWriteTransaction,
) {
switch backupSettingsStore.backupPlan(tx: tx) {
case .disabled:
break
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
owsFail("Attempting to set AEP, but Backups are not disabled!")
}
Logger.warn("Setting new AEP and MRBK!")
// Generate a new, random MRBK for use in the future alongside our new,
// random AEP.
accountKeyStore.setMediaRootBackupKey(
MediaRootBackupKey(backupKey: .generateRandom()),
tx: tx
)
svr.setNewAccountEntropyPoolWithSideEffects(
newCandidateAEP,
disablePIN: false,
authedAccount: .implicit(),
transaction: tx
)
}
}
// MARK: -
private class BackupSettingsViewModel: ObservableObject {
protocol ActionsDelegate: AnyObject {
func enableBackups(implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?)
func disableBackups()
func loadBackupSubscription()
func upgradeFromFreeToPaidPlan()
func manageOrCancelPaidPlan()
func managePaidPlanAsTester()
func performManualBackup()
func cancelManualBackup()
func setShouldAllowBackupUploadsOnCellular(_ newShouldAllowBackupUploadsOnCellular: Bool)
func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool)
func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan)
func setShouldAllowBackupDownloadsOnCellular()
func showViewBackupKey()
}
enum BackupSubscriptionLoadingState {
enum LoadedBackupSubscription {
case free
case paidButFreeForTesters
case paid(price: FiatMoney, renewalDate: Date)
case paidButExpiring(expirationDate: Date)
case paidButExpired(expirationDate: Date)
}
case loading
case loaded(LoadedBackupSubscription)
case networkError
case genericError
}
@Published var backupSubscriptionLoadingState: BackupSubscriptionLoadingState
@Published var backupPlan: BackupPlan
@Published var failedToDisableBackupsRemotely: Bool
@Published var latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStep>?
@Published var latestBackupAttachmentDownloadUpdate: BackupSettingsAttachmentDownloadTracker.DownloadUpdate?
@Published var latestBackupAttachmentUploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate?
@Published var lastBackupDate: Date?
@Published var lastBackupSizeBytes: UInt64?
@Published var shouldAllowBackupUploadsOnCellular: Bool
weak var actionsDelegate: ActionsDelegate?
init(
backupSubscriptionLoadingState: BackupSubscriptionLoadingState,
backupPlan: BackupPlan,
failedToDisableBackupsRemotely: Bool,
latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStep>?,
latestBackupAttachmentDownloadUpdate: BackupSettingsAttachmentDownloadTracker.DownloadUpdate?,
latestBackupAttachmentUploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate?,
lastBackupDate: Date?,
lastBackupSizeBytes: UInt64?,
shouldAllowBackupUploadsOnCellular: Bool,
) {
self.backupSubscriptionLoadingState = backupSubscriptionLoadingState
self.backupPlan = backupPlan
self.failedToDisableBackupsRemotely = failedToDisableBackupsRemotely
self.latestBackupExportProgressUpdate = latestBackupExportProgressUpdate
self.latestBackupAttachmentDownloadUpdate = latestBackupAttachmentDownloadUpdate
self.latestBackupAttachmentUploadUpdate = latestBackupAttachmentUploadUpdate
self.lastBackupDate = lastBackupDate
self.lastBackupSizeBytes = lastBackupSizeBytes
self.shouldAllowBackupUploadsOnCellular = shouldAllowBackupUploadsOnCellular
}
// MARK: -
func enableBackups(implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?) {
actionsDelegate?.enableBackups(implicitPlanSelection: implicitPlanSelection)
}
func disableBackups() {
actionsDelegate?.disableBackups()
}
// MARK: -
var isPaidPlanTester: Bool {
switch backupPlan {
case .disabled, .disabling, .free, .paid, .paidExpiringSoon:
false
case .paidAsTester:
true
}
}
// MARK: -
func loadBackupSubscription() {
actionsDelegate?.loadBackupSubscription()
}
func upgradeFromFreeToPaidPlan() {
actionsDelegate?.upgradeFromFreeToPaidPlan()
}
func manageOrCancelPaidPlan() {
actionsDelegate?.manageOrCancelPaidPlan()
}
func managePaidPlanAsTester() {
actionsDelegate?.managePaidPlanAsTester()
}
// MARK: -
func performManualBackup() {
actionsDelegate?.performManualBackup()
}
func cancelManualBackup() {
actionsDelegate?.cancelManualBackup()
}
// MARK: -
func setShouldAllowBackupUploadsOnCellular(_ newShouldAllowBackupUploadsOnCellular: Bool) {
actionsDelegate?.setShouldAllowBackupUploadsOnCellular(newShouldAllowBackupUploadsOnCellular)
}
// MARK: -
var optimizeLocalStorageAvailable: Bool {
switch backupPlan {
case .disabled, .disabling, .free:
false
case .paid, .paidExpiringSoon, .paidAsTester:
true
}
}
var optimizeLocalStorage: Bool {
switch backupPlan {
case .disabled, .disabling, .free:
false
case
.paid(let optimizeLocalStorage),
.paidExpiringSoon(let optimizeLocalStorage),
.paidAsTester(let optimizeLocalStorage):
optimizeLocalStorage
}
}
func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) {
actionsDelegate?.setOptimizeLocalStorage(newOptimizeLocalStorage)
}
// MARK: -
func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool) {
actionsDelegate?.setIsBackupDownloadQueueSuspended(isSuspended, backupPlan: backupPlan)
}
func setShouldAllowBackupDownloadsOnCellular() {
actionsDelegate?.setShouldAllowBackupDownloadsOnCellular()
}
// MARK: -
func showViewBackupKey() {
actionsDelegate?.showViewBackupKey()
}
}
// MARK: -
struct BackupSettingsView: View {
private enum Contents {
case enabled
case disablingDownloadsRunning(BackupSettingsAttachmentDownloadTracker.DownloadUpdate)
case disabling
case disabledFailedToDisableRemotely
case disabled
}
private var contents: Contents {
switch viewModel.backupPlan {
case .free, .paid, .paidExpiringSoon, .paidAsTester:
return .enabled
case .disabled:
if viewModel.failedToDisableBackupsRemotely {
return .disabledFailedToDisableRemotely
} else {
return .disabled
}
case .disabling:
let latestDownloadUpdate = viewModel.latestBackupAttachmentDownloadUpdate
switch latestDownloadUpdate?.state {
case nil, .suspended:
return .disabling
case .running, .pausedLowBattery, .pausedNeedsWifi, .pausedNeedsInternet, .outOfDiskSpace:
return .disablingDownloadsRunning(latestDownloadUpdate!)
}
}
}
@ObservedObject private var viewModel: BackupSettingsViewModel
fileprivate init(viewModel: BackupSettingsViewModel) {
self.viewModel = viewModel
}
var body: some View {
SignalList {
SignalSection {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BETA_NOTICE_HEADER",
comment: "Notice that backups is a beta feature")
)
.font(.subheadline)
} icon: {
Image(uiImage: Theme.iconImage(.info))
.frame(width: 24, height: 24)
}
.padding(.vertical, 2)
.foregroundColor(Color.Signal.label)
}
SignalSection {
BackupSubscriptionView(
loadingState: viewModel.backupSubscriptionLoadingState,
viewModel: viewModel
)
}
if let latestBackupAttachmentUploadUpdate = viewModel.latestBackupAttachmentUploadUpdate {
SignalSection {
BackupAttachmentUploadProgressView(
latestUploadUpdate: latestBackupAttachmentUploadUpdate
)
}
}
if let latestBackupAttachmentDownloadUpdate = viewModel.latestBackupAttachmentDownloadUpdate {
switch contents {
case .disabling, .disablingDownloadsRunning:
// We'll show a download progress bar below if necessary.
EmptyView()
case .enabled, .disabled, .disabledFailedToDisableRemotely:
SignalSection {
BackupAttachmentDownloadProgressView(
latestDownloadUpdate: latestBackupAttachmentDownloadUpdate,
viewModel: viewModel,
)
}
}
}
switch contents {
case .enabled:
SignalSection {
if let latestBackupExportProgressUpdate = viewModel.latestBackupExportProgressUpdate {
BackupExportProgressView(
latestProgressUpdate: latestBackupExportProgressUpdate,
viewModel: viewModel
)
} else {
Button {
viewModel.performManualBackup()
} label: {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_MANUAL_BACKUP_BUTTON_TITLE",
comment: "Title for a button allowing users to trigger a manual backup."
))
} icon: {
Image(uiImage: .backup)
.resizable()
.frame(width: 24, height: 24)
}
}
.foregroundStyle(Color.Signal.label)
}
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_ENABLED_SECTION_HEADER",
comment: "Header for a menu section related to settings for when Backups are enabled."
))
}
SignalSection {
BackupDetailsView(
lastBackupDate: viewModel.lastBackupDate,
lastBackupSizeBytes: viewModel.lastBackupSizeBytes,
shouldAllowBackupUploadsOnCellular: viewModel.shouldAllowBackupUploadsOnCellular,
viewModel: viewModel,
)
}
SignalSection {
Toggle(
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE",
comment: "Title for a toggle allowing users to change the Optimize Local Storage setting."
),
isOn: Binding(
get: { viewModel.optimizeLocalStorage },
set: { viewModel.setOptimizeLocalStorage($0) }
)
).disabled(!viewModel.optimizeLocalStorageAvailable)
} footer: {
let footerText: String = if
viewModel.optimizeLocalStorageAvailable,
viewModel.isPaidPlanTester
{
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE_FOR_TESTERS",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available and they are a tester."
)
} else if viewModel.optimizeLocalStorageAvailable {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available."
)
} else {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_UNAVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is unavailable."
)
}
Text(footerText)
.foregroundStyle(Color.Signal.secondaryLabel)
}
SignalSection {
Button {
viewModel.disableBackups()
} label: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_DISABLE_BACKUPS_BUTTON_TITLE",
comment: "Title for a button allowing users to turn off Backups."
))
.foregroundStyle(Color.Signal.red)
}
} footer: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_DISABLE_BACKUPS_BUTTON_FOOTER",
comment: "Footer for a menu section allowing users to turn off Backups."
))
.foregroundStyle(Color.Signal.secondaryLabel)
}
case .disablingDownloadsRunning(let lastDownloadUpdate):
SignalSection {
BackupAttachmentDownloadProgressView(
latestDownloadUpdate: lastDownloadUpdate,
viewModel: viewModel
)
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_DOWNLOADING_MEDIA_PROGRESS_VIEW_DESCRIPTION",
comment: "Description for a progress view tracking media being downloaded in service of disabling Backups."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
case .disabling:
SignalSection {
VStack(alignment: .leading) {
LottieView(animation: .named("linear_indeterminate"))
.playing(loopMode: .loop)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
Spacer().frame(height: 16)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_PROGRESS_VIEW_DESCRIPTION",
comment: "Description for a progress view tracking Backups being disabled."
))
.foregroundStyle(Color.Signal.secondaryLabel)
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_SECTION_HEADER",
comment: "Header for a menu section related to disabling Backups."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
case .disabled:
SignalSection {
reenableBackupsButton
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLED_SECTION_FOOTER",
comment: "Footer for a menu section related to settings for when Backups are disabled."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
SignalSection {
BackupViewKeyView(viewModel: viewModel)
}
case .disabledFailedToDisableRemotely:
SignalSection {
VStack(alignment: .center) {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_GENERIC_ERROR_TITLE",
comment: "Title for a view indicating we failed to delete the user's Backup due to an unexpected error."
))
.bold()
.foregroundStyle(Color.Signal.secondaryLabel)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_GENERIC_ERROR_MESSAGE",
comment: "Message for a view indicating we failed to delete the user's Backup due to an unexpected error."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
.padding(.horizontal, 4)
.padding(.vertical, 24)
.frame(maxWidth: .infinity)
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_GENERIC_ERROR_SECTION_HEADER",
comment: "Header for a menu section related to settings for when disabling Backups encountered an unexpected error."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
SignalSection {
reenableBackupsButton
}
SignalSection {
BackupViewKeyView(viewModel: viewModel)
}
}
}
}
/// A button to enable Backups if it was previously disabled, if we can let
/// the user reenable.
private var reenableBackupsButton: AnyView {
let implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?
switch viewModel.backupSubscriptionLoadingState {
case .loading, .networkError:
// Don't let them reenable until we know if they're already paying
// or not.
return AnyView(EmptyView())
case .loaded(.paidButFreeForTesters):
// Let them reenable with anything; there was no purchase.
implicitPlanSelection = nil
case .loaded(.free), .loaded(.paidButExpired), .genericError:
// Let them reenable with anything.
implicitPlanSelection = nil
case .loaded(.paid), .loaded(.paidButExpiring):
// Only let the user reenable with .paid, because they're already
// paying.
implicitPlanSelection = .paid
}
return AnyView(
Button {
viewModel.enableBackups(implicitPlanSelection: implicitPlanSelection)
} label: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_REENABLE_BACKUPS_BUTTON_TITLE",
comment: "Title for a button allowing users to re-enable Backups, after it had been previously disabled."
))
.foregroundStyle(Color.Signal.label)
}
)
}
}
// MARK: -
private struct BackupExportProgressView: View {
let latestProgressUpdate: OWSSequentialProgress<BackupExportJobStep>
let viewModel: BackupSettingsViewModel
var body: some View {
VStack(alignment: .leading) {
let percentComplete = latestProgressUpdate.percentComplete
ProgressView(value: percentComplete)
.progressViewStyle(.linear)
.tint(.Signal.accent)
.scaleEffect(x: 1, y: 1.5)
.padding(.vertical, 12)
Group {
Text(String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION",
comment: "Description for a progress bar tracking a multi-step backup operation. Embeds {{ the percentage complete of the overall operation, e.g. 20% }}."
),
percentComplete.formatted(.percent.precision(.fractionLength(0))),
))
let stepDescription = switch latestProgressUpdate.currentStep {
case .registerBackupId, .backupExport:
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_CREATING_BACKUP",
comment: "Description for a progress bar tracking a multi-step backup operation, where we are currently creating the backup."
)
case .backupUpload:
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_UPLOADING_BACKUP",
comment: "Description for a progress bar tracking a multi-step backup operation, where we are currently uploading the backup."
)
case .listMedia:
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_LISTING_MEDIA",
comment: "Description for a progress bar tracking a multi-step backup operation, where we are currently managing our backed-up media."
)
case .attachmentOrphaning, .attachmentUpload, .offloading:
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_UPLOADING_MEDIA",
comment: "Description for a progress bar tracking a multi-step backup operation, where we are currently uploading backup media."
)
}
Text(stepDescription)
}
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
Button {
viewModel.cancelManualBackup()
} label: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_MANUAL_BACKUP_CANCEL_BUTTON",
comment: "Title for a button shown under a progress bar tracking a manual backup, which lets the user cancel the backup."
))
}
.foregroundStyle(Color.Signal.label)
}
}
// MARK: -
private struct BackupAttachmentDownloadProgressView: View {
let latestDownloadUpdate: BackupSettingsAttachmentDownloadTracker.DownloadUpdate
let viewModel: BackupSettingsViewModel
var body: some View {
VStack(alignment: .leading) {
let progressViewColor: Color? = switch latestDownloadUpdate.state {
case .suspended:
nil
case .running, .pausedLowBattery, .pausedNeedsWifi, .pausedNeedsInternet:
.Signal.accent
case .outOfDiskSpace:
.yellow
}
let subtitleText: String = switch latestDownloadUpdate.state {
case .suspended:
switch viewModel.backupPlan {
case .disabled, .free, .paid, .paidAsTester:
String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_SUSPENDED",
comment: "Subtitle for a view explaining that downloads are available but not running. Embeds {{ the amount available to download as a file size, e.g. 100 MB }}."
),
latestDownloadUpdate.totalBytesToDownload.formatted(.byteCount(style: .decimal))
)
case .disabling, .paidExpiringSoon:
String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_SUSPENDED_PAID_SUBSCRIPTION_EXPIRING",
comment: "Subtitle for a view explaining that downloads are available but not running, and the user's paid subscription is expiring. Embeds {{ the amount available to download as a file size, e.g. 100 MB }}."
),
latestDownloadUpdate.totalBytesToDownload.formatted(.byteCount(style: .decimal))
)
}
case .running:
String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_RUNNING",
comment: "Subtitle for a progress bar 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% }}."
),
latestDownloadUpdate.bytesDownloaded.formatted(.byteCount(style: .decimal)),
latestDownloadUpdate.totalBytesToDownload.formatted(.byteCount(style: .decimal)),
latestDownloadUpdate.percentageDownloaded.formatted(.percent.precision(.fractionLength(0))),
)
case .pausedLowBattery:
OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_PAUSED_LOW_BATTERY",
comment: "Subtitle for a progress bar tracking downloads that are paused because of low battery."
)
case .pausedNeedsWifi:
OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_PAUSED_NEEDS_WIFI",
comment: "Subtitle for a progress bar tracking downloads that are paused because they need WiFi."
)
case .pausedNeedsInternet:
OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_PAUSED_NEEDS_INTERNET",
comment: "Subtitle for a progress bar tracking downloads that are paused because they need internet."
)
case .outOfDiskSpace(let bytesRequired):
String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_PAUSED_NEEDS_DISK_SPACE",
comment: "Subtitle for a progress bar tracking downloads that are paused because they need more disk space available. Embeds {{ the amount of space needed as a file size, e.g. 100 MB }}."
),
bytesRequired.formatted(.byteCount(style: .decimal))
)
}
if let progressViewColor {
ProgressView(value: latestDownloadUpdate.percentageDownloaded)
.progressViewStyle(.linear)
.tint(progressViewColor)
.scaleEffect(x: 1, y: 1.5)
.padding(.vertical, 12)
Text(subtitleText)
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
} else {
Text(subtitleText)
}
}
switch latestDownloadUpdate.state {
case .suspended:
Button {
viewModel.setIsBackupDownloadQueueSuspended(false)
} label: {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_ACTION_BUTTON_INITIATE_DOWNLOAD",
comment: "Title for a button shown in Backup Settings that lets a user initiate an available download."
))
.foregroundStyle(Color.Signal.label)
} icon: {
Image(uiImage: .arrowCircleDown)
.resizable()
.frame(width: 24, height: 24)
}
}
.foregroundStyle(Color.Signal.label)
case .running, .outOfDiskSpace:
Button {
viewModel.setIsBackupDownloadQueueSuspended(true)
} label: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_ACTION_BUTTON_CANCEL_DOWNLOAD",
comment: "Title for a button shown in Backup Settings that lets a user cancel an in-progress download."
))
}
.foregroundStyle(Color.Signal.label)
case .pausedNeedsWifi:
Button {
viewModel.setShouldAllowBackupDownloadsOnCellular()
} label: {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_ACTION_BUTTON_RESUME_DOWNLOAD_WITHOUT_WIFI",
comment: "Title for a button shown in Backup Settings that lets a user resume a download paused due to needing Wi-Fi."
))
} icon: {
Image(uiImage: .arrowCircleDown)
.resizable()
.frame(width: 24, height: 24)
}
}
.foregroundStyle(Color.Signal.label)
case .pausedLowBattery, .pausedNeedsInternet:
EmptyView()
}
}
}
// MARK: -
private struct BackupAttachmentUploadProgressView: View {
let latestUploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate
var body: some View {
VStack(alignment: .leading) {
ProgressView(value: latestUploadUpdate.percentageUploaded)
.progressViewStyle(.linear)
.tint(Color.Signal.accent)
.scaleEffect(x: 1, y: 1.5)
.padding(.vertical, 12)
let subtitleText: String = switch latestUploadUpdate.state {
case .running:
String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_RUNNING",
comment: "Subtitle for a progress bar tracking active uploading. Embeds 1:{{ the amount uploaded as a file size, e.g. 100 MB }}; 2:{{ the total amount to upload as a file size, e.g. 1 GB }}; 3:{{ the amount uploaded as a percentage, e.g. 10% }}."
),
latestUploadUpdate.bytesUploaded.formatted(.byteCount(style: .decimal)),
latestUploadUpdate.totalBytesToUpload.formatted(.byteCount(style: .decimal)),
latestUploadUpdate.percentageUploaded.formatted(.percent.precision(.fractionLength(0))),
)
case .pausedLowBattery:
OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_PAUSED_LOW_BATTERY",
comment: "Subtitle for a progress bar tracking uploads that are paused because of low battery."
)
case .pausedNeedsWifi:
OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_PAUSED_NEEDS_WIFI",
comment: "Subtitle for a progress bar tracking uploads that are paused because they need WiFi."
)
case .pausedNeedsInternet:
OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_PAUSED_NEEDS_INTERNET",
comment: "Subtitle for a progress bar tracking uploads that are paused because they need an internet connection"
)
}
Text(subtitleText)
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
}
// MARK: -
private struct BackupSubscriptionView: View {
let loadingState: BackupSettingsViewModel.BackupSubscriptionLoadingState
let viewModel: BackupSettingsViewModel
var body: some View {
switch loadingState {
case .loading:
VStack(alignment: .center) {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
// Force SwiftUI to redraw this if it re-appears (e.g.,
// because the user retried loading) instead of reusing one
// that will have stopped animating.
.id(UUID())
}
.frame(maxWidth: .infinity)
.frame(height: 140)
case .loaded(let loadedBackupSubscription):
loadedView(
loadedBackupSubscription: loadedBackupSubscription,
viewModel: viewModel
)
case .networkError:
VStack(alignment: .center) {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_NETWORK_ERROR_TITLE",
comment: "Title for a view indicating we failed to fetch someone's Backup plan due to a network error."
))
.font(.subheadline)
.bold()
.foregroundStyle(Color.Signal.secondaryLabel)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_NETWORK_ERROR_MESSAGE",
comment: "Message for a view indicating we failed to fetch someone's Backup plan due to a network error."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer().frame(height: 16)
Button {
viewModel.loadBackupSubscription()
} label: {
Text(CommonStrings.retryButton)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
.frame(maxWidth: .infinity)
.frame(minHeight: 140)
case .genericError:
VStack(alignment: .center) {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_GENERIC_ERROR_TITLE",
comment: "Title for a view indicating we failed to fetch someone's Backup plan due to an unexpected error."
))
.font(.subheadline)
.bold()
.foregroundStyle(Color.Signal.secondaryLabel)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_GENERIC_ERROR_MESSAGE",
comment: "Message for a view indicating we failed to fetch someone's Backup plan due to an unexpected error."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
.frame(maxWidth: .infinity)
.frame(minHeight: 140)
}
}
private func loadedView(
loadedBackupSubscription: BackupSettingsViewModel.BackupSubscriptionLoadingState.LoadedBackupSubscription,
viewModel: BackupSettingsViewModel
) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading) {
Group {
switch loadedBackupSubscription {
case .free:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_HEADER",
comment: "Header describing what the free backup plan includes."
))
case .paid, .paidButExpiring, .paidButExpired, .paidButFreeForTesters:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_HEADER",
comment: "Header describing what the paid backup plan includes."
))
}
}
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer().frame(height: 8)
switch loadedBackupSubscription {
case .free:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_DESCRIPTION",
comment: "Text describing the user's free backup plan."
))
case .paid(let price, let renewalDate):
let renewalStringFormat = OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_RENEWAL_FORMAT",
comment: "Text explaining when the user's paid backup plan renews. Embeds {{ the formatted renewal date }}."
)
let priceStringFormat = OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_PRICE_FORMAT",
comment: "Text explaining the price of the user's paid backup plan. Embeds {{ the formatted price }}."
)
Text(String(
format: priceStringFormat,
CurrencyFormatter.format(money: price)
))
Text(String(
format: renewalStringFormat,
DateFormatter.localizedString(from: renewalDate, dateStyle: .medium, timeStyle: .none)
))
case .paidButExpiring(let expirationDate), .paidButExpired(let expirationDate):
let expirationDateFormatString = switch loadedBackupSubscription {
case .free, .paid, .paidButFreeForTesters:
owsFail("Not possible")
case .paidButExpiring:
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_CANCELED_FUTURE_EXPIRATION_FORMAT",
comment: "Text explaining that a user's paid plan, which has been canceled, will expire on a future date. Embeds {{ the formatted expiration date }}."
)
case .paidButExpired:
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_CANCELED_PAST_EXPIRATION_FORMAT",
comment: "Text explaining that a user's paid plan, which has been canceled, expired on a past date. Embeds {{ the formatted expiration date }}."
)
}
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_CANCELED_DESCRIPTION",
comment: "Text describing that the user's paid backup plan has been canceled."
))
.foregroundStyle(Color.Signal.red)
Text(String(
format: expirationDateFormatString,
DateFormatter.localizedString(from: expirationDate, dateStyle: .medium, timeStyle: .none)
))
case .paidButFreeForTesters:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_FREE_FOR_TESTERS_DESCRIPTION",
comment: "Text describing that the user's backup plan is paid, but free for them as a tester."
))
}
Spacer().frame(height: 16)
Button {
switch loadedBackupSubscription {
case .free:
viewModel.upgradeFromFreeToPaidPlan()
case .paid, .paidButExpiring, .paidButExpired:
viewModel.manageOrCancelPaidPlan()
case .paidButFreeForTesters:
viewModel.managePaidPlanAsTester()
}
} label: {
switch loadedBackupSubscription {
case .free:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to upgrade from a free to paid backup plan."
))
case .paid:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to manage or cancel their paid backup plan."
))
case .paidButExpiring, .paidButExpired:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_CANCELED_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to reenable a paid backup plan that has been canceled."
))
case .paidButFreeForTesters:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_FREE_FOR_TESTERS_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to manage their backup plan as a tester."
))
}
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.foregroundStyle(Color.Signal.label)
.padding(.top, 8)
}
Spacer()
Image("backups-subscribed")
.frame(width: 56, height: 56)
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
}
}
// MARK: -
private struct BackupDetailsView: View {
let lastBackupDate: Date?
let lastBackupSizeBytes: UInt64?
let shouldAllowBackupUploadsOnCellular: Bool
let viewModel: BackupSettingsViewModel
var body: some View {
HStack {
let lastBackupMessage: String? = {
guard let lastBackupDate else {
return nil
}
let lastBackupDateString = DateFormatter.localizedString(from: lastBackupDate, dateStyle: .medium, timeStyle: .none)
let lastBackupTimeString = DateFormatter.localizedString(from: lastBackupDate, dateStyle: .none, timeStyle: .short)
if Calendar.current.isDateInToday(lastBackupDate) {
let todayFormatString = OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_TODAY_FORMAT",
comment: "Text explaining that the user's last backup was today. Embeds {{ the time of the backup }}."
)
return String(format: todayFormatString, lastBackupTimeString)
} else if Calendar.current.isDateInYesterday(lastBackupDate) {
let yesterdayFormatString = OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_YESTERDAY_FORMAT",
comment: "Text explaining that the user's last backup was yesterday. Embeds {{ the time of the backup }}."
)
return String(format: yesterdayFormatString, lastBackupTimeString)
} else {
let pastFormatString = OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_PAST_FORMAT",
comment: "Text explaining that the user's last backup was in the past. Embeds 1:{{ the date of the backup }} and 2:{{ the time of the backup }}."
)
return String(format: pastFormatString, lastBackupDateString, lastBackupTimeString)
}
}()
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_LABEL",
comment: "Label for a menu item explaining when the user's last backup occurred."
))
Spacer()
if let lastBackupMessage {
Text(lastBackupMessage)
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
HStack {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_SIZE_LABEL",
comment: "Label for a menu item explaining the size of the user's backup."
))
Spacer()
if let lastBackupSizeBytes {
Text(lastBackupSizeBytes.formatted(.byteCount(style: .decimal)))
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
Toggle(
OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_ON_CELLULAR_LABEL",
comment: "Label for a toggleable menu item describing whether to make backups on cellular data."
),
isOn: Binding(
get: { shouldAllowBackupUploadsOnCellular },
set: { viewModel.setShouldAllowBackupUploadsOnCellular($0) }
)
)
BackupViewKeyView(viewModel: viewModel)
}
}
// MARK: -
private struct BackupViewKeyView: View {
let viewModel: BackupSettingsViewModel
var body: some View {
Button {
viewModel.showViewBackupKey()
} label: {
HStack {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_VIEW_BACKUP_KEY_LABEL",
comment: "Label for a menu item offering to show the user their backup key."
))
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
.foregroundStyle(Color.Signal.label)
}
}
// MARK: - Previews
#if DEBUG
private extension BackupSettingsViewModel {
static func forPreview(
backupPlan: BackupPlan,
failedToDisableBackupsRemotely: Bool = false,
latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStep>? = nil,
latestBackupAttachmentDownloadUpdateState: BackupSettingsAttachmentDownloadTracker.DownloadUpdate.State? = nil,
latestBackupAttachmentUploadUpdateState: BackupSettingsAttachmentUploadTracker.UploadUpdate.State? = nil,
backupSubscriptionLoadingState: BackupSubscriptionLoadingState,
) -> BackupSettingsViewModel {
class PreviewActionsDelegate: ActionsDelegate {
func enableBackups(implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?) { print("Enabling! implicitPlanSelection: \(implicitPlanSelection as Any)") }
func disableBackups() { print("Disabling!") }
func loadBackupSubscription() { print("Loading BackupSubscription!") }
func upgradeFromFreeToPaidPlan() { print("Upgrading!") }
func manageOrCancelPaidPlan() { print("Managing or canceling!") }
func managePaidPlanAsTester() { print("Managing as tester!") }
func performManualBackup() { print("Manually backing up!") }
func cancelManualBackup() { print("Canceling manual backup!") }
func setShouldAllowBackupUploadsOnCellular(_ newShouldAllowBackupUploadsOnCellular: Bool) { print("Uploads on cellular: \(newShouldAllowBackupUploadsOnCellular)") }
func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) { print("Optimize local storage: \(newOptimizeLocalStorage)") }
func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan) { print("Download queue suspended: \(isSuspended) \(backupPlan)") }
func setShouldAllowBackupDownloadsOnCellular() { print("Downloads on cellular: true") }
func showViewBackupKey() { print("Showing View Backup Key!") }
func showCreateNewBackupKey() { print("Showing Create New Backup Key!") }
}
let viewModel = BackupSettingsViewModel(
backupSubscriptionLoadingState: backupSubscriptionLoadingState,
backupPlan: backupPlan,
failedToDisableBackupsRemotely: failedToDisableBackupsRemotely,
latestBackupExportProgressUpdate: latestBackupExportProgressUpdate,
latestBackupAttachmentDownloadUpdate: latestBackupAttachmentDownloadUpdateState.map {
BackupSettingsAttachmentDownloadTracker.DownloadUpdate(
state: $0,
bytesDownloaded: 1_400_000_000,
totalBytesToDownload: 1_600_000_000,
)
},
latestBackupAttachmentUploadUpdate: latestBackupAttachmentUploadUpdateState.map {
BackupSettingsAttachmentUploadTracker.UploadUpdate(
state: $0,
bytesUploaded: 400_000_000,
totalBytesToUpload: 1_600_000_000,
)
},
lastBackupDate: Date().addingTimeInterval(-1 * .day),
lastBackupSizeBytes: 2_400_000_000,
shouldAllowBackupUploadsOnCellular: false
)
let actionsDelegate = PreviewActionsDelegate()
viewModel.actionsDelegate = actionsDelegate
ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
return viewModel
}
}
#Preview("Plan: Paid") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paid(optimizeLocalStorage: false),
backupSubscriptionLoadingState: .loaded(.paid(
price: FiatMoney(currencyCode: "USD", value: 1.99),
renewalDate: Date().addingTimeInterval(.week)
)),
))
}
#Preview("Plan: Free") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Plan: Free For Testers") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paidAsTester(optimizeLocalStorage: false),
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Plan: Expiring") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
backupSubscriptionLoadingState: .loaded(.paidButExpiring(
expirationDate: Date().addingTimeInterval(.week)
))
))
}
#Preview("Plan: Expired") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
backupSubscriptionLoadingState: .loaded(.paidButExpired(
expirationDate: Date().addingTimeInterval(-1 * .week)
))
))
}
#Preview("Plan: Network Error") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paid(optimizeLocalStorage: false),
backupSubscriptionLoadingState: .networkError
))
}
#Preview("Plan: Generic Error") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paid(optimizeLocalStorage: false),
backupSubscriptionLoadingState: .genericError
))
}
extension OWSSequentialProgress<BackupExportJobStep> {
static func forPreview(
_ step: BackupExportJobStep,
_ progress: Float
) -> OWSSequentialProgress<BackupExportJobStep> {
return OWSProgress(
completedUnitCount: UInt64(progress * 100),
totalUnitCount: 100,
childProgresses: [
step.rawValue: [OWSProgress.ChildProgress(
completedUnitCount: 1,
totalUnitCount: 2,
label: step.rawValue,
parentLabel: nil
)]
]
).sequential(BackupExportJobStep.self)
}
}
#Preview("Manual Backup: Backup Export") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.backupExport, 0.33),
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Manual Backup: Backup Upload") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.backupUpload, 0.45),
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Manual Backup: Listing Media") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.listMedia, 0.50),
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Manual Backup: Media Upload") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload, 0.80),
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Downloads: Suspended") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .paid(optimizeLocalStorage: false),
latestBackupAttachmentDownloadUpdateState: .suspended,
backupSubscriptionLoadingState: .loaded(.paid(
price: FiatMoney(currencyCode: "USD", value: 1.99),
renewalDate: Date().addingTimeInterval(.week)
))
))
}
#Preview("Downloads: Suspended w/o Paid Plan") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .suspended,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Downloads: Running") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .running,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Downloads: Paused (Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedLowBattery,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Downloads: Paused (WiFi)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedNeedsWifi,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Downloads: Paused (Internet)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedNeedsInternet,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Downloads: Disk Space Error") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .outOfDiskSpace(bytesRequired: 200_000_000),
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Uploads: Running") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentUploadUpdateState: .running,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Uploads: Paused (WiFi)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentUploadUpdateState: .pausedNeedsWifi,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Uploads: Paused (Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentUploadUpdateState: .pausedLowBattery,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Disabling: Success") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .disabled,
backupSubscriptionLoadingState: .loaded(.free),
))
}
#Preview("Disabling: Remotely") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .disabling,
backupSubscriptionLoadingState: .loaded(.free),
))
}
#Preview("Disabling: Remotely Failed") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .disabled,
failedToDisableBackupsRemotely: true,
backupSubscriptionLoadingState: .loaded(.free),
))
}
#endif