Signal-iOS/Signal/Backups/BackupSettingsViewController.swift
2025-06-12 12:38:57 -07:00

1222 lines
46 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> {
enum OnLoadAction {
case none
case presentWelcomeToBackupsSheet
}
private let accountKeyStore: AccountKeyStore
private let backupDisablingManager: BackupDisablingManager
private let backupEnablingManager: BackupEnablingManager
private let backupSettingsStore: BackupSettingsStore
private let backupSubscriptionManager: BackupSubscriptionManager
private let db: DB
private let onLoadAction: OnLoadAction
private let viewModel: BackupSettingsViewModel
convenience init(
onLoadAction: OnLoadAction,
) {
self.init(
onLoadAction: onLoadAction,
accountKeyStore: DependenciesBridge.shared.accountKeyStore,
backupDisablingManager: AppEnvironment.shared.backupDisablingManager,
backupEnablingManager: AppEnvironment.shared.backupEnablingManager,
backupSettingsStore: BackupSettingsStore(),
backupSubscriptionManager: DependenciesBridge.shared.backupSubscriptionManager,
db: DependenciesBridge.shared.db,
)
}
init(
onLoadAction: OnLoadAction,
accountKeyStore: AccountKeyStore,
backupDisablingManager: BackupDisablingManager,
backupEnablingManager: BackupEnablingManager,
backupSettingsStore: BackupSettingsStore,
backupSubscriptionManager: BackupSubscriptionManager,
db: DB,
) {
self.accountKeyStore = accountKeyStore
self.backupDisablingManager = backupDisablingManager
self.backupEnablingManager = backupEnablingManager
self.backupSettingsStore = backupSettingsStore
self.backupSubscriptionManager = backupSubscriptionManager
self.db = db
self.onLoadAction = onLoadAction
self.viewModel = db.read { tx in
let viewModel = BackupSettingsViewModel(
backupEnabledState: .disabled, // Default, set below
backupPlanLoadingState: .loading, // Default, loaded after init
lastBackupDate: backupSettingsStore.lastBackupDate(tx: tx),
lastBackupSizeBytes: backupSettingsStore.lastBackupSizeBytes(tx: tx),
backupFrequency: backupSettingsStore.backupFrequency(tx: tx),
shouldBackUpOnCellular: backupSettingsStore.shouldBackUpOnCellular(tx: tx)
)
if let disableBackupsRemotelyState = backupDisablingManager.currentDisableRemotelyState(tx: tx) {
viewModel.handleDisableBackupsRemoteState(disableBackupsRemotelyState)
} else {
switch backupSettingsStore.backupPlan(tx: tx) {
case .disabled:
viewModel.backupEnabledState = .disabled
case .free, .paid, .paidExpiringSoon:
viewModel.backupEnabledState = .enabled
}
}
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
// Run as soon as we've set the actionDelegate.
viewModel.loadBackupPlan()
NotificationCenter.default.addObserver(
forName: BackupSettingsStore.Notifications.backupPlanChanged,
object: nil,
queue: nil
) { [weak self] _ in
guard let self else { return }
viewModel.loadBackupPlan()
}
}
override func viewDidLoad() {
switch onLoadAction {
case .none:
break
case .presentWelcomeToBackupsSheet:
presentWelcomeToBackupsSheet()
}
}
}
// MARK: - BackupSettingsViewModel.ActionsDelegate
extension BackupSettingsViewController: BackupSettingsViewModel.ActionsDelegate {
fileprivate func enableBackups(
implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?
) {
// TODO: [Backups] Show the rest of the onboarding flow.
Task {
if let planSelection = implicitPlanSelection {
await _enableBackups(
fromViewController: self,
planSelection: planSelection
)
} else {
await showChooseBackupPlan(initialPlanSelection: nil)
}
}
}
@MainActor
private func showChooseBackupPlan(
initialPlanSelection: ChooseBackupPlanViewController.PlanSelection?
) async {
let chooseBackupPlanViewController: ChooseBackupPlanViewController
do throws(OWSAssertionError) {
chooseBackupPlanViewController = try await .load(
fromViewController: self,
initialPlanSelection: initialPlanSelection,
onConfirmPlanSelectionBlock: { [weak self] chooseBackupPlanViewController, planSelection in
Task { [weak self] in
guard let self else { return }
await _enableBackups(
fromViewController: chooseBackupPlanViewController,
planSelection: planSelection
)
}
}
)
} catch {
return
}
navigationController?.pushViewController(
chooseBackupPlanViewController,
animated: true
)
}
@MainActor
private func _enableBackups(
fromViewController: UIViewController,
planSelection: ChooseBackupPlanViewController.PlanSelection
) async {
do throws(BackupEnablingManager.DisplayableError) {
try await backupEnablingManager.enableBackups(
fromViewController: fromViewController,
planSelection: planSelection
)
} catch {
OWSActionSheets.showActionSheet(
message: error.localizedActionSheetMessage,
fromViewController: fromViewController,
)
return
}
navigationController?.popToViewController(self, animated: true) { [self] in
// We know we're enabled now!
viewModel.backupEnabledState = .enabled
presentWelcomeToBackupsSheet()
}
}
private func presentWelcomeToBackupsSheet() {
let welcomeToBackupsSheet = HeroSheetViewController(
hero: .image(.backupsSubscribed),
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE",
comment: "Title for a sheet shown after the user enables backups."
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
comment: "Message for a sheet shown after the user enables backups."
),
primary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_PRIMARY_BUTTON",
comment: "Title for the primary button for a sheet shown after the user enables backups."
),
action: { _ in
self.viewModel.performManualBackup()
}
)),
secondary: .button(.dismissing(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_SECONDARY_BUTTON",
comment: "Title for the secondary button for a sheet shown after the user enables backups."
),
style: .secondary
))
)
present(welcomeToBackupsSheet, animated: true)
}
// MARK: -
fileprivate func disableBackups() {
AssertIsOnMainThread()
func errorActionSheet(_ message: String) {
OWSActionSheets.showActionSheet(
message: message,
fromViewController: self
)
}
do throws(BackupDisablingManager.NotRegisteredError) {
try db.write { tx throws(BackupDisablingManager.NotRegisteredError) in
return try backupDisablingManager.disableBackups(tx: tx)
}
if let disableRemotelyState = db.read(block: { backupDisablingManager.currentDisableRemotelyState(tx: $0) }) {
viewModel.handleDisableBackupsRemoteState(disableRemotelyState)
}
} catch {
errorActionSheet(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."
))
}
}
func showDisablingBackupsFailedSheet() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_ERROR_GENERIC_ERROR_ACTION_SHEET_TITLE",
comment: "Title shown in an action sheet indicating we failed to delete the user's Backup due to an unexpected error."
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_DISABLING_ERROR_GENERIC_ERROR_ACTION_SHEET_MESSAGE",
comment: "Message shown in an action sheet indicating we failed to delete the user's Backup due to an unexpected error."
),
)
actionSheet.addAction(ActionSheetAction(title: CommonStrings.contactSupport) { _ in
ContactSupportActionSheet.present(
emailFilter: .custom("iOS Disable Backups Failed"),
logDumper: .fromGlobals(),
fromViewController: self
)
})
actionSheet.addAction(.okay)
OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
}
// MARK: -
fileprivate func loadBackupPlan() async throws -> BackupSettingsViewModel.BackupPlanLoadingState.LoadedBackupPlan {
switch db.read(block: { backupSettingsStore.backupPlan(tx: $0) }) {
case .free:
return .free
case .disabled, .paid, .paidExpiringSoon:
break
}
guard
let backupSubscription = try await backupSubscriptionManager
.fetchAndMaybeDowngradeSubscription()
else {
return .free
}
let endOfCurrentPeriod = Date(timeIntervalSince1970: backupSubscription.endOfCurrentPeriod)
switch backupSubscription.status {
case .active, .pastDue:
// `.pastDue` means that a renewal failed, but the payment
// processor is automatically retrying. For now, assume it
// may recover, and show it as paid. If it fails, it'll
// become `.canceled` instead.
if backupSubscription.cancelAtEndOfPeriod {
return .paidExpiringSoon(expirationDate: endOfCurrentPeriod)
}
return .paid(
price: backupSubscription.amount,
renewalDate: endOfCurrentPeriod
)
case .canceled:
// TODO: [Backups] Downgrade local state to the free plan, if necessary.
// This might be the first place we learn, locally, that our
// subscription has expired and we've been implicitly downgraded to
// the free plan. Correspondingly, we should use this as a change to
// set local state, if necessary. Make sure to log that state change
// loudly!
return .free
case .incomplete, .unpaid, .unknown:
// These are unexpected statuses, so we know that something
// is wrong with the subscription. Consequently, we can show
// it as free.
owsFailDebug("Unexpected backup subscription status! \(backupSubscription.status)")
return .free
}
}
// MARK: -
fileprivate func upgradeFromFreeToPaidPlan() {
Task {
await showChooseBackupPlan(initialPlanSelection: .free)
}
}
fileprivate func manageOrCancelPaidPlan() {
guard let windowScene = view.window?.windowScene else {
owsFailDebug("Missing window scene!")
return
}
Task {
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
owsFailDebug("Failed to show manage-subscriptions view! \(error)")
}
// Reload the BackupPlan, since our subscription may now be in a
// different state (e.g., set to not renew).
viewModel.loadBackupPlan()
}
}
fileprivate func resubscribeToPaidPlan() {
Task {
await showChooseBackupPlan(initialPlanSelection: .free)
}
}
// MARK: -
fileprivate func performManualBackup() {
// TODO: [Backups] Implement
}
fileprivate func setBackupFrequency(_ newBackupFrequency: BackupFrequency) {
db.write { tx in
backupSettingsStore.setBackupFrequency(newBackupFrequency, tx: tx)
}
}
fileprivate func setShouldBackUpOnCellular(_ newShouldBackUpOnCellular: Bool) {
db.write { tx in
backupSettingsStore.setShouldBackUpOnCellular(newShouldBackUpOnCellular, tx: tx)
}
}
// MARK: -
fileprivate func showViewBackupKey() {
Task { await _showViewBackupKey() }
}
@MainActor
private func _showViewBackupKey() async {
guard let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }) else {
return
}
guard await LocalDeviceAuthentication().performBiometricAuth() else {
return
}
navigationController?.pushViewController(
BackupRecordKeyViewController(
aep: aep,
isOnboardingFlow: false,
onCompletion: { [weak self] recordKeyViewController in
self?.showKeyRecordedConfirmationSheet(
fromViewController: recordKeyViewController
)
}
),
animated: true
)
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
private func showKeyRecordedConfirmationSheet(fromViewController: BackupRecordKeyViewController) {
let sheet = HeroSheetViewController(
hero: .image(.backupsKey),
title: OWSLocalizedString(
"BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_TITLE",
comment: "Title for a sheet warning users to their 'Backup Key' safe."
),
body: OWSLocalizedString(
"BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_BODY",
comment: "Body for a sheet warning users to their 'Backup Key' safe."
),
primary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"BUTTON_CONTINUE",
comment: "Label for 'continue' button."
),
action: { [weak self] _ in
self?.dismiss(animated: true)
self?.navigationController?.interactivePopGestureRecognizer?.isEnabled = true
self?.navigationController?.popViewController(animated: true)
}
)),
secondary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_ONBOARDING_CONFIRM_KEY_SEE_KEY_AGAIN_BUTTON_TITLE",
comment: "Title for a button offering to let users see their 'Backup Key'."
),
style: .secondary,
action: .custom({ [weak self] _ in
self?.dismiss(animated: true)
self?.navigationController?.interactivePopGestureRecognizer?.isEnabled = true
})
))
)
fromViewController.present(sheet, animated: true)
}}
// MARK: -
private class BackupSettingsViewModel: ObservableObject {
protocol ActionsDelegate: AnyObject {
func enableBackups(implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?)
func disableBackups()
func showDisablingBackupsFailedSheet()
func loadBackupPlan() async throws -> BackupPlanLoadingState.LoadedBackupPlan
func upgradeFromFreeToPaidPlan()
func manageOrCancelPaidPlan()
func resubscribeToPaidPlan()
func performManualBackup()
func setBackupFrequency(_ newBackupFrequency: BackupFrequency)
func setShouldBackUpOnCellular(_ newShouldBackUpOnCellular: Bool)
func showViewBackupKey()
}
enum BackupPlanLoadingState {
enum LoadedBackupPlan {
case free
case paid(price: FiatMoney, renewalDate: Date)
case paidExpiringSoon(expirationDate: Date)
}
case loading
case loaded(LoadedBackupPlan)
case networkError
case genericError
}
enum BackupEnabledState {
case enabled
case disabled
case disabledLocallyStillDisablingRemotely
case disabledLocallyButDisableRemotelyFailed
}
@Published var backupPlanLoadingState: BackupPlanLoadingState
@Published var backupEnabledState: BackupEnabledState
@Published var lastBackupDate: Date?
@Published var lastBackupSizeBytes: UInt64?
@Published var backupFrequency: BackupFrequency
@Published var shouldBackUpOnCellular: Bool
weak var actionsDelegate: ActionsDelegate?
private let loadBackupPlanQueue: SerialTaskQueue
init(
backupEnabledState: BackupEnabledState,
backupPlanLoadingState: BackupPlanLoadingState,
lastBackupDate: Date?,
lastBackupSizeBytes: UInt64?,
backupFrequency: BackupFrequency,
shouldBackUpOnCellular: Bool,
) {
self.backupEnabledState = backupEnabledState
self.backupPlanLoadingState = backupPlanLoadingState
self.lastBackupDate = lastBackupDate
self.lastBackupSizeBytes = lastBackupSizeBytes
self.backupFrequency = backupFrequency
self.shouldBackUpOnCellular = shouldBackUpOnCellular
self.loadBackupPlanQueue = SerialTaskQueue()
}
// MARK: -
func enableBackups(implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?) {
actionsDelegate?.enableBackups(implicitPlanSelection: implicitPlanSelection)
}
func disableBackups() {
actionsDelegate?.disableBackups()
}
func handleDisableBackupsRemoteState(
_ disablingRemotelyState: BackupDisablingManager.DisableRemotelyState
) {
let disableRemotelyTask: Task<Void, Error>
switch disablingRemotelyState {
case .inProgress(let task):
withAnimation {
backupEnabledState = .disabledLocallyStillDisablingRemotely
}
disableRemotelyTask = task
case .previouslyFailed:
withAnimation {
backupEnabledState = .disabledLocallyButDisableRemotelyFailed
}
return
}
Task { @MainActor in
let newBackupEnabledState: BackupEnabledState
do {
try await disableRemotelyTask.value
newBackupEnabledState = .disabled
} catch {
newBackupEnabledState = .disabledLocallyButDisableRemotelyFailed
actionsDelegate?.showDisablingBackupsFailedSheet()
}
withAnimation {
backupEnabledState = newBackupEnabledState
}
}
}
// MARK: -
func loadBackupPlan() {
guard let actionsDelegate else { return }
loadBackupPlanQueue.enqueue { @MainActor [self, actionsDelegate] in
withAnimation {
backupPlanLoadingState = .loading
}
let newLoadingState: BackupPlanLoadingState
do {
let backupPlan = try await actionsDelegate.loadBackupPlan()
newLoadingState = .loaded(backupPlan)
} catch let error where error.isNetworkFailureOrTimeout {
newLoadingState = .networkError
} catch {
newLoadingState = .genericError
}
withAnimation {
backupPlanLoadingState = newLoadingState
}
}
}
func upgradeFromFreeToPaidPlan() {
actionsDelegate?.upgradeFromFreeToPaidPlan()
}
func manageOrCancelPaidPlan() {
actionsDelegate?.manageOrCancelPaidPlan()
}
func resubscribeToPaidPlan() {
actionsDelegate?.resubscribeToPaidPlan()
}
// MARK: -
func performManualBackup() {
actionsDelegate?.performManualBackup()
}
func setBackupFrequency(_ newBackupFrequency: BackupFrequency) {
backupFrequency = newBackupFrequency
actionsDelegate?.setBackupFrequency(newBackupFrequency)
}
func setShouldBackUpOnCellular(_ newShouldBackUpOnCellular: Bool) {
shouldBackUpOnCellular = newShouldBackUpOnCellular
actionsDelegate?.setShouldBackUpOnCellular(newShouldBackUpOnCellular)
}
// MARK: -
func showViewBackupKey() {
actionsDelegate?.showViewBackupKey()
}
}
// MARK: -
struct BackupSettingsView: View {
@ObservedObject private var viewModel: BackupSettingsViewModel
fileprivate init(viewModel: BackupSettingsViewModel) {
self.viewModel = viewModel
}
var body: some View {
SignalList {
SignalSection {
BackupPlanView(
loadingState: viewModel.backupPlanLoadingState,
viewModel: viewModel
)
}
switch viewModel.backupEnabledState {
case .enabled:
SignalSection {
Button {
viewModel.performManualBackup()
} label: {
Label {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_MANUAL_BACKUP_BUTTON_TITLE",
comment: "Title for a button allowing users to trigger a manual backup."
))
} icon: {
Image(uiImage: Theme.iconImage(.backup))
.resizable()
.frame(width: 24, height: 24)
}
}
.foregroundStyle(Color.Signal.label)
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_ENABLED_SECTION_HEADER",
comment: "Header for a menu section related to settings for when Backups are enabled."
))
}
SignalSection {
BackupEnabledView(viewModel: viewModel)
}
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 .disabled:
SignalSection {
reenableBackupsButton
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLED_SECTION_FOOTER",
comment: "Footer for a menu section related to settings for when Backups are disabled."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
case .disabledLocallyStillDisablingRemotely:
SignalSection {
VStack(alignment: .leading) {
LottieView(animation: .named("linear_indeterminate"))
.playing(loopMode: .loop)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
Spacer().frame(height: 16)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_PROGRESS_VIEW_DESCRIPTION",
comment: "Description for a progress view tracking Backups being disabled."
))
.foregroundStyle(Color.Signal.secondaryLabel)
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
} header: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUPS_DISABLING_SECTION_HEADER",
comment: "Header for a menu section related to disabling Backups."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
case .disabledLocallyButDisableRemotelyFailed:
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
}
}
}
}
/// A button to enable Backups if it was previously disabled, if we can let
/// the user reenable.
private var reenableBackupsButton: AnyView {
let implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?
switch viewModel.backupPlanLoadingState {
case .loading, .networkError:
// Don't let them reenable until we know if they're already paying
// or not.
return AnyView(EmptyView())
case .loaded(.free), .genericError:
// Let the reenable with anything.
implicitPlanSelection = nil
case .loaded(.paid), .loaded(.paidExpiringSoon):
// Only let the user reenable with .paid, because they're already
// paying.
implicitPlanSelection = .paid
}
return AnyView(
Button {
viewModel.enableBackups(implicitPlanSelection: implicitPlanSelection)
} label: {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_REENABLE_BACKUPS_BUTTON_TITLE",
comment: "Title for a button allowing users to re-enable Backups, after it had been previously disabled."
))
}
.buttonStyle(.plain)
)
}
}
// MARK: -
private struct BackupPlanView: View {
let loadingState: BackupSettingsViewModel.BackupPlanLoadingState
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 loadedBackupPlan):
loadedView(
loadedBackupPlan: loadedBackupPlan,
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.loadBackupPlan()
} label: {
Text(CommonStrings.retryButton)
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
.frame(maxWidth: .infinity)
.frame(minHeight: 140)
case .genericError:
VStack(alignment: .center) {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_GENERIC_ERROR_TITLE",
comment: "Title for a view indicating we failed to fetch someone's Backup plan due to an unexpected error."
))
.font(.subheadline)
.bold()
.foregroundStyle(Color.Signal.secondaryLabel)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_GENERIC_ERROR_MESSAGE",
comment: "Message for a view indicating we failed to fetch someone's Backup plan due to an unexpected error."
))
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
}
.frame(maxWidth: .infinity)
.frame(minHeight: 140)
}
}
private func loadedView(
loadedBackupPlan: BackupSettingsViewModel.BackupPlanLoadingState.LoadedBackupPlan,
viewModel: BackupSettingsViewModel
) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading) {
Group {
switch loadedBackupPlan {
case .free:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_HEADER",
comment: "Header describing what the free backup plan includes."
))
case .paid, .paidExpiringSoon:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_HEADER",
comment: "Header describing what the paid backup plan includes."
))
}
}
.font(.subheadline)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer().frame(height: 8)
switch loadedBackupPlan {
case .free:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_DESCRIPTION",
comment: "Text describing the user's free backup plan."
))
case .paid(let price, let renewalDate):
let renewalStringFormat = OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_RENEWAL_FORMAT",
comment: "Text explaining when the user's paid backup plan renews. Embeds {{ the formatted renewal date }}."
)
let priceStringFormat = OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_PRICE_FORMAT",
comment: "Text explaining the price of the user's paid backup plan. Embeds {{ the formatted price }}."
)
Text(String(
format: priceStringFormat,
CurrencyFormatter.format(money: price)
))
Text(String(
format: renewalStringFormat,
DateFormatter.localizedString(from: renewalDate, dateStyle: .medium, timeStyle: .none)
))
case .paidExpiringSoon(let expirationDate):
let expirationDateFutureString = 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 }}."
)
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_CANCELED_DESCRIPTION",
comment: "Text describing that the user's paid backup plan has been canceled."
))
.foregroundStyle(Color.Signal.red)
Text(String(
format: expirationDateFutureString,
DateFormatter.localizedString(from: expirationDate, dateStyle: .medium, timeStyle: .none)
))
}
Spacer().frame(height: 16)
Button {
switch loadedBackupPlan {
case .free:
viewModel.upgradeFromFreeToPaidPlan()
case .paid:
viewModel.manageOrCancelPaidPlan()
case .paidExpiringSoon:
viewModel.resubscribeToPaidPlan()
}
} label: {
switch loadedBackupPlan {
case .free:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_FREE_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to upgrade from a free to paid backup plan."
))
case .paid:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to manage or cancel their paid backup plan."
))
case .paidExpiringSoon:
Text(OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_PLAN_PAID_BUT_CANCELED_ACTION_BUTTON_TITLE",
comment: "Title for a button allowing users to reenable a paid backup plan that has been canceled."
))
}
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.foregroundStyle(Color.Signal.label)
.padding(.top, 8)
}
Spacer()
Image("backups-subscribed")
.frame(width: 56, height: 56)
}
.padding(.horizontal, 4)
.padding(.vertical, 8)
}
}
// MARK: -
private struct BackupEnabledView: View {
let viewModel: BackupSettingsViewModel
var body: some View {
HStack {
let lastBackupMessage: String? = {
guard let lastBackupDate = viewModel.lastBackupDate else {
return nil
}
let lastBackupDateString = DateFormatter.localizedString(from: lastBackupDate, dateStyle: .medium, timeStyle: .none)
let lastBackupTimeString = DateFormatter.localizedString(from: lastBackupDate, dateStyle: .none, timeStyle: .short)
if Calendar.current.isDateInToday(lastBackupDate) {
let todayFormatString = OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_TODAY_FORMAT",
comment: "Text explaining that the user's last backup was today. Embeds {{ the time of the backup }}."
)
return String(format: todayFormatString, lastBackupTimeString)
} else if Calendar.current.isDateInYesterday(lastBackupDate) {
let yesterdayFormatString = OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_YESTERDAY_FORMAT",
comment: "Text explaining that the user's last backup was yesterday. Embeds {{ the time of the backup }}."
)
return String(format: yesterdayFormatString, lastBackupTimeString)
} else {
let pastFormatString = OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_PAST_FORMAT",
comment: "Text explaining that the user's last backup was in the past. Embeds 1:{{ the date of the backup }} and 2:{{ the time of the backup }}."
)
return String(format: pastFormatString, lastBackupDateString, lastBackupTimeString)
}
}()
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_LAST_BACKUP_LABEL",
comment: "Label for a menu item explaining when the user's last backup occurred."
))
Spacer()
if let lastBackupMessage {
Text(lastBackupMessage)
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
HStack {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_SIZE_LABEL",
comment: "Label for a menu item explaining the size of the user's backup."
))
Spacer()
if let lastBackupSizeBytes = viewModel.lastBackupSizeBytes {
Text(lastBackupSizeBytes.formatted(.byteCount(style: .decimal)))
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
Picker(
OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_FREQUENCY_LABEL",
comment: "Label for a menu item explaining the frequency of automatic backups."
),
selection: Binding(
get: { viewModel.backupFrequency },
set: { viewModel.setBackupFrequency($0) }
)
) {
ForEach(BackupFrequency.allCases) { frequency in
let localizedString: String = switch frequency {
case .daily: OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_FREQUENCY_DAILY",
comment: "Text describing that a user's backup will be automatically performed daily."
)
case .weekly: OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_FREQUENCY_WEEKLY",
comment: "Text describing that a user's backup will be automatically performed weekly."
)
case .monthly: OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_FREQUENCY_MONTHLY",
comment: "Text describing that a user's backup will be automatically performed monthly."
)
case .manually: OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_BACKUP_FREQUENCY_MANUALLY",
comment: "Text describing that a user's backup will only be performed manually."
)
}
Text(localizedString).tag(frequency)
}
}
HStack {
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: { viewModel.shouldBackUpOnCellular },
set: { viewModel.setShouldBackUpOnCellular($0) }
)
)
}
Button {
viewModel.showViewBackupKey()
} label: {
HStack {
Text(OWSLocalizedString(
"BACKUP_SETTINGS_ENABLED_VIEW_BACKUP_KEY_LABEL",
comment: "Label for a menu item offering to show the user their backup key."
))
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Color.Signal.secondaryLabel)
}
}
.foregroundStyle(Color.Signal.label)
}
}
// MARK: -
#if DEBUG
private extension BackupSettingsViewModel {
static func forPreview(
backupEnabledState: BackupEnabledState,
planLoadResult: Result<BackupPlanLoadingState.LoadedBackupPlan, Error>,
) -> BackupSettingsViewModel {
class PreviewActionsDelegate: ActionsDelegate {
private let planLoadResult: Result<BackupPlanLoadingState.LoadedBackupPlan, Error>
init(planLoadResult: Result<BackupPlanLoadingState.LoadedBackupPlan, Error>) {
self.planLoadResult = planLoadResult
}
func enableBackups(implicitPlanSelection: ChooseBackupPlanViewController.PlanSelection?) { print("Enabling! implicitPlanSelection: \(implicitPlanSelection as Any)") }
func disableBackups() { print("Disabling!") }
func showDisablingBackupsFailedSheet() { print("Showing disabling-Backups-failed sheet!") }
func loadBackupPlan() async throws -> BackupSettingsViewModel.BackupPlanLoadingState.LoadedBackupPlan {
try! await Task.sleep(nanoseconds: 2.clampedNanoseconds)
return try planLoadResult.get()
}
func upgradeFromFreeToPaidPlan() { print("Upgrading!") }
func manageOrCancelPaidPlan() { print("Managing or canceling!") }
func resubscribeToPaidPlan() { print("Resubscribing!") }
func performManualBackup() { print("Manually backing up!") }
func setBackupFrequency(_ newBackupFrequency: BackupFrequency) { print("Frequency: \(newBackupFrequency)") }
func setShouldBackUpOnCellular(_ newShouldBackUpOnCellular: Bool) { print("Cellular: \(newShouldBackUpOnCellular)") }
func showViewBackupKey() { print("Showing View Backup Key!") }
}
let viewModel = BackupSettingsViewModel(
backupEnabledState: backupEnabledState,
backupPlanLoadingState: .loading,
lastBackupDate: Date().addingTimeInterval(-1 * .day),
lastBackupSizeBytes: 2_400_000_000,
backupFrequency: .daily,
shouldBackUpOnCellular: false
)
let actionsDelegate = PreviewActionsDelegate(planLoadResult: planLoadResult)
viewModel.actionsDelegate = actionsDelegate
ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
viewModel.loadBackupPlan()
return viewModel
}
}
#Preview("Paid") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .enabled,
planLoadResult: .success(.paid(
price: FiatMoney(currencyCode: "USD", value: 2.99),
renewalDate: Date().addingTimeInterval(.week)
))
))
}
#Preview("Free") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .enabled,
planLoadResult: .success(.free)
))
}
#Preview("Expiring") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .enabled,
planLoadResult: .success(.paidExpiringSoon(
expirationDate: Date().addingTimeInterval(.week)
))
))
}
#Preview("Plan Network Error") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .enabled,
planLoadResult: .failure(OWSHTTPError.networkFailure(.genericTimeout))
))
}
#Preview("Plan Generic Error") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .enabled,
planLoadResult: .failure(OWSGenericError(""))
))
}
#Preview("Disabled") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .disabled,
planLoadResult: .success(.free),
))
}
#Preview("Disabling Remotely") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .disabledLocallyStillDisablingRemotely,
planLoadResult: .success(.free),
))
}
#Preview("Disabling Remotely Error") {
BackupSettingsView(viewModel: .forPreview(
backupEnabledState: .disabledLocallyButDisableRemotelyFailed,
planLoadResult: .success(.free),
))
}
#endif