Path: blob/main/Signal/Backups/BackupSettingsViewController.swift
1 views
//
// 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
case automaticallyStartBackup(completion: ((UIViewController) -> Void)?)
}
private let accountEntropyPoolManager: AccountEntropyPoolManager
private let accountKeyStore: AccountKeyStore
private let backupAttachmentDownloadTracker: BackupAttachmentDownloadTracker
private let backupAttachmentUploadStore: BackupAttachmentUploadStore
private let backupAttachmentUploadTracker: BackupAttachmentUploadTracker
private let backupDisablingManager: BackupDisablingManager
private let backupEnablingManager: BackupEnablingManager
private let backupExportJobRunner: BackupExportJobRunner
private let backupFailureStateManager: BackupFailureStateManager
private let backupIdService: BackupIdService
private let backupPlanManager: BackupPlanManager
private let backupSettingsStore: BackupSettingsStore
private let backupSubscriptionIssueStore: BackupSubscriptionIssueStore
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 var onBackupComplete: ((UIViewController) -> Void)?
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,
backupAttachmentDownloadTracker: AppEnvironment.shared.backupAttachmentDownloadTracker,
backupAttachmentUploadTracker: AppEnvironment.shared.backupAttachmentUploadTracker,
backupAttachmentUploadStore: DependenciesBridge.shared.backupAttachmentUploadStore,
backupDisablingManager: AppEnvironment.shared.backupDisablingManager,
backupEnablingManager: AppEnvironment.shared.backupEnablingManager,
backupExportJobRunner: DependenciesBridge.shared.backupExportJobRunner,
backupFailureStateManager: DependenciesBridge.shared.backupFailureStateManager,
backupIdService: DependenciesBridge.shared.backupIdService,
backupPlanManager: DependenciesBridge.shared.backupPlanManager,
backupSettingsStore: BackupSettingsStore(),
backupSubscriptionIssueStore: BackupSubscriptionIssueStore(),
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,
backupAttachmentDownloadTracker: BackupAttachmentDownloadTracker,
backupAttachmentUploadTracker: BackupAttachmentUploadTracker,
backupAttachmentUploadStore: BackupAttachmentUploadStore,
backupDisablingManager: BackupDisablingManager,
backupEnablingManager: BackupEnablingManager,
backupExportJobRunner: BackupExportJobRunner,
backupFailureStateManager: BackupFailureStateManager,
backupIdService: BackupIdService,
backupPlanManager: BackupPlanManager,
backupSettingsStore: BackupSettingsStore,
backupSubscriptionIssueStore: BackupSubscriptionIssueStore,
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 = backupAttachmentDownloadTracker
self.backupAttachmentUploadTracker = backupAttachmentUploadTracker
self.backupAttachmentUploadStore = backupAttachmentUploadStore
self.backupDisablingManager = backupDisablingManager
self.backupEnablingManager = backupEnablingManager
self.backupExportJobRunner = backupExportJobRunner
self.backupFailureStateManager = backupFailureStateManager
self.backupIdService = backupIdService
self.backupPlanManager = backupPlanManager
self.backupSettingsStore = backupSettingsStore
self.backupSubscriptionIssueStore = backupSubscriptionIssueStore
self.backupSubscriptionManager = backupSubscriptionManager
self.db = db
self.deviceSleepManager = deviceSleepManager
self.subscriptionConfigManager = subscriptionConfigManager
self.tsAccountManager = tsAccountManager
self.onAppearAction = onAppearAction
switch onAppearAction {
case .presentWelcomeToBackupsSheet, nil:
break
case .automaticallyStartBackup(let completion):
self.onBackupComplete = completion
}
self.viewModel = db.read { tx in
let viewModel = BackupSettingsViewModel(
backupSubscriptionConfiguration: subscriptionConfigManager.backupConfigurationOrDefault(tx: tx),
backupSubscriptionLoadingState: .loading,
backupSubscriptionAlreadyRedeemed: backupSubscriptionIssueStore.shouldShowIAPSubscriptionAlreadyRedeemedWarning(tx: tx),
backupPlan: backupPlanManager.backupPlan(tx: tx),
failedToDisableBackupsRemotely: backupDisablingManager.disableRemotelyFailed(tx: tx),
latestBackupExportProgressUpdate: nil,
latestBackupAttachmentDownloadUpdate: nil,
latestBackupAttachmentUploadUpdate: nil,
lastBackupDetails: backupSettingsStore.lastBackupDetails(tx: tx),
shouldAllowBackupUploadsOnCellular: backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx),
mediaTierCapacityOverflow: Self.getMediaTierCapacityOverflow(
backupAttachmentUploadStore: backupAttachmentUploadStore,
backupSettingsStore: backupSettingsStore,
tx: tx,
),
hasBackupFailed: backupFailureStateManager.hasFailedBackup(tx: tx),
isBackgroundAppRefreshDisabled: Self.isBackgroundAppRefreshDisabled(),
)
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()
case .automaticallyStartBackup:
performManualBackup()
}
}
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!")
SheetDisplayableError.genericError.showSheet(from: self)
}
}
private func startExternalEventObservation() {
guard externalEventObservationTasks.isEmpty else {
return
}
externalEventObservationTasks = [
Task {
await deviceSleepManager.manageBlockForUpdateStream(
backupExportJobRunner.updates(),
label: "BackupSettings.BackupExportJob",
) { [weak self] exportJobUpdate in
guard let self else { return false }
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:
onBackupComplete.take()?(self)
case .failure(let error):
showSheetForBackupExportJobError(error)
}
db.read { tx in
self.viewModel.hasBackupFailed = self.backupFailureStateManager.hasFailedBackup(tx: tx)
}
}
return exportJobUpdate != nil
}
},
Task {
await deviceSleepManager.manageBlockForUpdateStream(
backupAttachmentDownloadTracker.updates(),
label: "BackupSettings.BackupDownloads",
) { [weak self] downloadTrackerUpdate in
guard let self else { return false }
let downloadViewUpdateState: BackupAttachmentDownloadProgressView.DownloadUpdate.State
switch downloadTrackerUpdate.state {
case .empty, .notRegisteredAndReady:
viewModel.latestBackupAttachmentDownloadUpdate = nil
return false
case .running:
downloadViewUpdateState = .running
case .suspended:
downloadViewUpdateState = .suspended
case .pausedLowBattery:
downloadViewUpdateState = .pausedLowBattery
case .pausedLowPowerMode:
downloadViewUpdateState = .pausedLowPowerMode
case .pausedNeedsWifi:
downloadViewUpdateState = .pausedNeedsWifi
case .pausedNeedsInternet:
downloadViewUpdateState = .pausedNeedsInternet
case .outOfDiskSpace(let bytesRequired):
downloadViewUpdateState = .outOfDiskSpace(bytesRequired: bytesRequired)
}
viewModel.latestBackupAttachmentDownloadUpdate = BackupAttachmentDownloadProgressView.DownloadUpdate(
state: downloadViewUpdateState,
bytesDownloaded: downloadTrackerUpdate.bytesDownloaded,
totalBytesToDownload: downloadTrackerUpdate.totalBytesToDownload,
percentageDownloaded: downloadTrackerUpdate.percentageDownloaded,
)
return true
}
},
Task {
await deviceSleepManager.manageBlockForUpdateStream(
backupAttachmentUploadTracker.updates(),
label: "BackupSettings.BackupUploads",
) { [weak self] uploadTrackerUpdate in
guard let self else { return false }
let uploadViewUpdateState: BackupAttachmentUploadProgressView.UploadUpdate.State
switch uploadTrackerUpdate.state {
case .noUploadsToReport,
.suspended,
.notRegisteredAndReady,
.hasConsumedMediaTierCapacity:
viewModel.latestBackupAttachmentUploadUpdate = nil
return false
case .uploading:
uploadViewUpdateState = .uploading
case .pausedLowBattery:
uploadViewUpdateState = .pausedLowBattery
case .pausedLowPowerMode:
uploadViewUpdateState = .pausedLowPowerMode
case .pausedNeedsWifi:
uploadViewUpdateState = .pausedNeedsWifi
case .pausedNeedsInternet:
uploadViewUpdateState = .pausedNeedsInternet
}
viewModel.latestBackupAttachmentUploadUpdate = BackupAttachmentUploadProgressView.UploadUpdate(
state: uploadViewUpdateState,
bytesUploaded: uploadTrackerUpdate.bytesUploaded,
totalBytesToUpload: uploadTrackerUpdate.totalBytesToUpload,
percentageUploaded: uploadTrackerUpdate.percentageUploaded,
)
return true
}
},
NotificationCenter.default.startTaskTrackingNotifications(
named: .OWSApplicationDidBecomeActive,
onNotification: { [weak self] in
self?.loadBackupSubscription()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: .backupPlanChanged,
onNotification: { [weak self] in
self?._backupPlanDidChange()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: .lastBackupDetailsDidChange,
onNotification: { [weak self] in
self?._lastBackupDetailsDidChange()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: .shouldAllowBackupUploadsOnCellularChanged,
onNotification: { [weak self] in
self?._shouldAllowBackupUploadsOnCellularDidChange()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: .backupSubscriptionAlreadyRedeemedDidChange,
onNotification: { [weak self] in
self?._backupSubscriptionAlreadyRedeemedDidChange()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: .backupIAPNotFoundLocallyDidChange,
onNotification: { [weak self] in
self?._backupIAPNotFoundLocallyDidChange()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: .hasConsumedMediaTierCapacityStatusDidChange,
onNotification: { [weak self] in
self?._hasConsumedMediaTierCapacityDidChange()
},
),
NotificationCenter.default.startTaskTrackingNotifications(
named: UIApplication.backgroundRefreshStatusDidChangeNotification,
onNotification: { [weak self] in
self?._isBackgroundAppRefreshDisabledDidChange()
},
),
]
}
private func stopExternalEventObservation() {
externalEventObservationTasks.forEach { $0.cancel() }
externalEventObservationTasks = []
}
// 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.lastBackupDetails = backupSettingsStore.lastBackupDetails(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 _lastBackupDetailsDidChange() {
db.read { tx in
viewModel.lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx)
}
}
private func _shouldAllowBackupUploadsOnCellularDidChange() {
db.read { tx in
viewModel.shouldAllowBackupUploadsOnCellular = backupSettingsStore.shouldAllowBackupUploadsOnCellular(tx: tx)
}
}
private func _backupSubscriptionAlreadyRedeemedDidChange() {
db.read { tx in
viewModel.backupSubscriptionAlreadyRedeemed = backupSubscriptionIssueStore.shouldShowIAPSubscriptionAlreadyRedeemedWarning(tx: tx)
}
}
private func _backupIAPNotFoundLocallyDidChange() {
// This property isn't directly on the view model, but is fetched as
// part of loading the subscription view.
loadBackupSubscription()
}
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 backupAttachmentUploadStore.totalEstimatedFullsizeBytesToUpload(tx: tx)
} else {
return nil
}
}
private func _isBackgroundAppRefreshDisabledDidChange() {
viewModel.isBackgroundAppRefreshDisabled = Self.isBackgroundAppRefreshDisabled()
}
private static func isBackgroundAppRefreshDisabled() -> Bool {
switch UIApplication.shared.backgroundRefreshStatus {
case .restricted, .denied: true
case .available: false
@unknown default: false
}
}
// MARK: - BackupSettingsViewModel.ActionsDelegate
fileprivate func enableBackups(
planSelection: BackupSettingsViewModel.EnableBackupsPlanSelection,
shouldShowWelcomeToBackupsSheet: Bool,
) {
Task {
switch planSelection {
case .required(let planSelection):
await _enableBackups(
fromViewController: self,
planSelection: planSelection,
shouldShowWelcomeToBackupsSheet: shouldShowWelcomeToBackupsSheet,
)
case .userChoice(let initialSelection):
await _showChooseBackupPlan(
initialPlanSelection: initialSelection,
shouldShowWelcomeToBackupsSheet: shouldShowWelcomeToBackupsSheet,
)
}
}
}
@MainActor
private func _showChooseBackupPlan(
initialPlanSelection: ChooseBackupPlanViewController.PlanSelection?,
shouldShowWelcomeToBackupsSheet: Bool,
) async {
do throws(SheetDisplayableError) {
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,
shouldShowWelcomeToBackupsSheet: shouldShowWelcomeToBackupsSheet,
)
}
},
)
navigationController?.pushViewController(
chooseBackupPlanViewController,
animated: true,
)
} catch {
error.showSheet(from: self)
}
}
@MainActor
private func _enableBackups(
fromViewController: UIViewController,
planSelection: ChooseBackupPlanViewController.PlanSelection,
shouldShowWelcomeToBackupsSheet: Bool,
) async {
do throws(SheetDisplayableError) {
try await backupEnablingManager.enableBackups(
fromViewController: fromViewController,
planSelection: planSelection,
)
navigationController?.popToViewController(self, animated: true) { [self] in
if shouldShowWelcomeToBackupsSheet {
presentWelcomeToBackupsSheet()
}
}
} catch {
error.showSheet(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.",
),
primaryButton: 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.",
),
)
downloadsActionSheet.addAction(.okay)
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 }
showAppStoreManageSubscriptions()
},
))
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
}
switch viewModel.backupSubscriptionLoadingState {
case .loading, .loaded:
break
case .networkError, .genericError:
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 .freeAndEnabled
case .paidAsTester:
return .paidButFreeForTesters
case .disabling, .disabled:
// Our IAP subscription may be active even if Backups are disabled,
// and if so we want to load the state of said subscription.
break
case .paid, .paidExpiringSoon:
break
}
let fetchedBackupSubscription: Subscription? = try await backupSubscriptionManager
.fetchAndMaybeDowngradeSubscription()
// Now that we've fetched a subscription, refetch state that may have
// changed as a result.
var backupIAPNotFoundLocally: Bool!
db.read { tx in
currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
backupIAPNotFoundLocally = backupSubscriptionIssueStore.shouldShowIAPSubscriptionNotFoundLocallyWarning(tx: tx)
}
if backupIAPNotFoundLocally {
return .paidButIAPNotFoundLocally
}
let backupSubscription: Subscription
switch currentBackupPlan {
case .free:
return .freeAndEnabled
case .paidAsTester:
return .paidButFreeForTesters
case .disabling, .disabled:
if let fetchedBackupSubscription {
backupSubscription = fetchedBackupSubscription
} else {
return .freeAndDisabled
}
case .paid, .paidExpiringSoon:
if let fetchedBackupSubscription {
backupSubscription = fetchedBackupSubscription
} else {
owsFailDebug("Missing Backups subscription after fetch, but still on paid plan!")
return .freeAndEnabled
}
}
switch backupSubscription.status {
case .canceled, .unrecognized:
fallthrough
case .active:
let endOfCurrentPeriod = backupSubscription.endOfCurrentPeriod
if backupSubscription.cancelAtEndOfPeriod {
if endOfCurrentPeriod.isAfterNow {
return .paidButExpiring(expirationDate: endOfCurrentPeriod)
} else {
return .paidButExpired(expirationDate: endOfCurrentPeriod)
}
} else {
return .paid(
price: backupSubscription.amount,
renewalDate: endOfCurrentPeriod,
)
}
case .pastDue:
// The .pastDue status is returned if we're in the IAP "billing
// retry", period, which indicates something has gone wrong with a
// subscription renewal.
//
// SeeAlso: BackupSubscriptionManager
return .paidButFailedToRenew
}
}
// MARK: -
fileprivate func showAppStoreManageSubscriptions() {
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()
}
}
// MARK: -
fileprivate func performManualBackup() {
// We observe BackupExportJobRunner updates, so we can ignore the
// returned task.
_ = backupExportJobRunner.startIfNecessary(mode: .manual)
}
fileprivate func cancelManualBackup() {
// We observe BackupExportJobRunner updates, so we can ignore the
// returned task.
_ = backupExportJobRunner.cancelIfRunning()
suspendUploads()
}
fileprivate func suspendUploads() {
db.write {
self.backupSettingsStore.setIsBackupUploadQueueSuspended(true, tx: $0)
}
}
private func showSheetForBackupExportJobError(_ error: Error) {
let actionSheet: ActionSheetController
switch error {
case is CancellationError:
return
case is NotRegisteredError:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_NOT_REGISTERED",
comment: "Message for an action sheet explaining that you must be registered to make a Backup.",
),
)
actionSheet.addAction(.okay)
case BackupExportJobError.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 _ where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse:
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)
default:
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) {
let isPaidPlanTester: Bool = db.write { 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
}
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: {})
}
}
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 }
Task {
// If appropriate, the warning sheet will let the user continue
// in a "create new AEP" flow.
await self.showCreateNewRecoveryKeyWarningSheet(fromViewController: recordKeyViewController)
}
},
)
navigationController?.pushViewController(recordKeyViewController, animated: true)
}
@MainActor
private func showCreateNewRecoveryKeyWarningSheet(
fromViewController: BackupRecordKeyViewController,
) async {
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 showCreateKeySheet = {
self._showCreateNewRecoveryKeyWarningSheet(
fromViewController: fromViewController,
currentBackupPlan: currentBackupPlan,
)
}
// Check if we've hit the limit for registering new backupIDs and warn the user
if
let limits = try? await backupIdService.fetchBackupIDLimits(auth: .implicit(), logger: PrefixedLogger(prefix: "[Settings]")),
!limits.hasPermitsRemaining
{
let bodyText = String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_LIMIT_REACHED_WARNING_SHEET_BODY",
comment: "Explanation text for a sheet warning users they've reached a rate limit for creating Recovery Key. {{ Embeds 1: the preformatted time they must wait before enabling backups, such as \"1 week\" or \"6 hours\". }}",
),
DateUtil.formatDuration(
seconds: UInt32(clamping: limits.retryAfterSeconds),
useShortFormat: false,
),
)
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_LIMIT_REACHED_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning users they've reached a rate limit for creating Recovery Key.",
),
message: bodyText,
)
actionSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_LIMIT_REACHED_WARNING_SHEET_CONTINUE_ACTION",
comment: "Action in an action sheet allowing to continue to rotate their key",
),
style: .destructive,
handler: { _ in
showCreateKeySheet()
},
))
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.learnMore,
handler: { _ in
CurrentAppContext().open(
URL.Support.backups,
completion: nil,
)
},
))
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.okButton,
handler: { _ in },
))
presentActionSheet(actionSheet)
} else {
showCreateKeySheet()
}
}
private func _showCreateNewRecoveryKeyWarningSheet(
fromViewController: BackupRecordKeyViewController,
currentBackupPlan: BackupPlan,
) {
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.",
),
primaryButton: HeroSheetViewController.Button(
title: primaryButtonTitle,
action: { sheet in
sheet.dismiss(animated: true) { [weak self] in
guard let self else { return }
showRecordNewRecoveryKey()
}
},
),
secondaryButton: .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: -
fileprivate func showBackupSubscriptionAlreadyRedeemedSheet() {
let alreadyRedeemedSheet = BackupSubscriptionAlreadyRedeemedSheet()
present(alreadyRedeemedSheet, animated: true)
}
fileprivate func showBackupIAPNotFoundLocallySheet() {
let notFoundLocallySheet = HeroSheetViewController(
hero: .circleIcon(icon: .backupErrorBold, iconSize: 40, tintColor: .orange, backgroundColor: UIColor(rgbHex: 0xF9E4B6)),
title: OWSLocalizedString(
"BACKUP_SETTINGS_IAP_NOT_FOUND_LOCALLY_SHEET_TITLE",
comment: "Title for a sheet explaining that the user's Backups subscription was not found on this device.",
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_IAP_NOT_FOUND_LOCALLY_SHEET_BODY",
comment: "Body for a sheet explaining that the user's Backups subscription was not found on this device.",
),
primaryButton: .dismissing(title: OWSLocalizedString(
"BACKUP_SETTINGS_IAP_NOT_FOUND_LOCALLY_SHEET_GOT_IT_BUTTON",
comment: "Button for a sheet explaining that the user's Backups subscription was not found on this device.",
)),
)
present(notFoundLocallySheet, animated: true)
}
fileprivate func showBackgroundAppRefreshDisabledWarningSheet() {
let disabledSheet = HeroSheetViewController(
hero: .circleIcon(icon: .backupErrorBold, iconSize: 40, tintColor: .orange, backgroundColor: UIColor(rgbHex: 0xF9E4B6)),
title: OWSLocalizedString(
"BACKUP_SETTINGS_BACKGROUND_APP_REFRESH_DISABLED_SHEET_TITLE",
comment: "Title for a sheet warning the user about the Background App Refresh permission. \"Background App Refresh\" should be localized the same way it is in iOS Settings app permissions.",
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_BACKGROUND_APP_REFRESH_DISABLED_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about the Background App Refresh permission. \"Background App Refresh\" should be localized the same way it is in iOS Settings app permissions.",
),
primaryButton: HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_SETTINGS_BACKGROUND_APP_REFRESH_DISABLED_SHEET_GO_TO_SETTINGS_BUTTON",
comment: "Title for a button that takes the users to Signal's iOS Settings page.",
),
action: { sheet in
sheet.dismiss(animated: true) {
UIApplication.shared.openSystemSettings()
}
},
),
secondaryButton: .dismissing(
title: CommonStrings.dismissButton,
style: .secondary,
),
)
present(disabledSheet, animated: true)
}
}
// MARK: -
private class BackupSettingsViewModel: ObservableObject {
enum EnableBackupsPlanSelection {
case required(ChooseBackupPlanViewController.PlanSelection)
case userChoice(initialSelection: ChooseBackupPlanViewController.PlanSelection?)
}
protocol ActionsDelegate: AnyObject {
func enableBackups(planSelection: EnableBackupsPlanSelection, shouldShowWelcomeToBackupsSheet: Bool)
func disableBackups()
func loadBackupSubscription()
func showAppStoreManageSubscriptions()
func performManualBackup()
func cancelManualBackup()
func suspendUploads()
func setShouldAllowBackupUploadsOnCellular(_ newShouldAllowBackupUploadsOnCellular: Bool)
func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool)
func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan)
func setShouldAllowBackupDownloadsOnCellular()
func showViewRecoveryKey()
func showBackupSubscriptionAlreadyRedeemedSheet()
func showBackupIAPNotFoundLocallySheet()
func showBackgroundAppRefreshDisabledWarningSheet()
}
enum BackupSubscriptionLoadingState: Equatable {
enum LoadedBackupSubscription: Equatable {
case freeAndEnabled
case freeAndDisabled
case paidButFreeForTesters
case paid(price: FiatMoney, renewalDate: Date)
case paidButExpiring(expirationDate: Date)
case paidButExpired(expirationDate: Date)
case paidButFailedToRenew
case paidButIAPNotFoundLocally
}
case loading
case loaded(LoadedBackupSubscription)
case networkError
case genericError
}
@Published var backupSubscriptionConfiguration: BackupSubscriptionConfiguration
@Published var backupSubscriptionLoadingState: BackupSubscriptionLoadingState
@Published var backupSubscriptionAlreadyRedeemed: Bool
@Published var backupPlan: BackupPlan
@Published var failedToDisableBackupsRemotely: Bool
@Published var latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStage>?
@Published var latestBackupAttachmentDownloadUpdate: BackupAttachmentDownloadProgressView.DownloadUpdate?
@Published var latestBackupAttachmentUploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate?
@Published var lastBackupDetails: BackupSettingsStore.LastBackupDetails?
@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?
/// Indicates that the user's Backup has failed recently, and we should show
/// a corresponding error.
@Published var hasBackupFailed: Bool
/// Indicates that the "Background App Refresh" permission is disabled, and
/// we should show a corresponding error. (This prevents `BGProcessingTask`
/// from running.)
@Published var isBackgroundAppRefreshDisabled: Bool
weak var actionsDelegate: ActionsDelegate?
init(
backupSubscriptionConfiguration: BackupSubscriptionConfiguration,
backupSubscriptionLoadingState: BackupSubscriptionLoadingState,
backupSubscriptionAlreadyRedeemed: Bool,
backupPlan: BackupPlan,
failedToDisableBackupsRemotely: Bool,
latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStage>?,
latestBackupAttachmentDownloadUpdate: BackupAttachmentDownloadProgressView.DownloadUpdate?,
latestBackupAttachmentUploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate?,
lastBackupDetails: BackupSettingsStore.LastBackupDetails?,
shouldAllowBackupUploadsOnCellular: Bool,
mediaTierCapacityOverflow: UInt64?,
hasBackupFailed: Bool,
isBackgroundAppRefreshDisabled: Bool,
) {
self.backupSubscriptionConfiguration = backupSubscriptionConfiguration
self.backupSubscriptionLoadingState = backupSubscriptionLoadingState
self.backupSubscriptionAlreadyRedeemed = backupSubscriptionAlreadyRedeemed
self.backupPlan = backupPlan
self.failedToDisableBackupsRemotely = failedToDisableBackupsRemotely
self.latestBackupExportProgressUpdate = latestBackupExportProgressUpdate
self.latestBackupAttachmentDownloadUpdate = latestBackupAttachmentDownloadUpdate
self.latestBackupAttachmentUploadUpdate = latestBackupAttachmentUploadUpdate
self.lastBackupDetails = lastBackupDetails
self.shouldAllowBackupUploadsOnCellular = shouldAllowBackupUploadsOnCellular
self.mediaTierCapacityOverflow = mediaTierCapacityOverflow
self.hasBackupFailed = hasBackupFailed
self.isBackgroundAppRefreshDisabled = isBackgroundAppRefreshDisabled
}
// MARK: -
func enableBackups(planSelection: EnableBackupsPlanSelection, shouldShowWelcomeToBackupsSheet: Bool) {
actionsDelegate?.enableBackups(
planSelection: planSelection,
shouldShowWelcomeToBackupsSheet: shouldShowWelcomeToBackupsSheet,
)
}
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 showAppStoreManageSubscriptions() {
actionsDelegate?.showAppStoreManageSubscriptions()
}
// 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: -
func showBackupSubscriptionAlreadyRedeemedSheet() {
actionsDelegate?.showBackupSubscriptionAlreadyRedeemedSheet()
}
func showBackupIAPNotFoundLocallySheet() {
actionsDelegate?.showBackupIAPNotFoundLocallySheet()
}
func showBackgroundAppRefreshDisabledWarningSheet() {
actionsDelegate?.showBackgroundAppRefreshDisabledWarningSheet()
}
}
// MARK: -
struct BackupSettingsView: View {
private enum Contents {
case enabled
case disablingDownloadsRunning(BackupAttachmentDownloadProgressView.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 {
if viewModel.backupSubscriptionAlreadyRedeemed {
SignalSection {
HStack(alignment: .center, spacing: 16) {
Image(.backupErrorBold)
.resizable()
.frame(width: 24, height: 24)
.foregroundStyle(Color.Signal.orange)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_SUBSCRIPTION_ALREADY_REDEEMED_NOTICE_TITLE",
comment: "Title for notice that the user's Backups subscription couldn't be redeemed.",
))
.font(.subheadline)
.foregroundColor(Color.Signal.label)
Button {
viewModel.showBackupSubscriptionAlreadyRedeemedSheet()
} label: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_SUBSCRIPTION_ALREADY_REDEEMED_NOTICE_DETAIL_BUTTON",
comment: "Title for detail button in notice that the user's Backups subscription couldn't be redeemed.",
))
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(Color.Signal.label)
}
}
}
.listRowBackground(Color.Signal.quaternaryFill)
}
SignalSection {
BackupSubscriptionView(
backupSubscriptionConfiguration: viewModel.backupSubscriptionConfiguration,
loadingState: viewModel.backupSubscriptionLoadingState,
viewModel: viewModel,
)
}
switch contents {
case .enabled:
if let latestBackupAttachmentDownloadUpdate = viewModel.latestBackupAttachmentDownloadUpdate {
SignalSection {
BackupAttachmentDownloadProgressView(
backupPlan: viewModel.backupPlan,
latestDownloadUpdate: latestBackupAttachmentDownloadUpdate,
viewModel: viewModel,
)
}
}
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 .disabled, .disabling, .disabledFailedToDisableRemotely:
EmptyView()
}
switch contents {
case .enabled:
SignalSection {
if viewModel.isBackgroundAppRefreshDisabled {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKGROUND_APP_REFRESH_DISABLED_MESSAGE",
comment: "Message describing that the Background App Refresh permission is disabled for Signal. \"Background App Refresh\" should be localized the same way it is in iOS Settings app permissions.",
))
.appendLink(
OWSLocalizedString(
"BACKUP_SETTINGS_BACKGROUND_APP_REFRESH_DISABLED_MESSAGE_UPDATE_NOW",
comment: "Add-on to a message describing that the Background App Refresh permission is disabled for Signal. \"Background App Refresh\" should be localized the same way it is in iOS Settings app permissions.",
),
useBold: true,
tint: .Signal.label,
action: {
viewModel.showBackgroundAppRefreshDisabledWarningSheet()
},
)
.font(.subheadline)
.multilineTextAlignment(.leading)
} icon: {
YellowBadgeView()
}
}
if viewModel.hasBackupFailed {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_FAILED_MESSAGE",
comment: "Message describing to the user that the last backup failed.",
))
.font(.subheadline)
.multilineTextAlignment(.leading)
} icon: {
YellowBadgeView()
}
}
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.nonPluralLocalizedStringWithFormat(
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(
fudgeBase2ToBase10: true,
zeroPadFractionDigits: false,
)),
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(.errorCircleFillCompact)
}
}
VStack(alignment: .leading) {
PerformManualBackupButton {
viewModel.performManualBackup()
}
}
} else if let latestBackupAttachmentUploadUpdate = viewModel.latestBackupAttachmentUploadUpdate {
BackupAttachmentUploadProgressView(
latestUploadUpdate: latestBackupAttachmentUploadUpdate,
)
CancelManualBackupButton {
viewModel.suspendUploads()
}
} else {
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(
lastBackupDetails: viewModel.lastBackupDetails,
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:
// Download progress is shown in the section above this, so don't show
// anything here until the downloads complete.
EmptyView()
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(
backupSubscriptionLoadingState: viewModel.backupSubscriptionLoadingState,
viewModel: viewModel,
)
} 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(
backupSubscriptionLoadingState: viewModel.backupSubscriptionLoadingState,
viewModel: viewModel,
)
}
SignalSection {
BackupViewKeyView(viewModel: viewModel)
}
}
}
}
}
private struct YellowBadgeView: View {
@ViewBuilder
var body: some View {
// Label is used so the horizontal position of the text aligns with
// other rows. On iOS 18, using an Image in the label aligns it to the
// top, but iOS 26 centers it. On iOS 26, using a Circle with a top
// alignment works, but it is glitchy and stretches too much on iOS 18,
// so just fork the behavior here.
if #available(iOS 26, *) {
Circle()
.frame(width: 10, height: 10)
.foregroundStyle(Color.Signal.yellow)
.padding(.top, 6)
.frame(maxHeight: .infinity, alignment: .top)
} else {
Image(systemName: "circle.fill")
.resizable()
.frame(width: 10, height: 10)
.foregroundStyle(Color.Signal.yellow)
}
}
}
private struct ReenableBackupsButton: View {
let backupSubscriptionLoadingState: BackupSettingsViewModel.BackupSubscriptionLoadingState
let viewModel: BackupSettingsViewModel
private var enableBackupsPlanSelection: BackupSettingsViewModel.EnableBackupsPlanSelection? {
switch backupSubscriptionLoadingState {
case .loading, .networkError:
// Don't let them reenable until we know more.
return nil
case
.loaded(.freeAndEnabled),
.loaded(.freeAndDisabled),
.loaded(.paidButFreeForTesters),
.loaded(.paidButExpired),
.loaded(.paidButFailedToRenew),
.loaded(.paidButIAPNotFoundLocally),
.genericError:
return .userChoice(initialSelection: nil)
case .loaded(.paid), .loaded(.paidButExpiring):
// They're currently paid, so automatically reenable with paid.
return .required(.paid)
}
}
var body: some View {
if let enableBackupsPlanSelection {
Button {
viewModel.enableBackups(
planSelection: enableBackupsPlanSelection,
shouldShowWelcomeToBackupsSheet: true,
)
} 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<BackupExportJobStage>
let latestAttachmentUploadUpdate: BackupAttachmentUploadProgressView.UploadUpdate?
private var progressBarState: ProgressBarState {
switch latestExportProgressUpdate.currentStep {
case .backupFileExport, .backupFileUpload:
let percentExportCompleted = latestExportProgressUpdate.progress(for: .backupFileExport)?.percentComplete ?? 0
let percentUploadCompleted = latestExportProgressUpdate.progress(for: .backupFileUpload)?.percentComplete ?? 0
let percentComplete = (0.95 * percentExportCompleted) + (0.05 * percentUploadCompleted)
return ProgressBarState(
style: .determinate(percentComplete: percentComplete),
label: String.nonPluralLocalizedStringWithFormat(
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(.owsPercent()),
),
)
case .attachmentUpload:
return ProgressBarState(
style: .determinate(percentComplete: latestAttachmentUploadUpdate?.percentageUploaded ?? 0),
label: BackupAttachmentUploadProgressView.subtitleText(
uploadUpdate: latestAttachmentUploadUpdate,
),
)
case .attachmentProcessing:
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.",
),
)
}
}
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(
value: value * animationPart1Progress,
tintColor: .tintColor
.blended(with: .white, alpha: 0.2),
)
ClearTrackProgressView(
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 {
struct DownloadUpdate: Equatable {
enum State: Equatable {
case running
case suspended
case pausedLowBattery
case pausedLowPowerMode
case pausedNeedsWifi
case pausedNeedsInternet
case outOfDiskSpace(bytesRequired: UInt64)
}
let state: State
let bytesDownloaded: UInt64
let totalBytesToDownload: UInt64
let percentageDownloaded: Float
var bytesRemaining: UInt64 {
let remainingBytes = totalBytesToDownload.subtractingReportingOverflow(bytesDownloaded)
guard !remainingBytes.overflow else {
return 0
}
return remainingBytes.partialValue
}
}
let backupPlan: BackupPlan
let latestDownloadUpdate: 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.nonPluralLocalizedStringWithFormat(
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.bytesRemaining.formatted(.owsByteCount()),
)
case .disabling, .paidExpiringSoon:
String.nonPluralLocalizedStringWithFormat(
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.bytesRemaining.formatted(.owsByteCount()),
)
}
case .running:
String.nonPluralLocalizedStringWithFormat(
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(.owsPercent()),
)
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.nonPluralLocalizedStringWithFormat(
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 {
struct UploadUpdate: Equatable {
enum State {
case uploading
case pausedLowBattery
case pausedLowPowerMode
case pausedNeedsWifi
case pausedNeedsInternet
}
let state: State
let bytesUploaded: UInt64
let totalBytesToUpload: UInt64
let percentageUploaded: Float
}
let latestUploadUpdate: 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: BackupAttachmentUploadProgressView.UploadUpdate?,
) -> String {
guard let uploadUpdate else {
return String(OWSLocalizedString(
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_RUNNING_GENERIC",
comment: "Subtitle for a progress bar tracking active uploading.",
))
}
switch uploadUpdate.state {
case .uploading:
let bytesUploaded = uploadUpdate.bytesUploaded
let totalBytesToUpload = uploadUpdate.totalBytesToUpload
let percentageUploaded = uploadUpdate.percentageUploaded
return String.nonPluralLocalizedStringWithFormat(
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(.owsPercent()),
)
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):
BackupSubscriptionLoadedView(
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 struct BackupSubscriptionLoadedView: View {
let backupSubscriptionConfiguration: BackupSubscriptionConfiguration
let loadedBackupSubscription: BackupSettingsViewModel.BackupSubscriptionLoadingState.LoadedBackupSubscription
let viewModel: BackupSettingsViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
VStack(alignment: .leading) {
headerView()
descriptionView()
}
Spacer()
Group {
switch loadedBackupSubscription {
case
.freeAndEnabled,
.freeAndDisabled,
.paidButFreeForTesters,
.paid,
.paidButExpiring,
.paidButExpired,
.paidButFailedToRenew:
Image(.backupsSubscribed).resizable()
case .paidButIAPNotFoundLocally:
Image(.backupsLogoWarningBadged).resizable()
}
}
.frame(width: 64, height: 64)
.padding(.leading, 16)
}
buttonsView()
}
.padding(4)
}
@ViewBuilder
private func headerView() -> some View {
switch loadedBackupSubscription {
case .freeAndEnabled, .freeAndDisabled:
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,
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer().frame(height: 8)
case .paidButFreeForTesters, .paid, .paidButExpiring, .paidButExpired, .paidButFailedToRenew:
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)
case .paidButIAPNotFoundLocally:
EmptyView()
}
}
@ViewBuilder
private func descriptionView() -> some View {
switch loadedBackupSubscription {
case .freeAndEnabled:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_DESCRIPTION",
comment: "Text describing the user's free backup plan.",
))
case .freeAndDisabled:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_AND_DISABLED_DESCRIPTION",
comment: "Text describing the user's free backup plan when they have Backups disabled.",
))
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.",
))
case .paid(let price, let renewalDate):
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.nonPluralLocalizedStringWithFormat(
priceStringFormat,
CurrencyFormatter.format(money: price),
))
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 }}.",
)
Text(String.nonPluralLocalizedStringWithFormat(
renewalStringFormat,
DateFormatter.localizedString(from: renewalDate, dateStyle: .medium, timeStyle: .none),
))
case .paidButExpiring(let expirationDate), .paidButExpired(let expirationDate):
let expirationDateFormatString = switch loadedBackupSubscription {
case .freeAndEnabled, .freeAndDisabled, .paidButFreeForTesters, .paid, .paidButFailedToRenew, .paidButIAPNotFoundLocally:
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.",
))
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(Color.Signal.red)
Text(String.nonPluralLocalizedStringWithFormat(
expirationDateFormatString,
DateFormatter.localizedString(from: expirationDate, dateStyle: .medium, timeStyle: .none),
))
case .paidButFailedToRenew:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_FAILED_TO_RENEW_DESCRIPTION_1",
comment: "Text describing that the user's paid backup plan has failed to renew.",
))
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(Color.Signal.red)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_FAILED_TO_RENEW_DESCRIPTION_2",
comment: "Text describing that the user's paid backup plan has failed to renew.",
))
case .paidButIAPNotFoundLocally:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_IAP_NOT_FOUND_LOCALLY_DESCRIPTION",
comment: "Text describing that the user's paid backup plan did not correspond to a App Store subscription on this device.",
))
}
}
@ViewBuilder
private func buttonsView() -> some View {
switch loadedBackupSubscription {
case .freeAndEnabled:
loadedViewButton(
label: 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.",
),
action: {
viewModel.enableBackups(
planSelection: .userChoice(initialSelection: .free),
shouldShowWelcomeToBackupsSheet: false,
)
},
)
case .freeAndDisabled:
// We already expose a "reenable Backups" button, so no need here.
EmptyView()
case .paidButFreeForTesters:
loadedViewButton(
label: 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.",
),
action: {
viewModel.enableBackups(
planSelection: .userChoice(initialSelection: .paid),
shouldShowWelcomeToBackupsSheet: false,
)
},
)
case .paid:
loadedViewButton(
label: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to manage or cancel their paid backup plan.",
),
action: {
viewModel.showAppStoreManageSubscriptions()
},
)
case .paidButExpiring, .paidButExpired:
loadedViewButton(
label: 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.",
),
action: {
viewModel.showAppStoreManageSubscriptions()
},
)
case .paidButFailedToRenew:
loadedViewButton(
label: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_FAILED_TO_RENEW_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to manage a paid backup plan that failed to renew.",
),
action: {
viewModel.showAppStoreManageSubscriptions()
},
)
case .paidButIAPNotFoundLocally:
HStack(spacing: 16) {
loadedViewButton(
label: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_IAP_NOT_FOUND_LOCALLY_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to renew their backup subscription on this device.",
),
expandWidth: true,
action: {
viewModel.enableBackups(
planSelection: .userChoice(initialSelection: nil),
shouldShowWelcomeToBackupsSheet: false,
)
},
)
loadedViewButton(
label: CommonStrings.learnMore,
expandWidth: true,
action: {
viewModel.showBackupIAPNotFoundLocallySheet()
},
)
}
}
}
/// - Parameter expandWidth
/// If true, the returned Button will expand its width to fill its container
/// rather than just encapsulate its label.
@ViewBuilder
private func loadedViewButton(
label: String,
expandWidth: Bool = false,
action: @escaping () -> Void,
) -> some View {
Button {
action()
} label: {
Text(label)
.frame(maxWidth: expandWidth ? .infinity : nil)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.foregroundStyle(Color.Signal.label)
.font(.subheadline.weight(.medium))
.padding(.top, 4)
}
}
// MARK: -
private struct BackupDetailsView: View {
let lastBackupDetails: BackupSettingsStore.LastBackupDetails?
let shouldAllowBackupUploadsOnCellular: Bool
let viewModel: BackupSettingsViewModel
var body: some View {
HStack {
let lastBackupMessage: String? = {
guard let lastBackupDate = lastBackupDetails?.date 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.nonPluralLocalizedStringWithFormat(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.nonPluralLocalizedStringWithFormat(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.nonPluralLocalizedStringWithFormat(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 = lastBackupDetails?.backupTotalSizeBytes {
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 OWSSequentialProgress<BackupExportJobStage> {
static func forPreview(
_ step: BackupExportJobStage,
) -> OWSSequentialProgress<BackupExportJobStage> {
return OWSProgress(
completedUnitCount: 0,
totalUnitCount: 1,
childProgresses: [
step.rawValue: [OWSProgress.ChildProgress(
completedUnitCount: 33,
totalUnitCount: 100,
label: step.rawValue,
parentLabel: nil,
)],
],
).sequential(BackupExportJobStage.self)
}
}
private extension BackupSettingsViewModel {
static func forPreview(
backupSubscriptionLoadingState: BackupSubscriptionLoadingState,
backupPlan: BackupPlan,
backupSubscriptionAlreadyRedeemed: Bool = false,
failedToDisableBackupsRemotely: Bool = false,
latestBackupExportProgressUpdate: OWSSequentialProgress<BackupExportJobStage>? = nil,
latestBackupAttachmentDownloadUpdateState: BackupAttachmentDownloadProgressView.DownloadUpdate.State? = nil,
latestBackupAttachmentUploadUpdateState: BackupAttachmentUploadProgressView.UploadUpdate.State? = nil,
mediaTierCapacityOverflow: UInt64? = nil,
hasBackupFailed: Bool = false,
isBackgroundAppRefreshDisabled: Bool = false,
) -> BackupSettingsViewModel {
class PreviewActionsDelegate: ActionsDelegate {
func enableBackups(planSelection: EnableBackupsPlanSelection, shouldShowWelcomeToBackupsSheet: Bool) { print("Enabling! planSelection: \(planSelection)") }
func disableBackups() { print("Disabling!") }
func loadBackupSubscription() { print("Loading BackupSubscription!") }
func showAppStoreManageSubscriptions() { print("AppStore Manage Subscriptions!") }
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!") }
func showBackupSubscriptionAlreadyRedeemedSheet() { print("Showing Backup subscription already redeemed sheet!") }
func showBackupIAPNotFoundLocallySheet() { print("Showing Backup IAP not found locally sheet!") }
func showBackgroundAppRefreshDisabledWarningSheet() { print("Showing Background App Refresh warning sheet!") }
}
let viewModel = BackupSettingsViewModel(
backupSubscriptionConfiguration: BackupSubscriptionConfiguration(
storageAllowanceBytes: 100_000_000_000,
freeTierMediaDays: 45,
),
backupSubscriptionLoadingState: backupSubscriptionLoadingState,
backupSubscriptionAlreadyRedeemed: backupSubscriptionAlreadyRedeemed,
backupPlan: backupPlan,
failedToDisableBackupsRemotely: failedToDisableBackupsRemotely,
latestBackupExportProgressUpdate: latestBackupExportProgressUpdate,
latestBackupAttachmentDownloadUpdate: latestBackupAttachmentDownloadUpdateState.map {
BackupAttachmentDownloadProgressView.DownloadUpdate(
state: $0,
bytesDownloaded: 1_400_000_000,
totalBytesToDownload: 1_600_000_000,
percentageDownloaded: 1.4 / 1.6,
)
},
latestBackupAttachmentUploadUpdate: latestBackupAttachmentUploadUpdateState.map {
BackupAttachmentUploadProgressView.UploadUpdate(
state: $0,
bytesUploaded: 400_000_000,
totalBytesToUpload: 1_600_000_000,
percentageUploaded: 0.4 / 1.6,
)
},
lastBackupDetails: BackupSettingsStore.LastBackupDetails(
firstBackupDate: Date().addingTimeInterval(-1 * .week),
date: Date().addingTimeInterval(-1 * .day),
backupFileSizeBytes: 40_000_000,
backupTotalSizeBytes: 2_400_000_000,
),
shouldAllowBackupUploadsOnCellular: false,
mediaTierCapacityOverflow: mediaTierCapacityOverflow,
hasBackupFailed: hasBackupFailed,
isBackgroundAppRefreshDisabled: isBackgroundAppRefreshDisabled,
)
let actionsDelegate = PreviewActionsDelegate()
viewModel.actionsDelegate = actionsDelegate
ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
return viewModel
}
}
#Preview("Plan: Free") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
))
}
#Preview("Plan: Free For Testers") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
))
}
#Preview("Plan: Paid") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paid(
price: FiatMoney(currencyCode: "USD", value: 1.99),
renewalDate: Date().addingTimeInterval(.week),
)),
backupPlan: .paid(optimizeLocalStorage: false),
))
}
#Preview("Plan: Expiring") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButExpiring(
expirationDate: Date().addingTimeInterval(.week),
)),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
))
}
#Preview("Plan: Expired") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButExpired(
expirationDate: Date().addingTimeInterval(-1 * .week),
)),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
))
}
#Preview("Plan: Failed to Renew") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFailedToRenew),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
))
}
#Preview("Plan: Already Redeemed") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paid(
price: FiatMoney(currencyCode: "USD", value: 1.99),
renewalDate: Date().addingTimeInterval(.week),
)),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
backupSubscriptionAlreadyRedeemed: true,
))
}
#Preview("Plan: Paid but No IAP") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButIAPNotFoundLocally),
backupPlan: .paid(optimizeLocalStorage: false),
))
}
#Preview("Plan: Network Error") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .networkError,
backupPlan: .paid(optimizeLocalStorage: false),
))
}
#Preview("Plan: Generic Error") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .genericError,
backupPlan: .paid(optimizeLocalStorage: false),
))
}
#Preview("Failed Backup") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
hasBackupFailed: true,
))
}
#Preview("Out of Quota") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
mediaTierCapacityOverflow: 1_000_000_000,
))
}
#Preview("Background App Refresh Disabled") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
isBackgroundAppRefreshDisabled: true,
))
}
#Preview("Manual Backup: Backup File Export") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.backupFileExport),
))
}
#Preview("Manual Backup: Backup File Upload") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupExportProgressUpdate: .forPreview(.backupFileUpload),
))
}
#Preview("Manual Backup: Media Upload w/o progress") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload),
latestBackupAttachmentUploadUpdateState: nil,
))
}
#Preview("Manual Backup: Media Upload") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload),
latestBackupAttachmentUploadUpdateState: .uploading,
))
}
#Preview("Manual Backup: Media Upload Paused (Low Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload),
latestBackupAttachmentUploadUpdateState: .pausedLowBattery,
))
}
#Preview("Manual Backup: Media Upload Paused (Low Power Mode)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload),
latestBackupAttachmentUploadUpdateState: .pausedLowPowerMode,
))
}
#Preview("Manual Backup: Media Upload Paused (WiFi)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload),
latestBackupAttachmentUploadUpdateState: .pausedNeedsWifi,
))
}
#Preview("Manual Backup: Media Upload Paused (Internet)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentUpload),
latestBackupAttachmentUploadUpdateState: .pausedNeedsInternet,
))
}
#Preview("Manual Backup: Processing Media") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paidButFreeForTesters),
backupPlan: .paidAsTester(optimizeLocalStorage: false),
latestBackupExportProgressUpdate: .forPreview(.attachmentProcessing),
))
}
#Preview("Downloads: Suspended") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.paid(
price: FiatMoney(currencyCode: "USD", value: 1.99),
renewalDate: Date().addingTimeInterval(.week),
)),
backupPlan: .paid(optimizeLocalStorage: false),
latestBackupAttachmentDownloadUpdateState: .suspended,
))
}
#Preview("Downloads: Suspended w/o Paid Plan") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .suspended,
))
}
#Preview("Downloads: Running") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .running,
))
}
#Preview("Downloads: Paused (Low Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedLowBattery,
))
}
#Preview("Downloads: Paused (Low Power Mode)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedLowPowerMode,
))
}
#Preview("Downloads: Paused (WiFi)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedNeedsWifi,
))
}
#Preview("Downloads: Paused (Internet)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .pausedNeedsInternet,
))
}
#Preview("Downloads: Disk Space Error") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentDownloadUpdateState: .outOfDiskSpace(bytesRequired: 200_000_000),
))
}
#Preview("Uploads: Running") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentUploadUpdateState: .uploading,
))
}
#Preview("Uploads: Paused (WiFi)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentUploadUpdateState: .pausedNeedsWifi,
))
}
#Preview("Uploads: Paused (Battery)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndEnabled),
backupPlan: .free,
latestBackupAttachmentUploadUpdateState: .pausedLowBattery,
))
}
#Preview("Disabling: Success") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndDisabled),
backupPlan: .disabled,
))
}
#Preview("Disabling: Remotely") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndDisabled),
backupPlan: .disabling,
))
}
#Preview("Disabling: Remotely (w/ Downloads)") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndDisabled),
backupPlan: .disabling,
latestBackupAttachmentDownloadUpdateState: .pausedNeedsInternet,
))
}
#Preview("Disabling: Remotely Failed") {
BackupSettingsView(viewModel: .forPreview(
backupSubscriptionLoadingState: .loaded(.freeAndDisabled),
backupPlan: .disabled,
failedToDisableBackupsRemotely: true,
))
}
#endif