// // 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, 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] = [] 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( updateStream: AsyncStream, 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? @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?, 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(8), 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 FeatureFlags.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 FeatureFlags.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 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? = 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 { static func forPreview( _ step: BackupExportJobStep, _ progress: Float ) -> OWSSequentialProgress { 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