Signal-iOS/SignalServiceKit/Subscriptions/Backups/BackupSubscriptionManager.swift

694 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
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: "[Backups][Sub]")
private let backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore
private let backupSettingsStore: BackupSettingsStore
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(
backupAttachmentUploadEraStore: BackupAttachmentUploadEraStore,
backupSettingsStore: BackupSettingsStore,
dateProvider: @escaping DateProvider,
db: any DB,
networkManager: NetworkManager,
receiptCredentialRedemptionJobQueue: BackupReceiptCredentialRedemptionJobQueue,
storageServiceManager: StorageServiceManager,
tsAccountManager: TSAccountManager
) {
self.backupAttachmentUploadEraStore = backupAttachmentUploadEraStore
self.backupSettingsStore = backupSettingsStore
self.dateProvider = dateProvider
self.db = db
self.networkManager = networkManager
self.receiptCredentialRedemptionJobQueue = receiptCredentialRedemptionJobQueue
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 {
/// For reasons unknown, the `Product.products(for:)` API sometimes
/// returns an empty list. (In sandbox testing, it'd happen ~50% of
/// the time.)
return try await Retry.performWithBackoff(
maxAttempts: 5,
isRetryable: { $0 is MissingProductError },
block: {
guard let product = try await Product.products(
for: [Constants.paidTierBackupsProductId]
).first else {
throw MissingProductError()
}
return product
}
)
} catch is MissingProductError {
throw OWSAssertionError(
"Paid-tier product repeatedly 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.
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: - Fetch current subscription
public 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)
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 {
let currentBackupPlan = db.read { backupSettingsStore.backupPlan(tx: $0) }
let downgradedBackupPlan: BackupPlan? = {
if let subscription, subscription.active, subscription.cancelAtEndOfPeriod {
switch currentBackupPlan {
case .paid(let optimizeLocalStorage):
return .paidExpiringSoon(optimizeLocalStorage: optimizeLocalStorage)
case .disabled, .free, .paidExpiringSoon:
break
}
} else if let subscription, subscription.active {
// No downgrade subscription present and active!
} else {
switch currentBackupPlan {
case .paid, .paidExpiringSoon:
return .free
case .disabled, .free:
break
}
}
return nil
}()
if let downgradedBackupPlan {
logger.info("Downgrading BackupPlan: \(currentBackupPlan) -> \(downgradedBackupPlan)")
await db.awaitableWrite { tx in
backupSettingsStore.setBackupPlan(downgradedBackupPlan, tx: tx)
}
}
}
// MARK: - Purchase new subscription
/// Returns the price for a Backups subscription, formatted for display.
public func subscriptionDisplayPrice() async throws -> String {
return try await getPaidTierProduct().displayPrice
}
/// 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.
public func purchaseNewSubscription() async throws -> PurchaseResult {
switch try await getPaidTierProduct().purchase() {
case .success(let purchaseResult):
switch purchaseResult {
case .verified:
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
/// - 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)
/// 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 redemptionTaskQueue.run {
try await self._redeemSubscriptionIfNecessary()
}
}
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 (
registrationState,
persistedIAPSubscriberData,
) = db.read { tx in
return (
tsAccountManager.registrationState(tx: tx),
store.getIAPSubscriberData(tx: tx),
)
}
guard registrationState.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<
BackupReceiptCredentialRedemptionJobRecord
>(
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
},
enqueueRedemptionJobBlock: { subscriberId, _, tx -> BackupReceiptCredentialRedemptionJobRecord in
return receiptCredentialRedemptionJobQueue.saveBackupRedemptionJob(
subscriberId: subscriberId,
tx: tx
)
},
startRedemptionJobBlock: { jobRecord async throws in
// Note that this step, if successful, will set BackupPlan.
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.
@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.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()
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)
}
}
}
// 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
}
}