Signal-iOS/Signal/Backups/BackupSettingsViewController.swift
2025-10-27 13:27:11 -05:00

2833 lines
115 KiB
Swift

//
// 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 OnAppearAction {
case presentWelcomeToBackupsSheet
}
private let accountEntropyPoolManager: AccountEntropyPoolManager
private let accountKeyStore: AccountKeyStore
private let backupAttachmentDownloadTracker: BackupSettingsAttachmentDownloadTracker
private let backupAttachmentUploadStore: BackupAttachmentUploadStore
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 subscriptionConfigManager: SubscriptionConfigManager
private let tsAccountManager: TSAccountManager
private var onAppearAction: OnAppearAction?
private let viewModel: BackupSettingsViewModel
private var externalEventObservationTasks: [Task<Void, Never>] = []
convenience init(
onAppearAction: OnAppearAction?,
) {
guard let deviceSleepManager = DependenciesBridge.shared.deviceSleepManager else {
owsFail("Unexpectedly missing DeviceSleepManager in main app!")
}
self.init(
onAppearAction: onAppearAction,
accountEntropyPoolManager: DependenciesBridge.shared.accountEntropyPoolManager,
accountKeyStore: DependenciesBridge.shared.accountKeyStore,
backupAttachmentDownloadProgress: DependenciesBridge.shared.backupAttachmentDownloadProgress,
backupAttachmentDownloadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentDownloadQueueStatusReporter,
backupAttachmentUploadProgress: DependenciesBridge.shared.backupAttachmentUploadProgress,
backupAttachmentUploadQueueStatusReporter: DependenciesBridge.shared.backupAttachmentUploadQueueStatusReporter,
backupAttachmentUploadStore: DependenciesBridge.shared.backupAttachmentUploadStore,
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,
subscriptionConfigManager: DependenciesBridge.shared.subscriptionConfigManager,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
)
}
init(
onAppearAction: OnAppearAction?,
accountEntropyPoolManager: AccountEntropyPoolManager,
accountKeyStore: AccountKeyStore,
backupAttachmentDownloadProgress: BackupAttachmentDownloadProgress,
backupAttachmentDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter,
backupAttachmentUploadProgress: BackupAttachmentUploadProgress,
backupAttachmentUploadQueueStatusReporter: BackupAttachmentUploadQueueStatusReporter,
backupAttachmentUploadStore: BackupAttachmentUploadStore,
backupDisablingManager: BackupDisablingManager,
backupEnablingManager: BackupEnablingManager,
backupExportJobRunner: BackupExportJobRunner,
backupPlanManager: BackupPlanManager,
backupSettingsStore: BackupSettingsStore,
backupSubscriptionManager: BackupSubscriptionManager,
db: DB,
deviceSleepManager: DeviceSleepManager,
subscriptionConfigManager: SubscriptionConfigManager,
tsAccountManager: TSAccountManager,
) {
owsPrecondition(
db.read { tsAccountManager.registrationState(tx: $0).isPrimaryDevice == true },
"Unsafe to let a linked device access Backup Settings!"
)
self.accountEntropyPoolManager = accountEntropyPoolManager
self.accountKeyStore = accountKeyStore
self.backupAttachmentDownloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: backupAttachmentDownloadQueueStatusReporter,
backupAttachmentDownloadProgress: backupAttachmentDownloadProgress
)
self.backupAttachmentUploadTracker = BackupSettingsAttachmentUploadTracker(
backupAttachmentUploadQueueStatusReporter: backupAttachmentUploadQueueStatusReporter,
backupAttachmentUploadProgress: backupAttachmentUploadProgress
)
self.backupAttachmentUploadStore = backupAttachmentUploadStore
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.subscriptionConfigManager = subscriptionConfigManager
self.tsAccountManager = tsAccountManager
self.onAppearAction = onAppearAction
self.viewModel = db.read { tx in
let viewModel = BackupSettingsViewModel(
backupSubscriptionConfiguration: subscriptionConfigManager.backupConfigurationOrDefault(tx: tx),
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),
mediaTierCapacityOverflow: Self.getMediaTierCapacityOverflow(
backupAttachmentUploadStore: backupAttachmentUploadStore,
backupSettingsStore: backupSettingsStore,
tx: tx
),
hasBackupFailed: backupSettingsStore.getLastBackupFailed(tx: tx)
)
return viewModel
}
super.init(wrappedView: BackupSettingsView(viewModel: viewModel))
title = OWSLocalizedString(
"BACKUPS_SETTINGS_TITLE",
comment: "Title for the 'Backup' settings menu."
)
OWSTableViewController2.removeBackButtonText(viewController: self)
viewModel.actionsDelegate = self
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await refreshBackupSubscriptionConfig()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
switch onAppearAction.take() {
case nil:
break
case .presentWelcomeToBackupsSheet:
presentWelcomeToBackupsSheet()
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startExternalEventObservation()
// Reload the view model, as state may have changed while we weren't
// visible.
reloadViewModel()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopExternalEventObservation()
}
// MARK: -
/// Refresh the `BackupSubscriptionConfig` we loaded during `init`.
///
/// Covers the niche case in which we hadn't successfully fetched the config
/// before init, prompting to contact support if we fail here as well (maybe
/// we're having parsing issues or something).
private func refreshBackupSubscriptionConfig() async {
do {
let backupSubscriptionConfig = try await subscriptionConfigManager.backupConfiguration()
// If we loaded a different BackupSubscriptionConfig than what we
// got during init, swap it in.
if viewModel.backupSubscriptionConfiguration != backupSubscriptionConfig {
viewModel.backupSubscriptionConfiguration = backupSubscriptionConfig
}
} catch where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse {
// Ignore network failures.
} catch {
owsFailDebug("Failed to fetch Backup subscription config!")
ActionSheetDisplayableError.genericError.showActionSheet(from: self)
}
}
private func startExternalEventObservation() {
guard externalEventObservationTasks.isEmpty else {
return
}
externalEventObservationTasks = [
Task { [weak self, backupExportJobRunner] in
await self?.preventDeviceSleepDuringNonNilUpdates(
updateStream: backupExportJobRunner.updates(),
label: "Export",
) { [weak self] exportJobUpdate in
guard let self else { return }
switch exportJobUpdate {
case nil:
viewModel.latestBackupExportProgressUpdate = nil
case .progress(let progressUpdate):
viewModel.latestBackupExportProgressUpdate = progressUpdate
case .completion(let result):
viewModel.latestBackupExportProgressUpdate = nil
switch result {
case .success:
break
case .failure(let exportJobError):
switch exportJobError {
case .cancellationError, .needsWifi, .networkRequestError:
Logger.warn("Failed to perform manual backup! \(exportJobError)")
case .backupError, .backupKeyError, .unregistered:
owsFailDebug("Failed to perform manual backup! \(exportJobError)")
}
showSheetForBackupExportJobError(exportJobError)
}
db.read { tx in
self.viewModel.lastBackupDate = self.backupSettingsStore.lastBackupDate(tx: tx)
self.viewModel.lastBackupSizeBytes = self.backupSettingsStore.lastBackupSizeBytes(tx: tx)
self.viewModel.hasBackupFailed = self.backupSettingsStore.getLastBackupFailed(tx: tx)
}
}
}
},
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()
}
}
},
Task.detached { [weak self] in
for await _ in NotificationCenter.default.notifications(
named: .hasConsumedMediaTierCapacityStatusDidChange
) {
await MainActor.run { [weak self] in
guard let self else { return }
_hasConsumedMediaTierCapacityDidChange()
}
}
}
]
}
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)
}
}
private func _hasConsumedMediaTierCapacityDidChange() {
db.read { tx in
viewModel.mediaTierCapacityOverflow = Self.getMediaTierCapacityOverflow(
backupAttachmentUploadStore: backupAttachmentUploadStore,
backupSettingsStore: backupSettingsStore,
tx: tx
)
}
}
private static func getMediaTierCapacityOverflow(
backupAttachmentUploadStore: BackupAttachmentUploadStore,
backupSettingsStore: BackupSettingsStore,
tx: DBReadTransaction
) -> UInt64? {
let hasConsumedMediaTierCapacity = backupSettingsStore.hasConsumedMediaTierCapacity(tx: tx)
if hasConsumedMediaTierCapacity {
return (try? backupAttachmentUploadStore.totalEstimatedFullsizeBytesToUpload(tx: tx)) ?? 0
} else {
return nil
}
}
// 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 {
do throws(ActionSheetDisplayableError) {
let chooseBackupPlanViewController: 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
)
}
}
)
navigationController?.pushViewController(
chooseBackupPlanViewController,
animated: true
)
} catch {
error.showActionSheet(from: self)
}
}
@MainActor
private func _enableBackups(
fromViewController: UIViewController,
planSelection: ChooseBackupPlanViewController.PlanSelection
) async {
do throws(ActionSheetDisplayableError) {
try await backupEnablingManager.enableBackups(
fromViewController: fromViewController,
planSelection: planSelection
)
navigationController?.popToViewController(self, animated: true) { [self] in
presentWelcomeToBackupsSheet()
}
} catch {
error.showActionSheet(from: fromViewController)
}
}
private func presentWelcomeToBackupsSheet() {
final class WelcomeToBackupsSheet: HeroSheetViewController {
override var canBeDismissed: Bool { false }
init(
onConfirm: @escaping () -> Void,
) {
super.init(
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: CommonStrings.okButton,
action: { _ in onConfirm() }
)),
)
}
}
let welcomeToBackupsSheet = WelcomeToBackupsSheet { [self] in
viewModel.performManualBackup()
dismiss(animated: true)
}
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: { [weak self] _ in
guard let self else { return }
let isRegisteredPrimaryDevice = db.read { tx in
self.tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice
}
guard isRegisteredPrimaryDevice 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
}
Task {
await self._disableBackups(aepSideEffect: nil)
}
},
))
actionSheet.addAction(.cancel)
presentActionSheet(actionSheet)
}
@MainActor
private func _disableBackups(aepSideEffect: BackupDisablingManager.AEPSideEffect?) async {
let backupPlanBeforeDisabling = viewModel.backupPlan
// If we were running a manual Backup, cancel it. Most of the manual
// Backup steps will respond to BackupPlan changing, but for example the
// message-export stage (ensconced in its own DB transaction) will not.
cancelManualBackup()
// 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(
aepSideEffect: aepSideEffect,
)
switch currentDownloadQueueStatus {
case .empty, .suspended, .notRegisteredAndReady, .appBackgrounded:
break
case .running, .noWifiReachability, .noReachability, .lowBattery, .lowPowerMode, .lowDiskSpace:
let downloadsActionSheet = ActionSheetController(
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."
),
)
await OWSActionSheets.showAndAwaitActionSheet(downloadsActionSheet, fromViewController: self)
}
switch backupPlanBeforeDisabling {
case .disabled, .disabling, .free, .paidAsTester, .paidExpiringSoon:
break
case .paid:
// If the user still has a paid subscription, suggest that they
// cancel it.
let cancelSubscriptionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_SUBSCRIPTION_CANCEL_ACTION_SHEET_TITLE",
comment: "Title for an action sheet shown when the user disables Backups, but is still subscribed to the paid plan.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_SUBSCRIPTION_CANCEL_ACTION_SHEET_MESSAGE",
comment: "Message for an action sheet shown when the user disables Backups, but is still subscribed to the paid plan.",
),
)
cancelSubscriptionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_SUBSCRIPTION_CANCEL_ACTION_SHEET_MANAGE_SUBSCRIPTION_BUTTON",
comment: "Button for an action sheet shown when the user disables Backups, letting them manage their subscription.",
),
handler: { [weak self] _ in
guard let self else { return }
manageOrCancelPaidPlan()
}
))
cancelSubscriptionSheet.addAction(.cancel)
await OWSActionSheets.showAndAwaitActionSheet(cancelSubscriptionSheet, fromViewController: self)
}
}
private func showDisablingBackupsFailedSheet() {
OWSActionSheets.showContactSupportActionSheet(
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."
),
emailFilter: .backupDisableFailed,
fromViewController: self
)
}
// MARK: -
private let loadBackupSubscriptionTaskQueue = SerialTaskQueue()
fileprivate func loadBackupSubscription() {
loadBackupSubscriptionTaskQueue.enqueueCancellingPrevious { @MainActor [self] in
if Task.isCancelled {
return
}
withAnimation {
viewModel.backupSubscriptionLoadingState = .loading
}
let newLoadingState: BackupSettingsViewModel.BackupSubscriptionLoadingState
do {
let backupSubscription = try await _loadBackupSubscription()
newLoadingState = .loaded(backupSubscription)
} catch is CancellationError {
// We were cancelled: leave it loading. Whoever cancelled us
// should be trying again.
return
} 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() {
// We observe updates from BackupExportJob, including when it
// finishes, so all we need to do here is kick it off.
backupExportJobRunner.startIfNecessary()
}
fileprivate func cancelManualBackup() {
backupExportJobRunner.cancelIfRunning()
suspendUploads()
}
fileprivate func suspendUploads() {
db.write {
self.backupSettingsStore.setIsBackupUploadQueueSuspended(true, tx: $0)
}
}
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.Support.backups,
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(true, tx: tx)
}
}
// MARK: -
fileprivate func showViewRecoveryKey() {
Task { await _showViewRecoveryKey() }
}
@MainActor
private func _showViewRecoveryKey() 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: [.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.
showCreateNewRecoveryKeyWarningSheet(fromViewController: recordKeyViewController)
},
)
navigationController?.pushViewController(recordKeyViewController, animated: true)
}
private func showCreateNewRecoveryKeyWarningSheet(
fromViewController: BackupRecordKeyViewController,
) {
let (
currentBackupPlan,
isRegisteredPrimaryDevice,
) = db.read { tx in
return (
backupSettingsStore.backupPlan(tx: tx),
tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
)
}
guard isRegisteredPrimaryDevice else {
OWSActionSheets.showActionSheet(
message: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_ERROR_NOT_REGISTERED",
comment: "Message shown in an action sheet when the user tries to create a new Recovery Key, but is not registered."
),
fromViewController: self
)
return
}
let primaryButtonTitle: String
switch currentBackupPlan {
case .disabling:
// For simplicity, if we're already disabling don't allow creating a
// new key. We may be disabling because of an earlier "create new
// key" action, and we don't want ambiguity about which key is the
// "latest".
//
// At the time of writing, you can't get to this flow if BackupPlan
// is .disabling, so this dead-ends instead of showing a nice error.
owsFail("Trying to show Create New Key sheet, but BackupPlan is .disabling. How did the UI let us get here?")
case .disabled:
primaryButtonTitle = CommonStrings.continueButton
case .free, .paid, .paidExpiringSoon, .paidAsTester:
primaryButtonTitle = OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_BACKUPS_MUST_BE_DISABLED_TITLE",
comment: "TItle for a sheet warning users that Backups must be disabled to create a new Recovery Key."
)
}
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 Recovery Key."
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_BODY",
comment: "Body for a sheet warning users about creating a new Recovery Key."
),
primary: .button(HeroSheetViewController.Button(
title: primaryButtonTitle,
action: { sheet in
sheet.dismiss(animated: true) { [weak self] in
guard let self else { return }
showRecordNewRecoveryKey()
}
}
)),
secondary: .button(.dismissing(
title: CommonStrings.cancelButton,
style: .secondary,
)),
)
fromViewController.present(warningSheet, animated: true)
}
private func showRecordNewRecoveryKey() {
let newCandidateAEP = AccountEntropyPool()
let recordKeyViewController = BackupRecordKeyViewController(
aepMode: .newCandidate(newCandidateAEP),
options: [.showContinueButton],
onContinuePressed: { [weak self] _ in
guard let self else { return }
showConfirmNewRecoveryKey(newCandidateAEP: newCandidateAEP)
}
)
navigationController?.pushViewController(recordKeyViewController, animated: true)
}
private func showConfirmNewRecoveryKey(newCandidateAEP: AccountEntropyPool) {
let confirmKeyViewController = BackupConfirmKeyViewController(
aep: newCandidateAEP,
onContinue: { [weak self] _ in
guard let self else { return }
self.finalizeNewRecoveryKey(newCandidateAEP: newCandidateAEP)
// 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 Recovery 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)
}
private func finalizeNewRecoveryKey(newCandidateAEP: AccountEntropyPool) {
db.write { tx in
switch backupSettingsStore.backupPlan(tx: tx) {
case .disabled:
Logger.warn("Rotating AEP.")
accountEntropyPoolManager.setAccountEntropyPool(
newAccountEntropyPool: newCandidateAEP,
disablePIN: false,
tx: tx
)
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
Logger.warn("Disabling Backups, then rotating AEP.")
Task {
await _disableBackups(aepSideEffect: .rotate(newAEP: newCandidateAEP))
}
}
}
}
}
// 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 suspendUploads()
func setShouldAllowBackupUploadsOnCellular(_ newShouldAllowBackupUploadsOnCellular: Bool)
func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool)
func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan)
func setShouldAllowBackupDownloadsOnCellular()
func showViewRecoveryKey()
}
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 backupSubscriptionConfiguration: BackupSubscriptionConfiguration
@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
/// Nil means has not consumed capacity; non-nil value represents the total byte count over
/// the server side capacity all local attachments consume (meaning that's how many bytes
/// the user has to delete to go back under storage quota).
@Published var mediaTierCapacityOverflow: UInt64?
@Published var hasBackupFailed: Bool
weak var actionsDelegate: ActionsDelegate?
init(
backupSubscriptionConfiguration: BackupSubscriptionConfiguration,
backupSubscriptionLoadingState: BackupSubscriptionLoadingState,
backupPlan: BackupPlan,
failedToDisableBackupsRemotely: Bool,
latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStep>?,
latestBackupAttachmentDownloadUpdate: BackupSettingsAttachmentDownloadTracker.DownloadUpdate?,
latestBackupAttachmentUploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate?,
lastBackupDate: Date?,
lastBackupSizeBytes: UInt64?,
shouldAllowBackupUploadsOnCellular: Bool,
mediaTierCapacityOverflow: UInt64?,
hasBackupFailed: Bool,
) {
self.backupSubscriptionConfiguration = backupSubscriptionConfiguration
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
self.mediaTierCapacityOverflow = mediaTierCapacityOverflow
self.hasBackupFailed = hasBackupFailed
}
// 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()
}
func suspendUploads() {
actionsDelegate?.suspendUploads()
}
// 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 showViewRecoveryKey() {
actionsDelegate?.showViewRecoveryKey()
}
}
// 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, .pausedLowPowerMode, .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(
backupSubscriptionConfiguration: viewModel.backupSubscriptionConfiguration,
loadingState: viewModel.backupSubscriptionLoadingState,
viewModel: viewModel
)
}
switch contents {
case .enabled:
SignalSection {
if viewModel.hasBackupFailed {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_FAILED_MESSAGE",
comment: "Message describing to the user that the last backup failed."
))
.font(.footnote)
.multilineTextAlignment(.leading)
} icon: {
Image(
uiImage: UIImage.buildBadgeImage(
size: .square(10),
color: UIColor.Signal.yellow
)
)
}
}
if let latestBackupExportProgressUpdate = viewModel.latestBackupExportProgressUpdate {
BackupExportProgressView(
latestExportProgressUpdate: latestBackupExportProgressUpdate,
latestAttachmentUploadUpdate: viewModel.latestBackupAttachmentUploadUpdate,
)
CancelManualBackupButton {
viewModel.cancelManualBackup()
}
} else if let mediaTierCapacityOverflow = viewModel.mediaTierCapacityOverflow {
VStack(alignment: .leading) {
Label {
Text(
String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_PAUSED_OUT_OF_STORAGE_SPACE_FORMAT",
comment: "Subtitle for a progress bar tracking uploads that are paused because the user is out of remote storage space. Embeds 1:{{ total storage space provided, e.g. 100 GB }}; 2:{{ space the user needs to free up by deleting media, e.g. 1 GB }}."
),
viewModel.backupSubscriptionConfiguration.storageAllowanceBytes.formatted(.owsByteCount),
max(
// Always display at least 5 MB
1000 * 1000 * 5,
Int64(clamping: mediaTierCapacityOverflow)
).formatted(.owsByteCount)
)
)
.appendLink(CommonStrings.learnMore, useBold: true, tint: .Signal.label) {
CurrentAppContext().open(
URL.Support.backups,
completion: nil
)
}
.font(.subheadline)
.foregroundStyle(Color.Signal.label)
.monospacedDigit()
.multilineTextAlignment(.leading)
} icon: {
Image(
uiImage: UIImage(named: "error-circle-fill-compact")!
)
}
}
VStack(alignment: .leading) {
PerformManualBackupButton {
viewModel.performManualBackup()
}
}
} else if let latestBackupAttachmentUploadUpdate = viewModel.latestBackupAttachmentUploadUpdate {
BackupAttachmentUploadProgressView(
latestUploadUpdate: latestBackupAttachmentUploadUpdate
)
CancelManualBackupButton {
viewModel.suspendUploads()
}
} else {
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:
BackupAttachmentDownloadProgressView(
backupPlan: viewModel.backupPlan,
latestDownloadUpdate: latestBackupAttachmentDownloadUpdate,
viewModel: viewModel,
)
}
}
PerformManualBackupButton {
viewModel.performManualBackup()
}
}
} 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,
)
if BuildFlags.Backups.showOptimizeMedia {
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: {
if BuildFlags.Backups.showOptimizeMedia {
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)
.font(.caption)
}
}
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(
backupPlan: viewModel.backupPlan,
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) {
StyledProgressBar(style: .indeterminate)
Spacer().frame(height: 8)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_PROGRESS_VIEW_DESCRIPTION",
comment: "Description for a progress view tracking Backups being disabled."
))
.foregroundStyle(Color.Signal.secondaryLabel)
}
.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 {
private struct ProgressBarState {
let style: StyledProgressBar.Style
let label: String
}
let latestExportProgressUpdate: OWSSequentialProgress<BackupExportJobStep>
let latestAttachmentUploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate?
private var progressBarState: ProgressBarState {
switch latestExportProgressUpdate.currentStep {
case .backupExport, .backupUpload:
let percentExportCompleted = latestExportProgressUpdate.progress(for: .backupExport)?.percentComplete ?? 0
let percentUploadCompleted = latestExportProgressUpdate.progress(for: .backupUpload)?.percentComplete ?? 0
let percentComplete = (0.95 * percentExportCompleted) + (0.05 * percentUploadCompleted)
return ProgressBarState(
style: .determinate(percentComplete: percentComplete),
label: String(
format: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_PREPARING_BACKUP",
comment: "Description for a progress bar tracking the preparation of a Backup. Embeds 1:{{ the percentage completed preformatted as a percent, e.g. 10% }}."
),
percentComplete.formatted(.percent.precision(.fractionLength(0)))
)
)
case .listMedia, .attachmentOrphaning,
.attachmentUpload where latestAttachmentUploadUpdate == nil:
return ProgressBarState(
style: .indeterminate,
label: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_PROCESSING_MEDIA",
comment: "Description for a progress bar tracking the processing of Backup media."
)
)
case .attachmentUpload:
// If this is nil, we'll be in the case above.
let latestAttachmentUploadUpdate = latestAttachmentUploadUpdate!
return ProgressBarState(
style: .determinate(percentComplete: latestAttachmentUploadUpdate.percentageUploaded),
label: BackupAttachmentUploadProgressView.subtitleText(
uploadUpdate: latestAttachmentUploadUpdate,
)
)
case .offloading:
return ProgressBarState(
style: .indeterminate,
label: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_PROGRESS_DESCRIPTION_OPTIMIZING_MEDIA",
comment: "Description for a progress bar tracking the optimizing of Backup media."
)
)
}
}
var body: some View {
VStack(alignment: .leading) {
let progressBarState = self.progressBarState
StyledProgressBar(style: progressBarState.style)
Text(progressBarState.label)
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
.monospacedDigit()
}
}
}
// MARK: -
private struct CancelManualBackupButton: View {
let onTap: () -> Void
var body: some View {
Button {
onTap()
} 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)
}
}
private struct PerformManualBackupButton: View {
let onTap: () -> Void
var body: some View {
Button {
onTap()
} 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)
}
}
// MARK: -
private struct StyledProgressBar: View {
enum Style {
case determinate(percentComplete: Float)
case indeterminate
}
let style: Style
var body: some View {
VStack {
switch style {
case .determinate(let percentComplete):
PulsingProgressBar(value: percentComplete)
.tint(.Signal.accent)
.clipShape(RoundedRectangle(cornerRadius: 3))
case .indeterminate:
LottieView(animation: .named("linear_indeterminate"))
.playing(loopMode: .loop)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
.scaleEffect(x: 1, y: 1.5)
.padding(.vertical, 12)
}
}
private struct PulsingProgressBar: View {
struct ClearTrackProgressView: UIViewRepresentable {
let value: Float
let tintColor: UIColor
func makeUIView(context: Context) -> UIProgressView {
let progressView = UIProgressView()
progressView.trackTintColor = .clear
progressView.progressTintColor = tintColor
return progressView
}
func updateUIView(_ uiView: UIProgressView, context: Context) {
uiView.setProgress(value, animated: false)
}
}
let value: Float
let animationDuration: TimeInterval = 1
let stopAfter: TimeInterval = 3
init(value: Float) {
self.value = value
}
@State private var animationPart1Progress: Float = 0
@State private var animationPart2Progress: Float = 0
@State private var animationPart3Progress: Float = 0
@State private var lastValue: Float?
@State private var isAnimating = true
@State private var animationTimer: Timer?
@State private var animationStopTimer: Timer?
var body: some View {
ZStack {
ProgressView(value: value)
.progressViewStyle(.linear)
ClearTrackProgressView
.init(
value: value * animationPart1Progress,
tintColor: .tintColor
.blended(with: .white, alpha: 0.2)
)
ClearTrackProgressView
.init(
value: value * animationPart2Progress,
tintColor: .tintColor
)
.onAppear {
// The animation gets started once and runs forever;
// it just no-ops on each loop if not animating.
startLoopingAnimation()
}
.onChange(of: value) { newValue in
if lastValue != newValue {
// When the value changes, reset
// the stop timer.
startStopTimer()
}
}
.onDisappear {
self.animationTimer?.invalidate()
self.animationTimer = nil
self.animationStopTimer?.invalidate()
self.animationStopTimer = nil
self.isAnimating = true
}
}
}
private func startLoopingAnimation() {
self.animationTimer = Timer.scheduledTimer(
withTimeInterval: animationDuration / 100,
repeats: true,
block: { _ in
// Don't animate under 20%; it looks ugly
guard self.isAnimating, (self.lastValue ?? 0) > 0.2 else {
animationPart1Progress = 0
animationPart2Progress = 0
animationPart3Progress = 0
return
}
if animationPart1Progress < 0.75 {
animationPart1Progress += 0.01
} else if animationPart2Progress < 0.99 {
if animationPart1Progress < 0.99 {
animationPart1Progress += 0.01
}
animationPart2Progress += 0.01
} else if animationPart3Progress < 1 {
animationPart3Progress += 0.01
} else {
animationPart1Progress = 0
animationPart2Progress = 0
animationPart3Progress = 0
}
}
)
startStopTimer()
}
/// We stop the animation after stopAfter seconds of no updates.
private func startStopTimer() {
self.animationStopTimer?.invalidate()
self.isAnimating = true
self.animationStopTimer = Timer.scheduledTimer(
withTimeInterval: stopAfter,
repeats: false,
block: { [self] _ in
self.isAnimating = false
}
)
self.lastValue = value
}
}
// MARK: -
private struct BackupAttachmentDownloadProgressView: View {
let backupPlan: BackupPlan
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, .pausedLowPowerMode, .pausedNeedsWifi, .pausedNeedsInternet:
.Signal.accent
case .outOfDiskSpace:
.yellow
}
let subtitleText: String = switch latestDownloadUpdate.state {
case .suspended:
switch 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(.owsByteCount)
)
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(.owsByteCount)
)
}
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(.owsByteCount),
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount),
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 .pausedLowPowerMode:
OWSLocalizedString(
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_PAUSED_LOW_POWER_MODE",
comment: "Subtitle for a progress bar tracking downloads that are paused because of low power mode."
)
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(.owsByteCount)
)
}
if let progressViewColor {
PulsingProgressBar(value: latestDownloadUpdate.percentageDownloaded)
.tint(progressViewColor)
.scaleEffect(x: 1, y: 1.5)
.padding(.vertical, 12)
Text(subtitleText)
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
.monospacedDigit()
} 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, .pausedLowPowerMode, .pausedNeedsInternet:
EmptyView()
}
}
}
// MARK: -
private struct BackupAttachmentUploadProgressView: View {
let latestUploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate
var body: some View {
VStack(alignment: .leading) {
PulsingProgressBar(value: latestUploadUpdate.percentageUploaded)
.tint(Color.Signal.accent)
.scaleEffect(x: 1, y: 1.5)
.padding(.vertical, 12)
let subtitleText: String = Self.subtitleText(uploadUpdate: latestUploadUpdate)
Text(subtitleText)
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
.monospacedDigit()
}
}
static func subtitleText(
uploadUpdate: BackupSettingsAttachmentUploadTracker.UploadUpdate
) -> String {
switch uploadUpdate.state {
case .running:
let bytesUploaded = uploadUpdate.bytesUploaded
let totalBytesToUpload = uploadUpdate.totalBytesToUpload
let percentageUploaded = uploadUpdate.percentageUploaded
return 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 percentage uploaded as a percent, e.g. 40% }}."
),
bytesUploaded.formatted(.owsByteCount),
totalBytesToUpload.formatted(.owsByteCount),
percentageUploaded.formatted(.percent.precision(.fractionLength(0)))
)
case .pausedLowBattery:
return 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 .pausedLowPowerMode:
return OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_PAUSED_LOW_POWER_MODE",
comment: "Subtitle for a progress bar tracking uploads that are paused because of low power mode."
)
case .pausedNeedsWifi:
return 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:
return 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"
)
}
}
}
// MARK: -
private struct BackupSubscriptionView: View {
let backupSubscriptionConfiguration: BackupSubscriptionConfiguration
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(
backupSubscriptionConfiguration: backupSubscriptionConfiguration,
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(
backupSubscriptionConfiguration: BackupSubscriptionConfiguration,
loadedBackupSubscription: BackupSettingsViewModel.BackupSubscriptionLoadingState.LoadedBackupSubscription,
viewModel: BackupSettingsViewModel
) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading) {
Group {
switch loadedBackupSubscription {
case .free:
Text(String.localizedStringWithFormat(
OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_HEADER_%d",
tableName: "PluralAware",
comment: "Header describing what the free backup plan includes. Embeds {{ the number of days that files are available, e.g. '45' }}."
),
backupSubscriptionConfiguration.freeTierMediaDays,
))
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)
.font(.subheadline)
.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)
}
}
if let lastBackupSizeBytes {
HStack {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_SIZE_LABEL",
comment: "Label for a menu item explaining the size of the user's backup."
))
Spacer()
Text(lastBackupSizeBytes.formatted(.owsByteCount))
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
BackupViewKeyView(viewModel: viewModel)
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) }
)
)
}
}
// MARK: -
private struct BackupViewKeyView: View {
let viewModel: BackupSettingsViewModel
var body: some View {
Button {
viewModel.showViewRecoveryKey()
} label: {
HStack {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_VIEW_BACKUP_KEY_LABEL",
comment: "Label for a menu item offering to show the user their recovery 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 suspendUploads() { print("Manually suspending uploads!") }
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 showViewRecoveryKey() { print("Showing View Recovery Key!") }
}
let viewModel = BackupSettingsViewModel(
backupSubscriptionConfiguration: BackupSubscriptionConfiguration(
storageAllowanceBytes: 100_000_000_000,
freeTierMediaDays: 45,
),
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,
mediaTierCapacityOverflow: nil,
hasBackupFailed: 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: 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),
latestBackupAttachmentUploadUpdateState: .running,
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Manual Backup: Media Upload Paused (Low Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload, 0.80),
latestBackupAttachmentUploadUpdateState: .pausedLowBattery,
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Manual Backup: Media Upload Paused (Low Power Mode)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload, 0.80),
latestBackupAttachmentUploadUpdateState: .pausedLowPowerMode,
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Manual Backup: Media Upload Paused (WiFi)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload, 0.80),
latestBackupAttachmentUploadUpdateState: .pausedNeedsWifi,
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Manual Backup: Media Upload Paused (Internet)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload, 0.80),
latestBackupAttachmentUploadUpdateState: .pausedNeedsInternet,
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters)
))
}
#Preview("Manual Backup: Offloading") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.offloading, 0.90),
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 (Low Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedLowBattery,
backupSubscriptionLoadingState: .loaded(.free)
))
}
#Preview("Downloads: Paused (Low Power Mode)") {
BackupSettingsView(viewModel: .forPreview(
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedLowPowerMode,
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