Path: blob/main/SignalServiceKit/Subscriptions/Backups/BackupSubscriptionManager.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import LibSignalClient
import StoreKit
/// Responsible for In-App Purchases (IAP) that grant access to paid-tier Backups.
///
/// - Note
/// Backup payments are done via IAP using Apple as the payment processor, and
/// consequently payments management is done via Apple ID management in the iOS
/// Settings app rather than in-app UI.
///
/// - Note
/// An IAP subscription may only be started on a primary. However, that primary
/// may or may not be the same device as our current primary; that primary may
/// or may not even be an iOS device if the user migrated from Android to iOS.
///
/// - Important
/// Not to be confused with ``DonationSubscriptionManager``, which does many
/// similar things but designed around donations and profile badges.
public protocol BackupSubscriptionManager {
typealias PurchaseResult = BackupSubscription.PurchaseResult
typealias IAPSubscriberData = BackupSubscription.IAPSubscriberData
// MARK: Fetch remote state
/// Fetch the user's Backups subscription, if it exists. May downgrade the
/// local `BackupPlan`, depending on the remote state of the subscription.
func fetchAndMaybeDowngradeSubscription() async throws -> Subscription?
// MARK: IAPSubscriberData
/// Get the user's current IAP subscriber data, if present.
func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData?
/// Persist the given IAP subscriber data.
///
/// - Important
/// Generally, this type generates and manages the `iapSubscriberData`
/// internally. The exception is "restoring" `iapSubscriberData` preserved
/// in external storage and considered authoritative, such as one in Storage
/// Service or a Backup.
func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction)
// MARK: Purchasing
/// Returns the price for a Backups subscription, formatted for display.
func subscriptionDisplayPrice() async throws -> String
/// Attempts to purchase a Backups subscription for the first time, via
/// StoreKit IAP.
///
/// - Important
/// If this method returns successfully, callers must subsequently call
/// ``redeemSubscriptionIfNecessary()`` to redeem the newly-purchased IAP
/// subscription.
///
/// - Note
/// While this should be called only for users who do not currently have a
/// Backups subscription, StoreKit handles already-subscribed users
/// gracefully by showing explanatory UI.
func purchaseNewSubscription() async throws -> PurchaseResult
// MARK: Redeeming
/// Record, in response to an external state change, that we should attempt
/// to redeem our Backups subscription.
func setRedemptionAttemptIsNecessary(tx: DBWriteTransaction)
/// Redeems a StoreKit Backups subscription with Signal servers for access
/// to paid-tier Backup credentials, if there exists a StoreKit transaction
/// we have not yet redeemed.
///
/// - Note
/// This method serializes callers, is safe to call repeatedly, and returns
/// quickly if there is not a transaction we have yet to redeem.
func redeemSubscriptionIfNecessary() async throws
}
// MARK: -
public enum BackupSubscription {
/// Bundles data associated with a user's IAP subscription.
public struct IAPSubscriberData {
/// An identifier generated by an IAP provider identifying the user's
/// subscription in the IAP system.
public enum IAPSubscriptionId {
/// An `originalTransactionId` from an iOS StoreKit `Transaction`.
case originalTransactionId(UInt64)
/// A `purchaseToken` identifying an Android Play Store subscription.
case purchaseToken(String)
}
/// A client-generated ID identifying this subscriber to Signal's
/// services. Like a `donationSubscriberId` (see: `DonationSubscriptionManager`),
/// this value is not associated with a user's account.
///
/// - Note
/// This value may have been generated by this client, or may have been
/// generated by a former primary device for this account and later
/// restored onto this device (e.g., via Storage Service or a backup).
public let subscriberId: Data
/// See doc on `IAPSubscriptionId`.
public let iapSubscriptionId: IAPSubscriptionId
fileprivate func matches(storeKitTransaction: Transaction) -> Bool {
switch iapSubscriptionId {
case .originalTransactionId(let originalTransactionId):
return storeKitTransaction.originalID == originalTransactionId
case .purchaseToken:
return false
}
}
}
/// Describes the result of initiating a StoreKit purchase.
public enum PurchaseResult {
/// Purchase was successful. Contains the result of the purchase's
/// redemption with Signal servers.
///
/// - Note
/// Success also covers if the user attempted to purchase this
/// subscription, but was already subscribed.
case success
/// Purchase is pending external action, such as approval when "Ask to
/// Buy" is enabled.
case pending
/// The user cancelled the purchase.
case userCancelled
}
}
// MARK: -
final class BackupSubscriptionManagerImpl: BackupSubscriptionManager {
private enum Constants {
/// This value corresponds to our IAP config set up in App Store
/// Connect, and must not change!
static let paidTierBackupsProductId = "backups.mediatier"
}
private let logger = PrefixedLogger(prefix: "[Backups][Sub]")
private let appContext: AppContext
private let backupPlanManager: BackupPlanManager
private let backupSubscriptionIssueStore: BackupSubscriptionIssueStore
private let backupSubscriptionRedeemer: BackupSubscriptionRedeemer
private let dateProvider: DateProvider
private let db: any DB
private let networkManager: NetworkManager
private let storageServiceManager: StorageServiceManager
private let store: Store
private let tsAccountManager: TSAccountManager
private let whoAmIManager: WhoAmIManager
init(
appContext: AppContext,
backupPlanManager: BackupPlanManager,
backupSubscriptionIssueStore: BackupSubscriptionIssueStore,
backupSubscriptionRedeemer: BackupSubscriptionRedeemer,
dateProvider: @escaping DateProvider,
db: any DB,
networkManager: NetworkManager,
storageServiceManager: StorageServiceManager,
tsAccountManager: TSAccountManager,
whoAmIManager: WhoAmIManager,
) {
self.appContext = appContext
self.backupPlanManager = backupPlanManager
self.backupSubscriptionIssueStore = backupSubscriptionIssueStore
self.backupSubscriptionRedeemer = backupSubscriptionRedeemer
self.dateProvider = dateProvider
self.db = db
self.networkManager = networkManager
self.storageServiceManager = storageServiceManager
self.store = Store()
self.tsAccountManager = tsAccountManager
self.whoAmIManager = whoAmIManager
Task { await doStartupLogging() }
listenForTransactionUpdates()
}
private func doStartupLogging() async {
let latestTransaction = await self.latestTransaction(onlyEntitling: false)
let latestEntitlingTransaction = await self.latestTransaction(onlyEntitling: true)
let localIAPSubscriberData: IAPSubscriberData?
let localBackupPlan: BackupPlan
(
localIAPSubscriberData,
localBackupPlan,
) = db.read {
(
store.getIAPSubscriberData(tx: $0),
backupPlanManager.backupPlan(tx: $0),
)
}
let logger = logger.suffixed(with: "BackupPlan: \(localBackupPlan)")
if let latestEntitlingTransaction {
if let localIAPSubscriberData, localIAPSubscriberData.matches(storeKitTransaction: latestEntitlingTransaction) {
logger.info("Active StoreKit, matches local IAPSubscriberData.")
} else {
logger.info("Active StoreKit, does not match local IAPSubscriberData.")
}
} else if let latestTransaction {
if let localIAPSubscriberData, localIAPSubscriberData.matches(storeKitTransaction: latestTransaction) {
logger.info("Inactive StoreKit, matches local IAPSubscriberData.")
} else {
logger.info("Inactive StoreKit, does not match local IAPSubscriberData.")
}
} else if localIAPSubscriberData != nil {
logger.info("No StoreKit, but local IAPSubscriberData.")
} else {
logger.info("No StoreKit or local IAPSubscriberData.")
}
}
// MARK: -
/// This should never throw, nor be missing.
private func getPaidTierProduct() async throws -> Product {
struct MissingProductError: Error {}
do {
guard
let product = try await Product.products(
for: [Constants.paidTierBackupsProductId],
).first
else {
throw MissingProductError()
}
return product
} catch is MissingProductError {
throw OWSAssertionError(
"Paid-tier product missing from StoreKit!",
logger: logger,
)
} catch {
throw OWSAssertionError(
"Failed to get paid-tier product from StoreKit! \(error)",
logger: logger,
)
}
}
/// Returns the latest `Transaction` for the the StoreKit "paid tier"
/// subscription, or `nil` if this IAP account has never subscribed.
///
/// - Parameter onlyEntitling
/// If `true`, returns the latest `Transaction` if it currently entitles us
/// to the subscription.
private func latestTransaction(onlyEntitling: Bool) async -> Transaction? {
let transactionResult: VerificationResult<Transaction>? = if onlyEntitling {
await Transaction.currentEntitlement(for: Constants.paidTierBackupsProductId)
} else {
await Transaction.latest(for: Constants.paidTierBackupsProductId)
}
guard let transactionResult else {
return nil
}
guard let transaction = try? transactionResult.payloadValue else {
owsFailDebug(
"Transaction was unverified! onlyEntitling: \(onlyEntitling)",
logger: logger,
)
return nil
}
return transaction
}
/// `Transaction.updates` is how the app is informed by StoreKit about
/// transactions other than ones we completed inline via `.purchase()`. This
/// covers scenarios like renewals and "Ask to Buy" where a transaction may
/// occur asynchronously; we'll learn about those transactions here.
///
/// If we learn about a transaction here that entitles us to a subscription
/// we'll attempt a redemption. We don't need to be more precise than that,
/// since we already regularly check if we need to perform a redemption and
/// track relevant state on our own.
private func listenForTransactionUpdates() {
Task.detached { [weak self] in
for await transactionResult in Transaction.updates {
/// Guard on `self` in here, since we're in an async stream.
guard let self else { return }
guard let transaction = try? transactionResult.payloadValue else {
owsFailDebug(
"Transaction from update was unverified!",
logger: logger,
)
continue
}
if
let latestEntitlingTransaction = await latestTransaction(onlyEntitling: true),
latestEntitlingTransaction.id == transaction.id
{
logger.info("Transaction update is for latest entitling transaction; attempting subscription redemption.")
do {
/// This transaction entitles us to a subscription, so
/// let's attempt to do so. Because we know we have a
/// novel transaction, we know redemption is necessary.
await db.awaitableWrite { tx in
self.setRedemptionAttemptIsNecessary(tx: tx)
}
try await redeemSubscriptionIfNecessary()
} catch {
owsFailDebug(
"Failed to redeem subscription: \(error)",
logger: logger,
)
}
} else {
logger.info("Transaction update is not for latest entitling subscription.")
}
/// All transactions should be finished eventually, so let's
/// make sure we do so.
await transaction.finish()
}
}
}
// MARK: -
func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? {
store.getIAPSubscriberData(tx: tx)
}
func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) {
store.setIAPSubscriberData(iapSubscriberData, tx: tx)
}
// MARK: -
func fetchAndMaybeDowngradeSubscription() async throws -> Subscription? {
guard let subscriberID = db.read(block: { store.getIAPSubscriberData(tx: $0)?.subscriberId }) else {
return nil
}
return try await _fetchAndMaybeDowngradeSubscription(
subscriberID: subscriberID,
subscriptionFetcher: SubscriptionFetcher(networkManager: networkManager),
)
}
private func _fetchAndMaybeDowngradeSubscription(
subscriberID: Data,
subscriptionFetcher: SubscriptionFetcher,
) async throws -> Subscription? {
let subscription = try await subscriptionFetcher.fetch(subscriberID: subscriberID)
let backupEntitlement = try await whoAmIManager.makeWhoAmIRequest().entitlements.backup
await db.awaitableWrite { tx in
warnSubscriptionFailedToRenewIfNecessary(
fetchedSubscription: subscription,
tx: tx,
)
downgradeBackupPlanIfNecessary(
fetchedSubscription: subscription,
backupEntitlement: backupEntitlement,
tx: tx,
)
}
return subscription
}
/// Warn the user if their subscription has failed to renew.
private func warnSubscriptionFailedToRenewIfNecessary(
fetchedSubscription subscription: Subscription?,
tx: DBWriteTransaction,
) {
guard let subscription else { return }
switch subscription.status {
case .active, .canceled:
break
case .unrecognized:
owsFailDebug("Unexpected subscription status for IAP subscription! \(subscription.status)")
case .pastDue:
// The .pastDue status is returned if we're in the IAP "billing
// retry", period, which indicates something has gone wrong with a
// subscription renewal.
backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionFailedToRenew(
endOfCurrentPeriod: subscription.endOfCurrentPeriod,
tx: tx,
)
}
}
/// While we store locally a `BackupPlan`, the ultimate source of truth as
/// to the state our our Backup subscription/plan is remote. Any time we
/// fetch that remote state could be the moment we learn that something
/// has changed, such that we should "downgrade" our local `BackupPlan`.
///
/// For example, something may have changed with our subscription, or our
/// Backup entitlement may have expired.
///
/// - Note
/// Upgrading requires redeeming a subscription that has renewed, which can
/// only happen while the app is running (rather than externally while the
/// app wasn't running). Consequently, the redemption code sets `BackupPlan`
/// for the upgrade case.
private func downgradeBackupPlanIfNecessary(
fetchedSubscription subscription: Subscription?,
backupEntitlement: WhoAmIRequestFactory.Responses.WhoAmI.Entitlements.BackupEntitlement?,
tx: DBWriteTransaction,
) {
let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
enum Downgrade {
case toFreeTier
case toPaidExpiringSoon(optimizeLocalStorage: Bool)
}
let downgrade: Downgrade? = {
/// The value of optimizeLocalStorage, if the current BackupPlan
/// is .paid. nil otherwise.
let paidTierOptimizeLocalStorage: Bool?
switch currentBackupPlan {
case .paidAsTester:
// Handled by `BackupTestFlightEntitlementManager`.
return nil
case .disabled, .disabling, .free:
// Nothing to downgrade.
return nil
case .paid(let optimizeLocalStorage):
paidTierOptimizeLocalStorage = optimizeLocalStorage
case .paidExpiringSoon:
paidTierOptimizeLocalStorage = nil
}
guard
let backupEntitlement,
Date(timeIntervalSince1970: backupEntitlement.expirationSeconds) > dateProvider()
else {
// Our entitlement has expired, so we must downgrade to the
// free tier. (Paid-tier operations will no longer work!)
//
// This likely means the subscription failed to renew, and
// the "grace period" during which the entitlement persists
// after the subscription period ends has now elapsed
// without the user fixing the renewal issue.
logger.warn("Backup entitlement missing or expired: downgrading to free tier.")
return .toFreeTier
}
let subscriptionCancelAtEndOfPeriod: Bool
switch subscription?.status {
case nil, .canceled:
// This means the subscription is "expired", which happens in
// two ways:
// - The user manually canceled, and their last-subscribed
// period has now elapsed.
// - A renewal failed, and Apple's given up trying to get
// the user to resolve the issue. (Note that Apple will
// try for 60d, so our Backup entitlement will have
// generally have expired before this happens.)
//
// "Expiration" is the trigger for Chat Service to wipe its
// knowledge of the subscriber ID, hence we can infer that a
// missing subscription expired. If that wiping hasn't happened
// yet, Chat Service will return the `.canceled` status.
//
// If the subscription has expired, downgrade to free.
logger.warn("IAP subscription missing or expired: downgrading to free tier.")
return .toFreeTier
case .active, .pastDue, .unrecognized:
subscriptionCancelAtEndOfPeriod = subscription!.cancelAtEndOfPeriod
}
// At this point we have a non-expired subscription, and we have a
// Backup entitlement, so things are generally good.
if
let paidTierOptimizeLocalStorage,
subscriptionCancelAtEndOfPeriod
{
// We're on the paid tier, but our subscription won't renew.
logger.warn("IAP subscription not renewing: downgrading to expiring soon.")
return .toPaidExpiringSoon(optimizeLocalStorage: paidTierOptimizeLocalStorage)
}
return nil
}()
if let downgrade {
let downgradedBackupPlan: BackupPlan = switch downgrade {
case .toFreeTier: .free
case .toPaidExpiringSoon(let optimizeLocalStorage): .paidExpiringSoon(optimizeLocalStorage: optimizeLocalStorage)
}
backupPlanManager.setBackupPlan(downgradedBackupPlan, tx: tx)
switch downgrade {
case .toFreeTier:
// Subscription issues no longer relevant!
backupSubscriptionIssueStore.setStopWarningIAPSubscriptionAlreadyRedeemed(tx: tx)
backupSubscriptionIssueStore.setStopWarningIAPSubscriptionNotFoundLocally(tx: tx)
// Warn that it expired, though.
backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionExpired(true, tx: tx)
case .toPaidExpiringSoon:
break
}
}
}
// MARK: - Purchase new subscription
func subscriptionDisplayPrice() async throws -> String {
owsPrecondition(!BuildFlags.Backups.avoidStoreKitForTesters)
return try await getPaidTierProduct().displayPrice
}
func purchaseNewSubscription() async throws -> PurchaseResult {
owsPrecondition(!BuildFlags.Backups.avoidStoreKitForTesters)
switch try await getPaidTierProduct().purchase() {
case .success(let purchaseResult):
switch purchaseResult {
case .verified:
// We've successfully purchased, which means a redemption
// attempt is necessary.
await db.awaitableWrite { tx in
setRedemptionAttemptIsNecessary(tx: tx)
}
return .success
case .unverified:
throw OWSAssertionError(
"Unverified successful purchase result!",
logger: logger,
)
}
case .userCancelled:
logger.info("User cancelled subscription purchase.")
return .userCancelled
case .pending:
logger.warn("Subscription purchase is pending; expect redemption if it is approved.")
return .pending
@unknown default:
throw OWSAssertionError(
"Unknown purchase result!",
logger: logger,
)
}
}
// MARK: -
/// We generally only attempt redemptions 1x/3d, but on occasion we know
/// that a redemption is necessary and we should bypass that debounce.
func setRedemptionAttemptIsNecessary(tx: DBWriteTransaction) {
store.wipeLastRedemptionNecessaryCheck(tx: tx)
}
// MARK: - Redeem subscription
/// - Note
/// `_redeemSubscriptionIfNecessary()` uses persisted state, so latter
/// callers may be able to short-circuit based on state persisted by an
/// earlier caller.
private let redemptionTaskQueue = ConcurrentTaskQueue(concurrentLimit: 1)
func redeemSubscriptionIfNecessary() async throws {
return try await redemptionTaskQueue.run {
try await self._redeemSubscriptionIfNecessary()
}
}
private func _redeemSubscriptionIfNecessary() async throws {
guard appContext.isMainApp else {
throw OWSAssertionError("Shouldn't be redeeming subscripions outside the main app process!")
}
if
let preexistingRedemptionContext = db.read(block: {
return BackupSubscriptionRedemptionContext.fetch(tx: $0)
})
{
// We have a persisted redemption context, which means a previous
// redemption was interrupted. Finish it, then try again.
//
// It's very likely that once we've finished the interrupted one
// the recursive call will no-op.
try await backupSubscriptionRedeemer.redeem(context: preexistingRedemptionContext)
try await _redeemSubscriptionIfNecessary()
}
/// Wait on any in-progress restores, since there's a chance we're
/// restoring subscriber data.
do {
try await storageServiceManager.waitForPendingRestores()
} catch let error as CancellationError {
throw error
} catch {
// ignore other errors; we want to proceed if we couldn't restore
}
let (
isRegisteredPrimaryDevice,
persistedIAPSubscriberData,
) = db.read { tx in
return (
tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
store.getIAPSubscriberData(tx: tx),
)
}
guard isRegisteredPrimaryDevice else {
return
}
let localEntitlingTransaction = await latestTransaction(onlyEntitling: true)
let localIAPSubscriberData: IAPSubscriberData
var registerNewSubscriberIdIfSubscriptionMissing = false
if
let localEntitlingTransaction,
let persistedIAPSubscriberData
{
if persistedIAPSubscriberData.matches(storeKitTransaction: localEntitlingTransaction) {
localIAPSubscriberData = persistedIAPSubscriberData
/// We have an active local subscription that matches our persisted
/// identifiers. Happy path!
///
/// However, we may need to register a new subscriber ID.
///
/// If you start a subscription with StoreKit, cancel it (and
/// let it expire), then resubscribe, StoreKit uses the same
/// `originalTransactionId` for the previous and current
/// iterations of the subscription.
///
/// That's an issue because Signal's servers wipe the
/// `subscriberID -> originalTransactionId` eventually for
/// expired StoreKit subscriptions, thereby rendering that
/// `subscriberID` useless; we'll fail to find a `Subscription`
/// for that `subscriberID` even though our subscription is
/// active again.
///
/// So, if we later find the `Subscription` is missing for this
/// `subscriberId`, register a new one.
registerNewSubscriberIdIfSubscriptionMissing = true
} else {
/// We have an active local subscription, but it doesn't match
/// our persisted identifers. That must mean we initiated a
/// subscription on another device (either with a different App
/// Store account, or even on an Android) and restored it here,
/// and also have subscribed with our local App Store account.
///
/// As a rule we prefer to rely on the local subscription, so
/// we'll "claim" it by generating and registering identifiers
/// for the local subscription!
localIAPSubscriberData = try await registerNewSubscriberId(
originalTransactionId: localEntitlingTransaction.originalID,
)
}
} else if let localEntitlingTransaction {
/// We have a local subscription, but don't yet have any persisted
/// identifiers. (This might be the first time we're subscribing!)
/// Generate and register them now!
localIAPSubscriberData = try await registerNewSubscriberId(
originalTransactionId: localEntitlingTransaction.originalID,
)
} else if let persistedIAPSubscriberData {
/// We don't have an active subscription locally, but we do have
/// identifiers for one. Those identifiers may be for a subscription
/// started by the current IAP account but since expired, or they
/// may be for a subscription started by another IAP account (e.g.,
/// a different Apple ID on this or another device, or an Android
/// from which we restored).
///
/// We'll go ahead and continue to redeem these identifiers if
/// possible, but because they don't match the local IAP account
/// we'll persist a warning below.
localIAPSubscriberData = persistedIAPSubscriberData
} else {
/// We don't have an active local subscription, nor do we have
/// subscription IDs for some other subscription. Nothing to do!
return
}
await reconcileIAPNotFoundLocallyWarnings(localIAPSubscriberData: localIAPSubscriberData)
let subscriptionRedemptionNecessaryChecker = SubscriptionRedemptionNecessityChecker<
BackupSubscriptionRedemptionContext,
>(
checkerStore: store,
dateProvider: dateProvider,
db: db,
logger: logger,
networkManager: networkManager,
tsAccountManager: tsAccountManager,
)
try await subscriptionRedemptionNecessaryChecker.redeemSubscriptionIfNecessary(
fetchSubscriptionBlock: { db, subscriptionFetcher -> (subscriberID: Data, subscription: Subscription)? in
if
let subscriberID = db.read(block: { store.getIAPSubscriberData(tx: $0)?.subscriberId }),
let subscription = try await _fetchAndMaybeDowngradeSubscription(
subscriberID: subscriberID,
subscriptionFetcher: subscriptionFetcher,
)
{
return (subscriberID, subscription)
}
if
let localEntitlingTransaction,
registerNewSubscriberIdIfSubscriptionMissing
{
// See comments above on registerNewSubscriberIdIfSubscriptionMissing.
logger.info("Registering new subscriber ID for active local IAP, remote subscription was missing!")
let newSubscriberId = try await registerNewSubscriberId(
originalTransactionId: localEntitlingTransaction.originalID,
).subscriberId
if
let subscription = try await _fetchAndMaybeDowngradeSubscription(
subscriberID: newSubscriberId,
subscriptionFetcher: subscriptionFetcher,
)
{
return (newSubscriberId, subscription)
} else {
owsFailDebug("Subscription missing, but we just registered a new subscriber ID!")
}
}
return nil
},
parseEntitlementExpirationBlock: { accountEntitlements, _ in
return accountEntitlements.backup?.expirationSeconds
},
saveRedemptionJobBlock: { subscriberId, subscription, tx -> BackupSubscriptionRedemptionContext in
let redemptionContext = BackupSubscriptionRedemptionContext(
subscriberId: subscriberId,
subscriptionEndOfCurrentPeriod: subscription.endOfCurrentPeriod,
)
redemptionContext.upsert(tx: tx)
return redemptionContext
},
startRedemptionJobBlock: { redemptionContext async throws in
// Note that this step, if successful, will set BackupPlan.
try await backupSubscriptionRedeemer.redeem(context: redemptionContext)
},
)
}
/// Generate a new subscriber ID, and register it with the server to be
/// associated with the given StoreKit "original transaction ID" for a
/// subscription. Persists and returns the new subscriber ID.
private func registerNewSubscriberId(
originalTransactionId: UInt64,
) async throws -> IAPSubscriberData {
logger.info("Generating and registering new Backups subscriber ID!")
let newSubscriberId: Data = Randomness.generateRandomBytes(32)
/// First, we tell the server (unauthenticated) that a new subscriber ID
/// exists. At this point, it won't be associated with anything.
let registerSubscriberIdResponse = try await networkManager.asyncRequest(
.registerSubscriberId(subscriberId: newSubscriberId),
)
guard registerSubscriberIdResponse.responseStatusCode == 200 else {
throw registerSubscriberIdResponse.asError()
}
/// Next, we tell the server (unauthenticated) to associate the
/// subscriber ID with the "original transaction ID" of an IAP.
///
/// Importantly, this request is safe to make repeatedly, with any
/// combination of `subscriberId` and `originalTransactionId`.
let associateIdsResponse = try await networkManager.asyncRequest(
.associateSubscriberId(
newSubscriberId,
withOriginalTransactionId: originalTransactionId,
),
)
guard associateIdsResponse.responseStatusCode == 200 else {
throw associateIdsResponse.asError()
}
let newSubscriberData = IAPSubscriberData(
subscriberId: newSubscriberId,
iapSubscriptionId: .originalTransactionId(originalTransactionId),
)
/// Our subscription is now set up on the service, and we should record
/// it locally!
await db.awaitableWrite { tx in
store.setIAPSubscriberData(newSubscriberData, tx: tx)
}
/// We store the subscriber data in Storage Service, so let's kick off
/// that backup now.
storageServiceManager.recordPendingLocalAccountUpdates()
return newSubscriberData
}
/// We warn the user if their `IAPSubscriberData` doesn't correspond to the
/// local-device IAP account. This method manages setting or clearing that
/// warning as appropriate.
private func reconcileIAPNotFoundLocallyWarnings(
localIAPSubscriberData: IAPSubscriberData,
) async {
let (
isWarningIAPNotFoundLocally,
backupPlan,
): (Bool, BackupPlan) = db.read { tx in
return (
backupSubscriptionIssueStore.shouldShowIAPSubscriptionNotFoundLocallyWarning(tx: tx),
backupPlanManager.backupPlan(tx: tx),
)
}
if
let latestTransaction = await latestTransaction(onlyEntitling: false),
localIAPSubscriberData.matches(storeKitTransaction: latestTransaction)
{
// Our local IAPSubscriberData came from a subscription by the local
// IAP account: clear any "not found locally" warnings.
if isWarningIAPNotFoundLocally {
await db.awaitableWrite { tx in
backupSubscriptionIssueStore.setStopWarningIAPSubscriptionNotFoundLocally(tx: tx)
}
}
return
}
// Our local IAPSubscriberData doesn't match a subscription from the
// local IAP account. We may want to save a warning.
if isWarningIAPNotFoundLocally {
// Already warning!
return
}
switch backupPlan {
case .free, .paidAsTester:
// We never discard IAPSubscriberData, even when we downgrade. If
// we're on the free or TestFlight plans, we don't need to warn.
return
case .disabling, .disabled, .paid, .paidExpiringSoon:
break
}
await db.awaitableWrite { tx in
backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionNotFoundLocally(tx: tx)
}
}
// MARK: - Persistence
private struct Store: SubscriptionRedemptionNecessityCheckerStore {
private enum Keys {
/// - SeeAlso ``BackupSubscription/IAPSubscriberData/subscriberId``
static let subscriberId = "subscriberId"
/// - SeeAlso ``BackupSubscription/IAPSubscriberData/subscriptionId``
static let originalTransactionId = "originalTransactionId"
/// - SeeAlso ``BackupSubscription/IAPSubscriberData/subscriptionId``
static let purchaseToken = "purchaseToken"
/// The last time we checked if redemption is necessary.
///
/// Used by `SubscriptionRedemptionNecessityCheckerStore`.
static let lastRedemptionNecessaryCheck = "lastRedemptionNecessaryCheck"
}
private let kvStore: KeyValueStore
init() {
self.kvStore = KeyValueStore(collection: "BackupSubscriptionManager")
}
// MARK: -
func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? {
guard let subscriberId = kvStore.getData(Keys.subscriberId, transaction: tx) else {
return nil
}
if let originalTransactionId = kvStore.getUInt64(Keys.originalTransactionId, transaction: tx) {
return IAPSubscriberData(
subscriberId: subscriberId,
iapSubscriptionId: .originalTransactionId(originalTransactionId),
)
} else if let purchaseToken = kvStore.getString(Keys.purchaseToken, transaction: tx) {
return IAPSubscriberData(
subscriberId: subscriberId,
iapSubscriptionId: .purchaseToken(purchaseToken),
)
}
owsFailDebug("Had subscriber ID, but missing IAP subscription ID!")
return nil
}
func setIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) {
kvStore.setData(iapSubscriberData.subscriberId, key: Keys.subscriberId, transaction: tx)
switch iapSubscriberData.iapSubscriptionId {
case .originalTransactionId(let originalTransactionId):
kvStore.removeValue(forKey: Keys.purchaseToken, transaction: tx)
kvStore.setUInt64(originalTransactionId, key: Keys.originalTransactionId, transaction: tx)
case .purchaseToken(let purchaseToken):
kvStore.removeValue(forKey: Keys.originalTransactionId, transaction: tx)
kvStore.setString(purchaseToken, key: Keys.purchaseToken, transaction: tx)
}
}
// MARK: - SubscriptionRedemptionNecessityCheckerStore
func getLastRedemptionNecessaryCheck(tx: DBReadTransaction) -> Date? {
return kvStore.getDate(Keys.lastRedemptionNecessaryCheck, transaction: tx)
}
func setLastRedemptionNecessaryCheck(_ now: Date, tx: DBWriteTransaction) {
kvStore.setDate(now, key: Keys.lastRedemptionNecessaryCheck, transaction: tx)
}
func wipeLastRedemptionNecessaryCheck(tx: DBWriteTransaction) {
kvStore.removeValue(forKey: Keys.lastRedemptionNecessaryCheck, transaction: tx)
}
}
}
// MARK: -
private extension TSRequest {
static func registerSubscriberId(subscriberId: Data) -> TSRequest {
return OWSRequestFactory.setSubscriberID(subscriberId)
}
static func associateSubscriberId(
_ subscriberId: Data,
withOriginalTransactionId originalTransactionId: UInt64,
) -> TSRequest {
var request = TSRequest(
url: URL(string: "v1/subscription/\(subscriberId.asBase64Url)/appstore/\(originalTransactionId)")!,
method: "POST",
parameters: nil,
)
request.auth = .anonymous
request.applyRedactionStrategy(.redactURL())
return request
}
}