Path: blob/main/Signal/Backups/ChooseBackupPlanViewController.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import StoreKit
import SwiftUI
class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
typealias OnConfirmPlanSelectionBlock = (ChooseBackupPlanViewController, PlanSelection) -> Void
enum StoreKitAvailability {
case available(paidPlanDisplayPrice: String)
case unavailableForTesters
}
enum PlanSelection {
case free
case paid
}
// MARK: -
private let backupKeyService: BackupKeyService
private let backupSettingsStore: BackupSettingsStore
private let db: DB
private let tsAccountManager: TSAccountManager
private let freeMediaTierDays: UInt64
private let initialPlanSelection: PlanSelection?
private let onConfirmPlanSelectionBlock: OnConfirmPlanSelectionBlock
private let viewModel: ChooseBackupPlanViewModel
init(
freeMediaTierDays: UInt64,
initialPlanSelection: PlanSelection?,
storeKitAvailability: StoreKitAvailability,
storageAllowanceBytes: UInt64,
backupKeyService: BackupKeyService,
backupSettingsStore: BackupSettingsStore,
db: DB,
tsAccountManager: TSAccountManager,
onConfirmPlanSelectionBlock: @escaping OnConfirmPlanSelectionBlock,
) {
self.backupKeyService = backupKeyService
self.backupSettingsStore = backupSettingsStore
self.db = db
self.tsAccountManager = tsAccountManager
self.initialPlanSelection = initialPlanSelection
self.freeMediaTierDays = freeMediaTierDays
self.onConfirmPlanSelectionBlock = onConfirmPlanSelectionBlock
self.viewModel = ChooseBackupPlanViewModel(
initialPlanSelection: initialPlanSelection,
freeMediaTierDays: freeMediaTierDays,
storageAllowanceBytes: storageAllowanceBytes,
storeKitAvailability: storeKitAvailability,
)
super.init(wrappedView: ChooseBackupPlanView(viewModel: viewModel))
viewModel.actionsDelegate = self
}
static func load(
fromViewController: UIViewController,
initialPlanSelection: PlanSelection?,
onConfirmPlanSelectionBlock: @escaping OnConfirmPlanSelectionBlock,
) async throws(SheetDisplayableError) -> ChooseBackupPlanViewController {
let backupSubscriptionManager = DependenciesBridge.shared.backupSubscriptionManager
let subscriptionConfigManager = DependenciesBridge.shared.subscriptionConfigManager
let (
storeKitAvailability,
backupSubscriptionConfiguration,
) = try await ModalActivityIndicatorViewController.presentAndPropagateResult(
from: fromViewController,
) { () throws(SheetDisplayableError) in
let storeKitAvailability: StoreKitAvailability
if BuildFlags.Backups.avoidStoreKitForTesters {
storeKitAvailability = .unavailableForTesters
} else {
do {
storeKitAvailability = .available(
paidPlanDisplayPrice: try await backupSubscriptionManager.subscriptionDisplayPrice(),
)
} catch StoreKitError.networkError {
throw .networkError
} catch {
owsFailDebug("Failed to get paidPlanDisplayPrice!")
throw .genericError
}
}
let backupSubscriptionConfig: BackupSubscriptionConfiguration
do {
backupSubscriptionConfig = try await subscriptionConfigManager.backupConfiguration()
} catch where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse {
throw .networkError
} catch {
throw .genericError
}
return (storeKitAvailability, backupSubscriptionConfig)
}
return ChooseBackupPlanViewController(
freeMediaTierDays: backupSubscriptionConfiguration.freeTierMediaDays,
initialPlanSelection: initialPlanSelection,
storeKitAvailability: storeKitAvailability,
storageAllowanceBytes: backupSubscriptionConfiguration.storageAllowanceBytes,
backupKeyService: DependenciesBridge.shared.backupKeyService,
backupSettingsStore: BackupSettingsStore(),
db: DependenciesBridge.shared.db,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
onConfirmPlanSelectionBlock: onConfirmPlanSelectionBlock,
)
}
}
// MARK: - ChooseBackupPlanViewModel.ActionsDelegate
extension ChooseBackupPlanViewController: ChooseBackupPlanViewModel.ActionsDelegate {
fileprivate func confirmSelection(_ planSelection: PlanSelection) {
switch (initialPlanSelection, planSelection) {
case (.free, .free), (.paid, .paid):
owsFail("Unexpectedly confirmed selection of initial plan! This should've been disallowed.")
case (nil, _), (.free, .paid):
onConfirmPlanSelectionBlock(self, planSelection)
case (.paid, .free):
OWSActionSheets.showConfirmationAlert(
title: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_DOWNGRADE_CONFIRMATION_ACTION_SHEET_TITLE",
comment: "Title for an action sheet confirming the user wants to downgrade their Backup plan.",
),
message: String.localizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_DOWNGRADE_CONFIRMATION_ACTION_SHEET_MESSAGE_%d",
tableName: "PluralAware",
comment: "Message for an action sheet confirming the user wants to downgrade their Backup plan. Embeds {{ the number of days that files are available, e.g. '45' }}.",
),
freeMediaTierDays,
),
proceedTitle: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_DOWNGRADE_CONFIRMATION_ACTION_SHEET_PROCEED_BUTTON",
comment: "Button for an action sheet confirming the user wants to downgrade their Backup plan.",
),
proceedAction: { [self] _ in
onConfirmPlanSelectionBlock(self, planSelection)
},
)
}
}
}
// MARK: -
private class ChooseBackupPlanViewModel: ObservableObject {
typealias StoreKitAvailability = ChooseBackupPlanViewController.StoreKitAvailability
typealias PlanSelection = ChooseBackupPlanViewController.PlanSelection
protocol ActionsDelegate: AnyObject {
func confirmSelection(_ planSelection: PlanSelection)
}
@Published var planSelection: PlanSelection
let initialPlanSelection: PlanSelection?
let freeMediaTierDays: UInt64
let storageAllowanceBytes: UInt64
let storeKitAvailability: StoreKitAvailability
weak var actionsDelegate: ActionsDelegate?
init(
initialPlanSelection: PlanSelection?,
freeMediaTierDays: UInt64,
storageAllowanceBytes: UInt64,
storeKitAvailability: StoreKitAvailability,
) {
self.planSelection = initialPlanSelection ?? .free
self.initialPlanSelection = initialPlanSelection
self.freeMediaTierDays = freeMediaTierDays
self.storageAllowanceBytes = storageAllowanceBytes
self.storeKitAvailability = storeKitAvailability
}
// MARK: Actions
func confirmSelection() {
actionsDelegate?.confirmSelection(planSelection)
}
}
struct ChooseBackupPlanView: View {
@ObservedObject private var viewModel: ChooseBackupPlanViewModel
fileprivate init(viewModel: ChooseBackupPlanViewModel) {
self.viewModel = viewModel
}
var body: some View {
ScrollableContentPinnedFooterView {
VStack {
Image("backups-choose-plan")
.frame(width: 80, height: 80)
Spacer().frame(height: 8)
Text(OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_TITLE",
comment: "Title for a view allowing users to choose a Backup plan.",
))
.font(.title.weight(.semibold))
.foregroundStyle(Color.Signal.label)
Spacer().frame(height: 12)
Text(OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_SUBTITLE",
comment: "Subtitle for a view allowing users to choose a Backup plan.",
))
.appendLink(CommonStrings.learnMore) {
CurrentAppContext().open(
URL.Support.backups,
completion: nil,
)
}
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer().frame(height: 20)
PlanOptionView(
title: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_FREE_PLAN_TITLE",
comment: "Title for the free plan option, when choosing a Backup plan.",
),
subtitle: String.localizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_FREE_PLAN_SUBTITLE_%d",
tableName: "PluralAware",
comment: "Subtitle for the free plan option, when choosing a Backup plan. Embeds {{ the number of days that files are available, e.g. '45' }}.",
),
viewModel.freeMediaTierDays,
),
bullets: [
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included.",
)),
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: String.localizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_RECENT_MEDIA_BACKUP_%d",
tableName: "PluralAware",
comment: "Text for a bullet point in a list of Backup features, describing that recent media is included. Embeds {{ the number of days that files are available, e.g. '45' }}.",
),
viewModel.freeMediaTierDays,
)),
],
isCurrentPlan: viewModel.initialPlanSelection == .free,
isSelected: viewModel.planSelection == .free,
onTap: {
viewModel.planSelection = .free
},
)
Spacer().frame(height: 16)
PlanOptionView(
title: {
switch viewModel.storeKitAvailability {
case .available(let paidPlanDisplayPrice):
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_PAID_PLAN_TITLE",
comment: "Title for the paid plan option, when choosing a Backup plan. Embeds {{ the formatted monthly cost, as currency, of the paid plan }}.",
),
paidPlanDisplayPrice,
)
case .unavailableForTesters:
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_PAID_PLAN_NO_PURCHASES_TITLE",
comment: "Title for the paid plan option, when choosing a Backup plan as a tester.",
)
}
}(),
subtitle: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_PAID_PLAN_SUBTITLE",
comment: "Subtitle for the paid plan option, when choosing a Backup plan.",
),
bullets: [
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included.",
)),
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_MEDIA_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all media is included.",
)),
PlanOptionView.BulletPoint(iconKey: "data", text: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_STORAGE_AMOUNT",
comment: "Text for a bullet point in a list of Backup features, describing the amount of included storage. Embeds {{ the amount of storage preformatted as a localized byte count, e.g. '100 GB' }}.",
),
viewModel.storageAllowanceBytes.formatted(.owsByteCount(
fudgeBase2ToBase10: true,
zeroPadFractionDigits: false,
)),
)),
],
isCurrentPlan: viewModel.initialPlanSelection == .paid,
isSelected: viewModel.planSelection == .paid,
onTap: {
viewModel.planSelection = .paid
},
)
}
.padding(.horizontal, 16)
termsAndConditionsLink()
.padding(.vertical, 16)
} pinnedFooter: {
Button {
viewModel.confirmSelection()
} label: {
let text = switch viewModel.planSelection {
case .free:
switch viewModel.initialPlanSelection {
case nil, .free:
CommonStrings.continueButton
case .paid:
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_DOWNGRADE_BUTTON_TEXT",
comment: "Text for a button that will downgrade the user from the paid Backup plan to the free one.",
)
}
case .paid:
switch viewModel.storeKitAvailability {
case .available(let paidPlanDisplayPrice):
String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_SUBSCRIBE_PAID_BUTTON_TEXT",
comment: "Text for a button that will subscribe the user to the paid Backup plan. Embeds {{ the formatted monthly cost, as currency, of the paid plan }}.",
),
paidPlanDisplayPrice,
)
case .unavailableForTesters:
CommonStrings.continueButton
}
}
Text(text)
}
.disabled(viewModel.planSelection == viewModel.initialPlanSelection)
.buttonStyle(Registration.UI.LargePrimaryButtonStyle())
.padding(.horizontal, 24)
}
.padding(.horizontal)
.multilineTextAlignment(.center)
.background(Color.Signal.groupedBackground)
}
private func termsAndConditionsLink() -> some View {
let label = OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_TERM_AND_PRIVACY_POLICY_TEXT",
comment: "Title for a label allowing users to view Signal's Terms & Conditions.",
)
return Text(" [\(label)](https://support.signal.org/)")
.font(.subheadline.weight(.bold))
.environment(\.openURL, OpenURLAction { _ in
CurrentAppContext().open(
TSConstants.legalTermsUrl,
completion: nil,
)
return .handled
})
.foregroundStyle(Color.Signal.secondaryLabel)
.tint(Color.Signal.secondaryLabel)
}
}
// MARK: -
private struct PlanOptionView: View {
struct BulletPoint {
let iconKey: String
let text: String
}
let title: String
let subtitle: String
let bullets: [BulletPoint]
let isCurrentPlan: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(alignment: .top) {
VStack(alignment: .leading) {
if isCurrentPlan {
Label(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_CURRENT_PLAN_LABEL",
comment: "A label indicating that a given Backup plan option is what the user has already enabled.",
),
systemImage: "checkmark",
)
.font(.footnote)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(subtitle).foregroundStyle(Color.Signal.secondaryLabel)
ForEach(bullets, id: \.iconKey) { bullet in
Label {
Text(bullet.text).font(.subheadline)
} icon: {
Image(bullet.iconKey)
.foregroundStyle(
isSelected
? Color.Signal.ultramarine
: Color.Signal.label,
)
}
.padding(.leading, 20)
.padding(.vertical, 2)
}
}
Spacer()
Group {
if isSelected {
Circle()
.fill(Color.Signal.ultramarine)
.overlay {
Image(systemName: "checkmark")
.resizable()
.foregroundColor(.white)
.padding(6)
}
} else {
Circle()
.stroke(Color.Signal.secondaryLabel, lineWidth: 2)
.opacity(0.3)
}
}
.frame(width: 24, height: 24)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 20)
.padding(.leading, 20)
.padding(.trailing, 16)
.background(Color.Signal.secondaryGroupedBackground)
.cornerRadius(16)
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(
Color.Signal.ultramarine,
lineWidth: isSelected ? 3 : 0,
)
}
.shadow(
color: isSelected ? .black.opacity(0.12) : .clear,
radius: 8,
y: 2,
)
}
.buttonStyle(.plain)
}
}
// MARK: -
#if DEBUG
private extension ChooseBackupPlanViewModel {
static func forPreview(
storeKitAvailability: StoreKitAvailability,
) -> ChooseBackupPlanViewModel {
class ChoosePlanActionsDelegate: ChooseBackupPlanViewModel.ActionsDelegate {
func confirmSelection(_ planSelection: ChooseBackupPlanViewModel.PlanSelection) {
print("Confirming \(planSelection)")
}
}
let viewModel = ChooseBackupPlanViewModel(
initialPlanSelection: .free,
freeMediaTierDays: 45,
storageAllowanceBytes: 100_000_000_000,
storeKitAvailability: storeKitAvailability,
)
let actionsDelegate = ChoosePlanActionsDelegate()
ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
viewModel.actionsDelegate = actionsDelegate
return viewModel
}
}
#Preview("Purchases") {
ChooseBackupPlanView(viewModel: .forPreview(
storeKitAvailability: .available(paidPlanDisplayPrice: "$1.99"),
))
}
#Preview("No Purchases") {
ChooseBackupPlanView(viewModel: .forPreview(
storeKitAvailability: .unavailableForTesters,
))
}
#endif