3559 lines
146 KiB
Swift
3559 lines
146 KiB
Swift
//
|
|
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Lottie
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
import StoreKit
|
|
import SwiftUI
|
|
|
|
class BackupSettingsViewController:
|
|
HostingController<BackupSettingsView>,
|
|
BackupSettingsViewModel.ActionsDelegate
|
|
{
|
|
enum OnAppearAction {
|
|
case presentWelcomeToBackupsSheet
|
|
case automaticallyStartBackup(completion: ((UIViewController) -> Void)?)
|
|
case disableOptimizeLocalStorage
|
|
}
|
|
|
|
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 nil, .presentWelcomeToBackupsSheet, .disableOptimizeLocalStorage:
|
|
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()
|
|
case .disableOptimizeLocalStorage:
|
|
setOptimizeLocalStorage(false)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .OWSApplicationDidBecomeActive) {
|
|
self?.loadBackupSubscription()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .backupPlanChanged) {
|
|
self?._backupPlanDidChange()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .lastBackupDetailsDidChange) {
|
|
self?._lastBackupDetailsDidChange()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .shouldAllowBackupUploadsOnCellularChanged) {
|
|
self?._shouldAllowBackupUploadsOnCellularDidChange()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .backupSubscriptionAlreadyRedeemedDidChange) {
|
|
self?._backupSubscriptionAlreadyRedeemedDidChange()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .backupIAPNotFoundLocallyDidChange) {
|
|
self?._backupIAPNotFoundLocallyDidChange()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: .hasConsumedMediaTierCapacityStatusDidChange) {
|
|
self?._hasConsumedMediaTierCapacityDidChange()
|
|
}
|
|
},
|
|
Task { [weak self] in
|
|
for await _ in NotificationCenter.default.notifications(named: UIApplication.backgroundRefreshStatusDidChangeNotification) {
|
|
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(
|
|
currentBackupPlan: BackupPlan,
|
|
planSelectionOption: BackupSettingsViewModel.EnableBackupsPlanSelectionOption,
|
|
) {
|
|
let areBackupsDisabled = switch currentBackupPlan {
|
|
case .disabling, .disabled: true
|
|
case .free, .paid, .paidExpiringSoon, .paidAsTester: false
|
|
}
|
|
|
|
Task {
|
|
if areBackupsDisabled {
|
|
guard
|
|
let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }),
|
|
let authSuccess = await LocalDeviceAuthentication().performBiometricAuth()
|
|
else {
|
|
return
|
|
}
|
|
|
|
_showRecordAndConfirmExistingRecoveryKey(
|
|
aep: aep,
|
|
authSuccess: authSuccess,
|
|
onConfirmed: { [weak self] in
|
|
Task {
|
|
await self?._enableBackups(
|
|
planSelectionOption: planSelectionOption,
|
|
shouldShowWelcomeToBackupsSheet: true,
|
|
)
|
|
}
|
|
},
|
|
)
|
|
} else {
|
|
await _enableBackups(
|
|
planSelectionOption: planSelectionOption,
|
|
shouldShowWelcomeToBackupsSheet: false,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func _showRecordAndConfirmExistingRecoveryKey(
|
|
aep: AccountEntropyPool,
|
|
authSuccess: LocalDeviceAuthentication.AuthSuccess,
|
|
onConfirmed: @escaping () -> Void,
|
|
) {
|
|
let recordRecoveryKeyViewController = BackupRecordKeyViewController(
|
|
aepMode: .current(aep, authSuccess),
|
|
options: [.showContinueButton],
|
|
onContinuePressed: { [weak self] _ in
|
|
guard let self else { return }
|
|
|
|
let confirmRecoveryKeyViewController = BackupConfirmKeyViewController(
|
|
aep: aep,
|
|
onConfirmed: { _ in
|
|
onConfirmed()
|
|
},
|
|
onSeeKeyAgain: { [weak self] in
|
|
guard let self else { return }
|
|
navigationController?.popViewController(animated: true)
|
|
},
|
|
)
|
|
|
|
navigationController?.pushViewController(confirmRecoveryKeyViewController, animated: true)
|
|
},
|
|
)
|
|
|
|
navigationController?.pushViewController(recordRecoveryKeyViewController, animated: true)
|
|
}
|
|
|
|
@MainActor
|
|
private func _enableBackups(
|
|
planSelectionOption: BackupSettingsViewModel.EnableBackupsPlanSelectionOption,
|
|
shouldShowWelcomeToBackupsSheet: Bool,
|
|
) async {
|
|
switch planSelectionOption {
|
|
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(
|
|
optimizeLocalStorage: (isOn: Bool, onValueChanged: (Bool) -> Void)?,
|
|
onConfirm: @escaping (HeroSheetViewController) -> Void,
|
|
) {
|
|
let toggle: HeroSheetViewController.Body.Toggle?
|
|
if let (isOn, onValueChanged) = optimizeLocalStorage {
|
|
toggle = HeroSheetViewController.Body.Toggle(
|
|
title: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_TITLE",
|
|
comment: "Title for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.",
|
|
),
|
|
footer: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_FOOTER",
|
|
comment: "Footer for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.",
|
|
),
|
|
isOn: isOn,
|
|
onValueChanged: onValueChanged,
|
|
)
|
|
} else {
|
|
toggle = nil
|
|
}
|
|
|
|
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: HeroSheetViewController.Body(
|
|
textContent: .plain(OWSLocalizedString(
|
|
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
|
|
comment: "Message for a sheet shown after the user enables backups.",
|
|
)),
|
|
toggle: toggle,
|
|
),
|
|
primary: .button(HeroSheetViewController.Button(
|
|
title: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_BUTTON_TITLE",
|
|
comment: "Title for a button in a sheet shown after the user enables backups.",
|
|
),
|
|
action: { onConfirm($0) },
|
|
)),
|
|
secondary: nil,
|
|
)
|
|
}
|
|
}
|
|
|
|
let backupPlan = db.read { tx in
|
|
backupPlanManager.backupPlan(tx: tx)
|
|
}
|
|
|
|
let welcomeToBackupsSheet: WelcomeToBackupsSheet
|
|
switch backupPlan {
|
|
case .disabled,
|
|
.disabling,
|
|
.free:
|
|
welcomeToBackupsSheet = WelcomeToBackupsSheet(
|
|
optimizeLocalStorage: nil,
|
|
onConfirm: { sheet in
|
|
sheet.dismiss(animated: true) { [self] in
|
|
viewModel.performManualBackup()
|
|
}
|
|
},
|
|
)
|
|
case .paid,
|
|
.paidAsTester,
|
|
.paidExpiringSoon:
|
|
var isOptimizeStorageEnabled = false
|
|
|
|
welcomeToBackupsSheet = WelcomeToBackupsSheet(
|
|
optimizeLocalStorage: (
|
|
isOn: isOptimizeStorageEnabled,
|
|
onValueChanged: { isOptimizeStorageEnabled = $0 },
|
|
),
|
|
onConfirm: { sheet in
|
|
sheet.dismiss(animated: true) { [self] in
|
|
setOptimizeLocalStorage(isOptimizeStorageEnabled)
|
|
viewModel.performManualBackup()
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
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 BackupArchive.Response.BackupUploadFormError.tooLarge:
|
|
actionSheet = ActionSheetController(
|
|
message: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_FILE_TOO_LARGE",
|
|
comment: "Message for an action sheet explaining that performing a backup failed because the backup file is too large to upload.",
|
|
),
|
|
)
|
|
actionSheet.addAction(.okay)
|
|
|
|
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 hasMadeAtLeastOneBackup: Bool? = db.write { tx in
|
|
let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
|
|
let lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx)
|
|
|
|
let newBackupPlan: BackupPlan
|
|
switch currentBackupPlan {
|
|
case .disabled,
|
|
.disabling,
|
|
.free,
|
|
.paid(optimizeLocalStorage: newOptimizeLocalStorage),
|
|
.paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage),
|
|
.paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage):
|
|
return nil
|
|
case .paid:
|
|
newBackupPlan = .paid(optimizeLocalStorage: newOptimizeLocalStorage)
|
|
case .paidExpiringSoon:
|
|
newBackupPlan = .paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage)
|
|
case .paidAsTester:
|
|
newBackupPlan = .paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage)
|
|
}
|
|
|
|
backupPlanManager.setBackupPlan(newBackupPlan, tx: tx)
|
|
return lastBackupDetails != nil
|
|
}
|
|
|
|
if
|
|
hasMadeAtLeastOneBackup == true,
|
|
!newOptimizeLocalStorage
|
|
{
|
|
// If disabling Optimize Local Storage with media potentially
|
|
// offloaded, offer to start downloads now.
|
|
showDownloadOffloadedMediaSheet()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan) {
|
|
if isSuspended {
|
|
let warningTitle: String?
|
|
let warningMessage: String?
|
|
|
|
switch backupPlan {
|
|
case .disabled, .disabling:
|
|
warningTitle = OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_DISABLING_WARNING_SHEET_TITLE",
|
|
comment: "Title for a sheet warning the user about skipping downloads while disabling Backups.",
|
|
)
|
|
warningMessage = OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_DISABLING_WARNING_SHEET_MESSAGE",
|
|
comment: "Message for a sheet warning the user about skipping downloads while disabling Backups.",
|
|
)
|
|
case .free, .paidExpiringSoon:
|
|
warningTitle = OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_EXPIRING_WARNING_SHEET_TITLE",
|
|
comment: "Title for a sheet warning the user about skipping downloads that will expire.",
|
|
)
|
|
warningMessage = OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_EXPIRING_WARNING_SHEET_MESSAGE",
|
|
comment: "Message for a sheet warning the user about skipping downloads that will expire.",
|
|
)
|
|
case .paid, .paidAsTester:
|
|
warningTitle = nil
|
|
warningMessage = nil
|
|
}
|
|
|
|
if let warningTitle, let warningMessage {
|
|
let warningSheet = ActionSheetController(
|
|
title: warningTitle,
|
|
message: warningMessage,
|
|
)
|
|
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
|
|
let secondWarningSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_TITLE",
|
|
comment: "Title for a double-confirmation sheet warning the user about skipping downloads.",
|
|
),
|
|
message: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_MESSAGE",
|
|
comment: "Message for a double-confirmation sheet warning the user about skipping downloads.",
|
|
),
|
|
)
|
|
secondWarningSheet.addAction(ActionSheetAction(
|
|
title: OWSLocalizedString(
|
|
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_ACTION_SKIP",
|
|
comment: "Title for an action in a double-confirmation sheet warning the user about skipping downloads.",
|
|
),
|
|
style: .destructive,
|
|
handler: { [self] _ in
|
|
db.write { tx in
|
|
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
|
}
|
|
},
|
|
))
|
|
secondWarningSheet.addAction(.cancel)
|
|
|
|
presentActionSheet(secondWarningSheet)
|
|
},
|
|
))
|
|
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(true, tx: tx)
|
|
}
|
|
}
|
|
} 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,
|
|
onConfirmed: { [weak self] _ in
|
|
guard let self else { return }
|
|
|
|
// Pop all the way back to Backup Settings.
|
|
navigationController?.popToViewController(self, animated: true) {
|
|
self.finalizeNewRecoveryKey(newCandidateAEP: newCandidateAEP)
|
|
|
|
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,
|
|
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 EnableBackupsPlanSelectionOption {
|
|
case required(ChooseBackupPlanViewController.PlanSelection)
|
|
case userChoice(initialSelection: ChooseBackupPlanViewController.PlanSelection?)
|
|
}
|
|
|
|
protocol ActionsDelegate: AnyObject {
|
|
func enableBackups(currentBackupPlan: BackupPlan, planSelectionOption: EnableBackupsPlanSelectionOption)
|
|
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(planSelectionOption: EnableBackupsPlanSelectionOption) {
|
|
actionsDelegate?.enableBackups(
|
|
currentBackupPlan: backupPlan,
|
|
planSelectionOption: planSelectionOption,
|
|
)
|
|
}
|
|
|
|
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: -
|
|
|
|
/// Whether the "Optimze Storage" feature is available, per the current
|
|
/// `BackupPlan`.
|
|
var isOptimizeLocalStorageAvailable: Bool {
|
|
switch backupPlan {
|
|
case .disabled, .disabling, .free:
|
|
false
|
|
case .paid, .paidAsTester:
|
|
true
|
|
case .paidExpiringSoon(let optimizeLocalStorage):
|
|
// Only allow disabling Optimize Storage if expiring soon, not enabling.
|
|
optimizeLocalStorage
|
|
}
|
|
}
|
|
|
|
var isOptimizeLocalStorageEnabled: 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,
|
|
)
|
|
|
|
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.isOptimizeLocalStorageEnabled },
|
|
set: { viewModel.setOptimizeLocalStorage($0) },
|
|
),
|
|
).disabled(!viewModel.isOptimizeLocalStorageAvailable)
|
|
} footer: {
|
|
let footerText: String = if viewModel.isOptimizeLocalStorageAvailable {
|
|
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 enableBackupsPlanSelectionOption: BackupSettingsViewModel.EnableBackupsPlanSelectionOption? {
|
|
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 enableBackupsPlanSelectionOption {
|
|
Button {
|
|
viewModel.enableBackups(
|
|
planSelectionOption: enableBackupsPlanSelectionOption,
|
|
)
|
|
} 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(
|
|
planSelectionOption: .userChoice(initialSelection: .free),
|
|
)
|
|
},
|
|
)
|
|
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(
|
|
planSelectionOption: .userChoice(initialSelection: .paid),
|
|
)
|
|
},
|
|
)
|
|
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(
|
|
planSelectionOption: .userChoice(initialSelection: nil),
|
|
)
|
|
},
|
|
)
|
|
|
|
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(currentBackupPlan: BackupPlan, planSelectionOption: EnableBackupsPlanSelectionOption) { print("Enabling! planSelectionOption: \(planSelectionOption)") }
|
|
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: true),
|
|
latestBackupAttachmentDownloadUpdateState: .suspended,
|
|
))
|
|
}
|
|
|
|
#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
|