235 lines
9.1 KiB
Swift
235 lines
9.1 KiB
Swift
//
|
||
// Copyright 2025 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
import SignalServiceKit
|
||
import SignalUI
|
||
import StoreKit
|
||
import UIKit
|
||
|
||
final class BackupEnablingManager {
|
||
private let backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore
|
||
private let backupDisablingManager: BackupDisablingManager
|
||
private let backupKeyService: BackupKeyService
|
||
private let backupPlanManager: BackupPlanManager
|
||
private let backupSettingsStore: BackupSettingsStore
|
||
private let backupSubscriptionManager: BackupSubscriptionManager
|
||
private let backupTestFlightEntitlementManager: BackupTestFlightEntitlementManager
|
||
private let db: DB
|
||
private let tsAccountManager: TSAccountManager
|
||
private let notificationPresenter: NotificationPresenter
|
||
|
||
init(
|
||
backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore,
|
||
backupDisablingManager: BackupDisablingManager,
|
||
backupKeyService: BackupKeyService,
|
||
backupPlanManager: BackupPlanManager,
|
||
backupSubscriptionManager: BackupSubscriptionManager,
|
||
backupTestFlightEntitlementManager: BackupTestFlightEntitlementManager,
|
||
db: DB,
|
||
tsAccountManager: TSAccountManager,
|
||
notificationPresenter: NotificationPresenter
|
||
) {
|
||
self.backupAttachmentUploadEraStore = backupAttachmentUploadEraStore
|
||
self.backupDisablingManager = backupDisablingManager
|
||
self.backupKeyService = backupKeyService
|
||
self.backupPlanManager = backupPlanManager
|
||
self.backupSettingsStore = BackupSettingsStore()
|
||
self.backupSubscriptionManager = backupSubscriptionManager
|
||
self.backupTestFlightEntitlementManager = backupTestFlightEntitlementManager
|
||
self.db = db
|
||
self.tsAccountManager = tsAccountManager
|
||
self.notificationPresenter = notificationPresenter
|
||
}
|
||
|
||
@MainActor
|
||
func enableBackups(
|
||
fromViewController: UIViewController,
|
||
planSelection: ChooseBackupPlanViewController.PlanSelection,
|
||
) async throws(ActionSheetDisplayableError) {
|
||
let (
|
||
isRegisteredPrimaryDevice,
|
||
localIdentifiers,
|
||
): (
|
||
Bool,
|
||
LocalIdentifiers?
|
||
) = db.read { tx in
|
||
return (
|
||
tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
|
||
tsAccountManager.localIdentifiers(tx: tx),
|
||
)
|
||
}
|
||
|
||
owsPrecondition(
|
||
isRegisteredPrimaryDevice,
|
||
"Attempting to enable Backups on a non-primary device!"
|
||
)
|
||
|
||
guard let localIdentifiers else {
|
||
throw .custom(localizedMessage: OWSLocalizedString(
|
||
"CHOOSE_BACKUP_PLAN_CONFIRMATION_ERROR_NOT_REGISTERED",
|
||
comment: "Message shown in an action sheet when the user tries to confirm a plan selection, but is not registered."
|
||
))
|
||
}
|
||
|
||
try await ModalActivityIndicatorViewController.presentAndPropagateResult(
|
||
from: fromViewController
|
||
) { [self] () throws(ActionSheetDisplayableError) in
|
||
try await _enableBackups(
|
||
planSelection: planSelection,
|
||
localIdentifiers: localIdentifiers
|
||
)
|
||
}
|
||
|
||
scheduleEnableBackupsNotification()
|
||
}
|
||
|
||
private func _enableBackups(
|
||
planSelection: ChooseBackupPlanViewController.PlanSelection,
|
||
localIdentifiers: LocalIdentifiers,
|
||
) async throws(ActionSheetDisplayableError) {
|
||
// First, reserve a Backup ID. We'll need this regardless of which plan
|
||
// the user chose, and we want to be sure it's succeeded before we
|
||
// attempt a potential purchase. (Redeeming a Backups subscription
|
||
// without this step will fail!)
|
||
do {
|
||
// This is a no-op unless we're also actively *disabling* Backups
|
||
// remotely. If we are, we don't wanna race, so we'll wait for
|
||
// it to finish.
|
||
await self.backupDisablingManager.disableRemotelyIfNecessary()
|
||
|
||
_ = try await self.backupKeyService.registerBackupKey(
|
||
localIdentifiers: localIdentifiers,
|
||
auth: .implicit()
|
||
)
|
||
} catch where error.isNetworkFailureOrTimeout {
|
||
throw .networkError
|
||
} catch {
|
||
owsFailDebug("Unexpectedly failed to register Backup ID! \(error)")
|
||
throw .genericError
|
||
}
|
||
|
||
switch planSelection {
|
||
case .free:
|
||
try await setBackupPlan { _ in .free }
|
||
case .paid:
|
||
if BuildFlags.Backups.avoidStoreKitForTesters {
|
||
try await enablePaidPlanWithoutStoreKit()
|
||
} else {
|
||
try await enablePaidPlanWithStoreKit()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private func scheduleEnableBackupsNotification() {
|
||
let backupsEnabledTimestamp = Date()
|
||
let notificationDelay = TimeInterval.random(in: .hour...(.hour * 3))
|
||
db.write { tx in
|
||
backupSettingsStore.setLastBackupEnabledDetails(
|
||
backupsEnabledTime: backupsEnabledTimestamp,
|
||
notificationDelay: notificationDelay,
|
||
tx: tx
|
||
)
|
||
}
|
||
notificationPresenter.scheduleNotifyForBackupsEnabled(backupsTimestamp: backupsEnabledTimestamp)
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private func enablePaidPlanWithStoreKit() async throws(ActionSheetDisplayableError) {
|
||
let purchaseResult: BackupSubscription.PurchaseResult
|
||
do {
|
||
purchaseResult = try await backupSubscriptionManager.purchaseNewSubscription()
|
||
} catch StoreKitError.networkError {
|
||
throw .networkError
|
||
} catch {
|
||
owsFailDebug("StoreKit purchase unexpectedly failed: \(error)")
|
||
throw .custom(localizedMessage: OWSLocalizedString(
|
||
"CHOOSE_BACKUP_PLAN_CONFIRMATION_ERROR_PURCHASE",
|
||
comment: "Message shown in an action sheet when the user tries to confirm selecting the paid plan, but encountered an error from Apple while purchasing."
|
||
))
|
||
}
|
||
|
||
switch purchaseResult {
|
||
case .success:
|
||
do {
|
||
try await self.backupSubscriptionManager.redeemSubscriptionIfNecessary()
|
||
} catch {
|
||
owsFailDebug("Unexpectedly failed to redeem subscription! \(error)")
|
||
throw .custom(localizedMessage: OWSLocalizedString(
|
||
"CHOOSE_BACKUP_PLAN_CONFIRMATION_ERROR_PURCHASE_REDEMPTION",
|
||
comment: "Message shown in an action sheet when the user tries to confirm selecting the paid plan, but encountered an error while redeeming their completed purchase."
|
||
))
|
||
}
|
||
|
||
try await setBackupPlan { tx in
|
||
let currentOptimizeLocalStorage: Bool
|
||
switch backupPlanManager.backupPlan(tx: tx) {
|
||
case .disabled, .disabling, .free:
|
||
currentOptimizeLocalStorage = false
|
||
case
|
||
.paid(let optimizeLocalStorage),
|
||
.paidExpiringSoon(let optimizeLocalStorage),
|
||
.paidAsTester(let optimizeLocalStorage):
|
||
currentOptimizeLocalStorage = optimizeLocalStorage
|
||
}
|
||
|
||
return .paid(optimizeLocalStorage: currentOptimizeLocalStorage)
|
||
}
|
||
|
||
case .pending:
|
||
// The subscription won't be redeemed until if/when the purchase
|
||
// is approved, but if/when that happens BackupPlan will get set
|
||
// set to .paid. For the time being, we can enable Backups as
|
||
// a free-tier user!
|
||
try await setBackupPlan { _ in .free }
|
||
|
||
case .userCancelled:
|
||
// Do nothing – don't even dismiss "choose plan", to give
|
||
// the user the chance to try again. We've reserved a Backup
|
||
// ID at this point, but that's fine even if they don't end
|
||
// up enabling Backups at all.
|
||
break
|
||
|
||
}
|
||
}
|
||
|
||
private func enablePaidPlanWithoutStoreKit() async throws(ActionSheetDisplayableError) {
|
||
do {
|
||
try await backupTestFlightEntitlementManager.acquireEntitlement()
|
||
} catch where error.isNetworkFailureOrTimeout {
|
||
throw .networkError
|
||
} catch {
|
||
owsFailDebug("Unexpectedly failed to renew Backup entitlement for tester! \(error)")
|
||
throw .genericError
|
||
}
|
||
|
||
try await setBackupPlan { tx in
|
||
// Rotate the upload era, since now that we're paid we'll probably
|
||
// want to do uploads, and to that end need to do a list-media.
|
||
backupAttachmentUploadEraStore.rotateUploadEra(tx: tx)
|
||
|
||
return .paidAsTester(optimizeLocalStorage: false)
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private func setBackupPlan(
|
||
block: (DBWriteTransaction) -> BackupPlan,
|
||
) async throws(ActionSheetDisplayableError) {
|
||
do {
|
||
try await db.awaitableWriteWithRollbackIfThrows { tx in
|
||
let newBackupPlan = block(tx)
|
||
try backupPlanManager.setBackupPlan(newBackupPlan, tx: tx)
|
||
}
|
||
} catch {
|
||
owsFailDebug("Failed to set BackupPlan! \(error)")
|
||
throw .genericError
|
||
}
|
||
}
|
||
}
|