1024 lines
41 KiB
Swift
1024 lines
41 KiB
Swift
//
|
|
// 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: - StoreKit
|
|
|
|
/// 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: - IAPSubscriberData
|
|
|
|
func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? {
|
|
store.getIAPSubscriberData(tx: tx)
|
|
}
|
|
|
|
func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) {
|
|
store.setIAPSubscriberData(iapSubscriberData, tx: tx)
|
|
}
|
|
|
|
// MARK: - Subscription Fetch
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Backup Plan Downgrade
|
|
|
|
private enum BackupPlanDowngrade {
|
|
/// Describes a subscription downgrade to the free tier.
|
|
case toFreeTier
|
|
/// Describes a subscription downgrade to "paid, expiring soon". Implies
|
|
/// that the current subscription is "paid, not expiring soon".
|
|
case toPaidExpiringSoon(
|
|
optimizeLocalStorage: Bool,
|
|
endOfCurrentPeriod: Date,
|
|
)
|
|
}
|
|
|
|
/// 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)
|
|
|
|
let downgrade: BackupPlanDowngrade? = {
|
|
/// 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
|
|
let subscriptionEndOfCurrentPeriod: Date
|
|
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
|
|
subscriptionEndOfCurrentPeriod = subscription!.endOfCurrentPeriod
|
|
}
|
|
|
|
// 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,
|
|
endOfCurrentPeriod: subscriptionEndOfCurrentPeriod,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}()
|
|
|
|
if let downgrade {
|
|
downgradeBackupPlan(
|
|
downgrade: downgrade,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
|
|
private func downgradeBackupPlan(
|
|
downgrade: BackupPlanDowngrade,
|
|
tx: DBWriteTransaction,
|
|
) {
|
|
switch downgrade {
|
|
case .toFreeTier:
|
|
backupPlanManager.setBackupPlan(.free, tx: tx)
|
|
|
|
// Subscription issues no longer relevant!
|
|
backupSubscriptionIssueStore.setStopWarningIAPSubscriptionAlreadyRedeemed(tx: tx)
|
|
backupSubscriptionIssueStore.setStopWarningIAPSubscriptionNotFoundLocally(tx: tx)
|
|
|
|
// Warn that it expired, though...
|
|
backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionExpired(true, tx: tx)
|
|
// ...and stop warning that it's expiring soon.
|
|
backupSubscriptionIssueStore.setStopWarningIAPSubscriptionExpiringSoon(tx: tx)
|
|
|
|
case .toPaidExpiringSoon(let optimizeLocalStorage, let endOfCurrentPeriod):
|
|
backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionExpiringSoon(
|
|
endOfCurrentPeriod: endOfCurrentPeriod,
|
|
now: dateProvider(),
|
|
tx: tx,
|
|
)
|
|
|
|
backupPlanManager.setBackupPlan(
|
|
.paidExpiringSoon(optimizeLocalStorage: optimizeLocalStorage),
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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: - Redeem subscription
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// - 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 backupPlan: BackupPlan
|
|
let isRegisteredPrimaryDevice: Bool
|
|
let persistedIAPSubscriberData: IAPSubscriberData?
|
|
(
|
|
backupPlan,
|
|
isRegisteredPrimaryDevice,
|
|
persistedIAPSubscriberData,
|
|
) = db.read { tx in
|
|
return (
|
|
backupPlanManager.backupPlan(tx: tx),
|
|
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.
|
|
switch backupPlan {
|
|
case .disabled, .disabling, .free, .paidAsTester:
|
|
break
|
|
case .paid, .paidExpiringSoon:
|
|
// This should never happen. And, if we end up thinking we're
|
|
// paid-tier with no local IAP nothing, we should downgrade to
|
|
// free since it's likely things are not working.
|
|
logger.warn("Missing local IAP anything, but have a paid BackupPlan. Downgrading.")
|
|
await db.awaitableWrite { tx in
|
|
downgradeBackupPlan(
|
|
downgrade: .toFreeTier,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|