Signal-iOS/Signal/Backups/ChooseBackupPlanViewController.swift
2026-06-03 21:28:07 -07:00

426 lines
18 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import StoreKit
import SwiftUI
class ChooseBackupPlanViewController:
HostingController<ChooseBackupPlanView>,
ChooseBackupPlanViewModel.ActionsDelegate
{
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
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)
BackupPlanOptionView(
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: [
BackupPlanOptionView.BulletPoint(icon: .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.",
)),
BackupPlanOptionView.BulletPoint(icon: .albumTilt, 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)
BackupPlanOptionView(
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: [
BackupPlanOptionView.BulletPoint(icon: .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.",
)),
BackupPlanOptionView.BulletPoint(icon: .albumTilt, 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.",
)),
BackupPlanOptionView.BulletPoint(icon: .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: -
#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