// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import CryptoKit import StoreKit import LibSignalClient /// 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 /// 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 backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore private let backupPlanManager: BackupPlanManager 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 init( backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore, backupPlanManager: BackupPlanManager, backupSubscriptionRedeemer: BackupSubscriptionRedeemer, dateProvider: @escaping DateProvider, db: any DB, networkManager: NetworkManager, storageServiceManager: StorageServiceManager, tsAccountManager: TSAccountManager ) { self.backupAttachmentUploadEraStore = backupAttachmentUploadEraStore self.backupPlanManager = backupPlanManager self.backupSubscriptionRedeemer = backupSubscriptionRedeemer self.dateProvider = dateProvider self.db = db self.networkManager = networkManager self.storageServiceManager = storageServiceManager self.store = Store(backupAttachmentUploadEraStore: backupAttachmentUploadEraStore) self.tsAccountManager = tsAccountManager listenForTransactionUpdates() } /// 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 `Transaction` that most recently entitled us to the StoreKit /// "paid tier" subscription, or `nil` if we are not entitled to it. /// /// For example, if we originally purchased a subscription in transaction T, /// then renewed it twice in transactions T+1 (now expired) and T+2 /// (currently valid), this method will return transaction T+2. private func latestEntitlingTransaction() async -> Transaction? { guard let latestEntitlingTransactionResult = await Transaction.currentEntitlement( for: Constants.paidTierBackupsProductId ) else { return nil } guard let latestEntitlingTransaction = try? latestEntitlingTransactionResult.payloadValue else { owsFailDebug( "Latest entitlement transaction was unverified!", logger: logger ) return nil } return latestEntitlingTransaction } /// `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 } /// All transactions should be finished eventually, so let's /// make sure we do so. await transaction.finish() if let latestEntitlingTransaction = await latestEntitlingTransaction(), 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.") } } } } // MARK: - func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? { store.getIAPSubscriberData(tx: tx) } func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) { store.setIAPSubscriberData(iapSubscriberData, tx: tx) } // MARK: - Fetch current subscription 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) try await downgradeBackupPlanIfNecessary(fetchedSubscription: subscription) return subscription } /// Our remote `Subscription` is the source of truth for what state our /// colloquial "backup plan" is in. So, any time we fetch a `Subscription` /// could be the moment we learn our subscription has changed. Specifically, /// our subscription could have changed since we last fetched such that we /// should "downgrade" our local `BackupPlan`; for example, it might have /// expired, or will be expiring soon. /// /// - Note /// Upgrading requires redeeming a subscription that's renewed, which /// necessarily needs the app to run in order to happen. So, the redemption /// code also sets `BackupPlan` as appropriate. private func downgradeBackupPlanIfNecessary( fetchedSubscription subscription: Subscription? ) async throws { try await db.awaitableWriteWithRollbackIfThrows { tx in let currentBackupPlan = backupPlanManager.backupPlan(tx: tx) let downgradedBackupPlan: BackupPlan? = { if let subscription, subscription.active { switch currentBackupPlan { case .paid(let optimizeLocalStorage) where subscription.cancelAtEndOfPeriod: return .paidExpiringSoon(optimizeLocalStorage: optimizeLocalStorage) case .paid: break case .disabled, .disabling, .free, .paidExpiringSoon, .paidAsTester: break } } else { switch currentBackupPlan { case .paid, .paidExpiringSoon: return .free case .disabled, .disabling, .free, .paidAsTester: break } } return nil }() if let downgradedBackupPlan { do { try backupPlanManager.setBackupPlan(downgradedBackupPlan, tx: tx) logger.info("Downgraded BackupPlan: \(currentBackupPlan) -> \(downgradedBackupPlan)") } catch { owsFailDebug("Failed to downgrade BackupPlan! \(error)") throw error } } } } // MARK: - Purchase new subscription func subscriptionDisplayPrice() async throws -> String { owsPrecondition(!FeatureFlags.Backups.avoidStoreKitForTesters) return try await getPaidTierProduct().displayPrice } func purchaseNewSubscription() async throws -> PurchaseResult { owsPrecondition(!FeatureFlags.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 { 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. try? await storageServiceManager.waitForPendingRestores() let ( isRegisteredPrimaryDevice, persistedIAPSubscriberData, ) = db.read { tx in return ( tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice, store.getIAPSubscriberData(tx: tx), ) } guard isRegisteredPrimaryDevice else { return } let localEntitlingTransaction = await latestEntitlingTransaction() var registerNewSubscriberIdIfSubscriptionMissing = false if let localEntitlingTransaction, let persistedIAPSubscriberData { if persistedIAPSubscriberData.matches(storeKitTransaction: localEntitlingTransaction) { /// We have an active local subscription that matches our persisted /// identifiers. That's the simplest happy-path! Probably... logger.debug("Local transaction matches persisted: \(localEntitlingTransaction.originalID)") /// ...because 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` mapping when the /// StoreKit subscription expires, 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! 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! try await registerNewSubscriberId( originalTransactionId: localEntitlingTransaction.originalID ) } else if persistedIAPSubscriberData != nil { /// We're don't have an active local subscription, but we do have /// identifiers for a subscription. The subscription may be from /// this device but since expired, or we may have restored the /// subscription from another device where we initiated the IAP /// subscription. Regardless, we'll move forward with the /// subscription identifiers in case they're still valid! logger.warn("Have persisted backup subscription IDs, but no local active subscription...") } else { /// We don't have an active local subscription, nor do we have /// subscription IDs for some other subscription. Nothing to do! return } 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 ) 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, _, tx -> BackupSubscriptionRedemptionContext in let redemptionContext = BackupSubscriptionRedemptionContext(subscriberId: subscriberId) 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. @discardableResult private func registerNewSubscriberId( originalTransactionId: UInt64 ) async throws -> Data { 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 OWSAssertionError( "Unexpected status code registering new Backup subscriber ID! \(registerSubscriberIdResponse.responseStatusCode)", logger: logger ) } /// 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 OWSAssertionError( "Unexpected status code associating new Backup subscriber ID with originalTransactionId! \(associateIdsResponse.responseStatusCode)", logger: logger ) } /// Our subscription is now set up on the service, and we should record /// it locally! await db.awaitableWrite { tx in let newSubscriberData = IAPSubscriberData( subscriberId: newSubscriberId, iapSubscriptionId: .originalTransactionId(originalTransactionId) ) store.setIAPSubscriberData(newSubscriberData, tx: tx) } /// We store the subscriber data in Storage Service, so let's kick off /// that backup now. storageServiceManager.recordPendingLocalAccountUpdates() return newSubscriberId } // 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 backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore private let kvStore: KeyValueStore init(backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore) { self.backupAttachmentUploadEraStore = backupAttachmentUploadEraStore 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) } // Any time we set the subscriber ID, rotate the upload era. backupAttachmentUploadEraStore.rotateUploadEra(tx: 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 } }