// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import StoreKit import LibSignalClient 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: - /// 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 final class BackupSubscriptionManager { public typealias PurchaseResult = BackupSubscription.PurchaseResult public typealias IAPSubscriberData = BackupSubscription.IAPSubscriberData 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: "[MessageBackup][Sub]") private let dateProvider: DateProvider private let db: any DB private let networkManager: NetworkManager private let receiptCredentialRedemptionJobQueue: BackupReceiptCredentialRedemptionJobQueue private let storageServiceManager: StorageServiceManager private let store: Store private let tsAccountManager: TSAccountManager init( dateProvider: @escaping DateProvider, db: any DB, networkManager: NetworkManager, receiptCredentialRedemptionJobQueue: BackupReceiptCredentialRedemptionJobQueue, storageServiceManager: StorageServiceManager, tsAccountManager: TSAccountManager ) { self.dateProvider = dateProvider self.db = db self.networkManager = networkManager self.receiptCredentialRedemptionJobQueue = receiptCredentialRedemptionJobQueue self.storageServiceManager = storageServiceManager self.store = Store() self.tsAccountManager = tsAccountManager listenForTransactionUpdates() } /// 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. try await redeemSubscriptionIfNecessary() } catch { owsFailDebug( "Failed to redeem subscription: \(error)", logger: logger ) } } else { logger.info("Transaction update is not for latest entitling subscription.") } } } } // MARK: - /// Get the user's current IAP subscriber data, if present. public func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? { store.getIAPSubscriberData(tx: tx) } /// 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. public func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) { store.setIAPSubscriberData(iapSubscriberData, tx: tx) } // MARK: - Purchase new subscription /// Attempts to purchase and redeem a Backups subscription for the first /// time, via StoreKit IAP. /// /// - 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. public func purchaseNewSubscription() async throws -> PurchaseResult { guard let paidTierProduct = try await Product.products(for: [Constants.paidTierBackupsProductId]).first else { throw OWSAssertionError( "Failed to get paid tier subscription product from StoreKit!", logger: logger ) } switch try await paidTierProduct.purchase() { case .success(let purchaseResult): switch purchaseResult { case .verified: try await redeemSubscriptionIfNecessary() 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 /// Serializes multiple attempts to redeem a subscription, so they don't /// race. Specifically, if a caller attempts to redeem a subscription while /// a previous caller's attempt is in progress, the latter caller will wait /// on the previous caller. /// /// `_redeemSubscriptionIfNecessary()` uses persisted state, so latter /// callers may be able to short-circuit based on state persisted by an /// earlier caller. private let redemptionAttemptSerializer = SerialTaskQueue() /// 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. public func redeemSubscriptionIfNecessary() async throws { return try await redemptionAttemptSerializer.enqueue { try await self._redeemSubscriptionIfNecessary() }.value } private func _redeemSubscriptionIfNecessary() async throws { /// Wait on any in-progress restores, since there's a chance we're /// restoring subscriber data. try? await storageServiceManager.waitForPendingRestores() let persistedIAPSubscriberData: IAPSubscriberData? = db.read { tx in return store.getIAPSubscriberData(tx: tx) } let localEntitlingTransaction = await latestEntitlingTransaction() 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! Great. } 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. 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< BackupReceiptCredentialRedemptionJobRecord >( checkerStore: store, dateProvider: dateProvider, db: db, logger: logger, networkManager: networkManager, tsAccountManager: tsAccountManager ) try await subscriptionRedemptionNecessaryChecker.redeemSubscriptionIfNecessary( parseEntitlementExpirationBlock: { accountEntitlements, _ in return accountEntitlements.backup?.expirationSeconds }, enqueueRedemptionJobBlock: { subscriberId, _, tx -> BackupReceiptCredentialRedemptionJobRecord in return receiptCredentialRedemptionJobQueue.saveBackupRedemptionJob( subscriberId: subscriberId, tx: tx ) }, startRedemptionJobBlock: { jobRecord async throws in try await receiptCredentialRedemptionJobQueue.runBackupRedemptionJob(jobRecord: jobRecord) } ) } /// 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 { 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.makePromise( request: .registerSubscriberId(subscriberId: newSubscriberId) ).awaitable() 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.makePromise( request: .associateSubscriberId( newSubscriberId, withOriginalTransactionId: originalTransactionId ) ).awaitable() 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() } // 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 subscriberId(tx: DBReadTransaction) -> Data? { return getIAPSubscriberData(tx: tx)?.subscriberId } 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) } } } // 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(.redactURLForSuccessResponses()) return request } }