426 lines
18 KiB
Swift
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
|