753 lines
31 KiB
Swift
753 lines
31 KiB
Swift
//
|
|
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import StoreKit
|
|
import LibSignalClient
|
|
|
|
/// Responsible for In-App Purchases (IAP) via StoreKit 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.
|
|
///
|
|
/// - 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 RedemptionResult = BackupSubscription.RedemptionResult
|
|
|
|
/// 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.
|
|
///
|
|
/// - Note
|
|
/// This method will finish successfully
|
|
func purchaseNewSubscription() async throws -> PurchaseResult
|
|
|
|
/// 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 -> RedemptionResult
|
|
|
|
/// Returns the current Backup subscriber ID, if one exists.
|
|
func getBackupSubscriberId(tx: DBReadTransaction) -> Data?
|
|
|
|
/// Sets the current Backup subscriber ID.
|
|
///
|
|
/// - Important
|
|
/// Generally, this type generates and manages the `backupSubscriberId`
|
|
/// internally. This API should only be invoked by callers who are
|
|
/// confident their `backupSubscriberId` is the most-current, such as when
|
|
/// restoring from Storage Service or a Backup.
|
|
func setBackupSubscriberId(_ backupSubscriberId: Data, tx: DBWriteTransaction)
|
|
}
|
|
|
|
public enum BackupSubscription {
|
|
|
|
/// 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(RedemptionResult)
|
|
|
|
/// Purchase is pending external action, such as approval when "Ask to
|
|
/// Buy" is enabled.
|
|
case pending
|
|
|
|
/// The user cancelled the purchase.
|
|
case userCancelled
|
|
}
|
|
|
|
/// Describes the result of redeeming a StoreKit purchase with Signal
|
|
/// servers.
|
|
public enum RedemptionResult {
|
|
/// Redemption was successful.
|
|
case success
|
|
|
|
/// No action was necessary. For example, we may have already redeemed
|
|
/// the subscription for the current period, or may not have an active
|
|
/// subscription to redeem.
|
|
case noActionNeeded
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
|
|
/// A "receipt level" baked by the server into the receipt credentials
|
|
/// used for Backups, representing the free (messages) tier.
|
|
static let freeTierBackupsReceiptLevel = 200
|
|
/// A "receipt level" baked by the server into the receipt credentials
|
|
/// used for Backups, representing the paid (media) tier.
|
|
static let paidTierBackupsReceiptLevel = 201
|
|
}
|
|
|
|
private let logger = PrefixedLogger(prefix: "[BkpSubMgr]")
|
|
|
|
private let dateProvider: DateProvider
|
|
private let db: any DB
|
|
private let kvStore: KeyValueStore
|
|
private let networkManager: NetworkManager
|
|
|
|
init(
|
|
dateProvider: @escaping DateProvider,
|
|
db: any DB,
|
|
networkManager: NetworkManager
|
|
) {
|
|
self.dateProvider = dateProvider
|
|
self.db = db
|
|
self.kvStore = KeyValueStore(collection: "BackupSubscriptionManager")
|
|
self.networkManager = networkManager
|
|
|
|
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
|
|
}
|
|
|
|
/// Listens to `Transaction.updates`, and handles any transaction updates as
|
|
/// appropriate.
|
|
///
|
|
/// `Transaction.updates` is how the app is informed by StoreKit about
|
|
/// transactions other than ones we completed inline via `.purchase()`. The
|
|
/// big case this covers is renewals: when the subscription renews, we learn
|
|
/// about it next time we launch via this listener. If, for example, a user
|
|
/// went offline for two months (i.e., two renewal periods) this listener
|
|
/// will receive two transactions: one for the now-expired period, and one
|
|
/// for the current period.
|
|
///
|
|
/// The other case this covers is "Ask to Buy", where the user's purchase is
|
|
/// "pending" until approved/denied by someone else (e.g., a parent); when
|
|
/// that happens, we get a callback here.
|
|
private func listenForTransactionUpdates() {
|
|
// TODO: [BSub] Ensure this is only kicked off once, during app launch.
|
|
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
|
|
}
|
|
|
|
logger.info("Got transaction update.")
|
|
|
|
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 redeem a subscription,
|
|
/// so let's attempt to do so. We could get here, for
|
|
/// example, if someone who purchased a subscription had
|
|
/// "Ask to Buy" on; when the purchase is approved we'll get
|
|
/// a callback here, and should try and redeem.
|
|
///
|
|
/// Note that `redeemSubscriptionIfNecessary` will fetch the
|
|
/// latest entitling transaction itself, and finish it when
|
|
/// redemption succeeds.
|
|
_ = try await redeemSubscriptionIfNecessary()
|
|
} catch {
|
|
owsFailDebug(
|
|
"Failed to redeem subscription: \(error)",
|
|
logger: logger
|
|
)
|
|
}
|
|
} else {
|
|
logger.info("Transaction update is not for latest entitling subscription; finishing it.")
|
|
|
|
/// This transaction doesn't entitle us to a subscription,
|
|
/// maybe because it's expired or revoked. Regardless, all
|
|
/// transactions should be finished evenutally, so let's lay
|
|
/// this one to rest.
|
|
await transaction.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Purchase new subscription
|
|
|
|
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:
|
|
return .success(try await redeemSubscriptionIfNecessary())
|
|
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()
|
|
|
|
func redeemSubscriptionIfNecessary() async throws -> RedemptionResult {
|
|
return try await redemptionAttemptSerializer.enqueue {
|
|
try await self._redeemSubscriptionIfNecessary()
|
|
}.value
|
|
}
|
|
|
|
private func _redeemSubscriptionIfNecessary() async throws -> RedemptionResult {
|
|
// TODO: [BSub] We need to wait on Storage Service restore, since we keep the subscriber ID in there.
|
|
|
|
guard let latestEntitlingTransaction = await latestEntitlingTransaction() else {
|
|
return .noActionNeeded
|
|
}
|
|
|
|
let (
|
|
latestRedeemedTransactionId,
|
|
persistedBackupSubscriberId,
|
|
inProgressRedemptionState
|
|
): (
|
|
UInt64?,
|
|
Data?,
|
|
RedemptionAttemptState
|
|
) = try db.read { tx in
|
|
return (
|
|
getLatestRedeemedTransactionId(tx: tx),
|
|
getBackupSubscriberId(tx: tx),
|
|
try getInProgressRedemptionState(tx: tx)
|
|
)
|
|
}
|
|
|
|
guard latestRedeemedTransactionId != latestEntitlingTransaction.id else {
|
|
/// This means that we've already done a redemption for this
|
|
/// transaction. The transaction should already be finished, so
|
|
/// we've got nothing left to do.
|
|
///
|
|
/// It's possible that we'd get here with an unfinished transaction
|
|
/// if we persisted relevant data, but crashed before finishing the
|
|
/// transaction. That's not ideal but should be okay; when we get a
|
|
/// new latest-entitling transaction (in the next subscription
|
|
/// period) that unfinished transaction will be handled as expired.
|
|
return .noActionNeeded
|
|
}
|
|
|
|
let subscriberId: Data
|
|
if let persistedBackupSubscriberId {
|
|
subscriberId = persistedBackupSubscriberId
|
|
} else {
|
|
subscriberId = try await registerNewSubscriberId(
|
|
originalTransactionId: latestEntitlingTransaction.originalID
|
|
)
|
|
}
|
|
|
|
if
|
|
let subscriptionStatus = await latestEntitlingTransaction.subscriptionStatus,
|
|
let renewalInfo = try? subscriptionStatus.renewalInfo.payloadValue,
|
|
let renewalDate = renewalInfo.renewalDate
|
|
{
|
|
logger.info("Attempting to redeem subscription for renewal period ending at \(renewalDate).")
|
|
} else {
|
|
logger.warn("Attempting to redeem subscription, but with unverified or missing renewal date!")
|
|
}
|
|
|
|
/// Redeem a subscription for this transaction, and subsequently mark it
|
|
/// as finished.
|
|
return try await redeemSubscription(
|
|
subscriberId: subscriberId,
|
|
latestTransaction: latestEntitlingTransaction,
|
|
inProgressRedemptionState: inProgressRedemptionState
|
|
)
|
|
}
|
|
|
|
/// 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 -> Data {
|
|
logger.info("Generating and registering new Backups subscriber ID!")
|
|
|
|
let newSubscriberId = 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 subscriber ID is now set up on the service, and we should record
|
|
/// it locally!
|
|
await db.awaitableWrite { tx in
|
|
self.setBackupSubscriberId(newSubscriberId, tx: tx)
|
|
}
|
|
|
|
// TODO: [BSub] Record the new subscriber ID in Storage Service
|
|
|
|
return newSubscriberId
|
|
}
|
|
|
|
/// Performs the steps required to redeem a Backup subscription for the
|
|
/// period covered by the given `Transaction`.
|
|
///
|
|
/// Specifically, performs the following steps:
|
|
/// 1. Generates a "receipt credential request".
|
|
/// 2. Sends the receipt credential request to the service, receiving in
|
|
/// return a receipt credential presentation.
|
|
/// 3. Redeems the receipt credential presentation with the service, which
|
|
/// enables or extends the server-side flag enabling paid-tier Backups
|
|
/// for our account.
|
|
///
|
|
/// - Note
|
|
/// This method functions as a state machine, starting with the given
|
|
/// redemption state. As we move through each step we persist updated state,
|
|
/// then recursively call this method with the new state.
|
|
///
|
|
/// It's important that we persist the intermediate states so that we can
|
|
/// resume if interrupted, since we may be mutating remote state in such a
|
|
/// way that's only safe to retry with the same inputs.
|
|
private func redeemSubscription(
|
|
subscriberId: Data,
|
|
latestTransaction: Transaction,
|
|
inProgressRedemptionState: RedemptionAttemptState
|
|
) async throws -> RedemptionResult {
|
|
func markTransactionAsRedeemed() async {
|
|
await db.awaitableWrite { tx in
|
|
/// This also clears the in-progress redemption state.
|
|
self.setLatestRedeemedTransactionId(latestTransaction.id, tx: tx)
|
|
}
|
|
await latestTransaction.finish()
|
|
}
|
|
|
|
switch inProgressRedemptionState {
|
|
case .unattempted:
|
|
logger.info("Generating receipt credential request.")
|
|
|
|
// TODO: [BSub] Move this code out of DonationSubscriptionManager
|
|
let (
|
|
receiptCredentialRequestContext,
|
|
receiptCredentialRequest
|
|
) = DonationSubscriptionManager.generateReceiptRequest()
|
|
|
|
let nextRedemptionState: RedemptionAttemptState = .receiptCredentialRequesting(
|
|
request: receiptCredentialRequest,
|
|
context: receiptCredentialRequestContext
|
|
)
|
|
|
|
try await db.awaitableWrite { tx throws in
|
|
try self.setInProgressRedemptionState(nextRedemptionState, tx: tx)
|
|
}
|
|
|
|
return try await redeemSubscription(
|
|
subscriberId: subscriberId,
|
|
latestTransaction: latestTransaction,
|
|
inProgressRedemptionState: nextRedemptionState
|
|
)
|
|
case .receiptCredentialRequesting(
|
|
let receiptCredentialRequest,
|
|
let receiptCredentialRequestContext
|
|
):
|
|
logger.info("Requesting receipt credential.")
|
|
|
|
let receiptCredential: ReceiptCredential
|
|
do {
|
|
// TODO: [BSub] Move this code out of DonationSubscriptionManager
|
|
receiptCredential = try await DonationSubscriptionManager.requestReceiptCredential(
|
|
subscriberId: subscriberId,
|
|
isValidReceiptLevelPredicate: { receiptLevel -> Bool in
|
|
/// We'll accept either receipt level here to handle
|
|
/// things like clock skew, although we're generally
|
|
/// expecting a paid-tier receipt credential.
|
|
return (
|
|
receiptLevel == Constants.paidTierBackupsReceiptLevel
|
|
|| receiptLevel == Constants.freeTierBackupsReceiptLevel
|
|
)
|
|
},
|
|
context: receiptCredentialRequestContext,
|
|
request: receiptCredentialRequest,
|
|
logger: logger
|
|
).awaitable()
|
|
} catch let error as DonationSubscriptionManager.KnownReceiptCredentialRequestError {
|
|
switch error.errorCode {
|
|
case .paymentIntentRedeemed:
|
|
logger.warn("Subscription had already been redeemed for this period!")
|
|
|
|
/// This error (a 409) indicates that we've already redeemed
|
|
/// a receipt credential for the current "invoice", or
|
|
/// subscription period.
|
|
///
|
|
/// We end up here if for whatever reason we don't know that
|
|
/// we've already redeemed for this subscription period. For
|
|
/// example, we may have redeemed on a previous install and
|
|
/// are missing the latest-redeemed transaction ID on this
|
|
/// install.
|
|
///
|
|
/// Regardless, we now know that we've redeemed for this
|
|
/// subscription period, so there's nothing left to do and
|
|
/// we can treat this as a success.
|
|
await markTransactionAsRedeemed()
|
|
return .success
|
|
case
|
|
.paymentStillProcessing,
|
|
.paymentFailed,
|
|
.localValidationFailed,
|
|
.serverValidationFailed,
|
|
.paymentNotFound:
|
|
throw error
|
|
}
|
|
}
|
|
|
|
let nextRedemptionState: RedemptionAttemptState = .receiptCredentialRedemption(
|
|
receiptCredential
|
|
)
|
|
|
|
try await db.awaitableWrite { tx in
|
|
try self.setInProgressRedemptionState(nextRedemptionState, tx: tx)
|
|
}
|
|
|
|
return try await redeemSubscription(
|
|
subscriberId: subscriberId,
|
|
latestTransaction: latestTransaction,
|
|
inProgressRedemptionState: nextRedemptionState
|
|
)
|
|
case .receiptCredentialRedemption(let receiptCredential):
|
|
logger.info("Redeeming receipt credential.")
|
|
|
|
let presentation = try DonationSubscriptionManager.generateReceiptCredentialPresentation(
|
|
receiptCredential: receiptCredential
|
|
)
|
|
|
|
let response = try await networkManager.makePromise(
|
|
request: .backupRedeemReceiptCredential(
|
|
receiptCredentialPresentation: presentation
|
|
)
|
|
).awaitable()
|
|
|
|
switch response.responseStatusCode {
|
|
case 400:
|
|
/// This indicates that our receipt credential presentation has
|
|
/// expired. This is a weird scenario, because it indicates that
|
|
/// so much time has elapsed since we got the receipt credential
|
|
/// presentation and attempted to redeem it that it expired.
|
|
/// Weird, but not impossible!
|
|
///
|
|
/// We can handle this by throwing away the expired receipt
|
|
/// credential and starting over.
|
|
logger.warn("Receipt credential was expired!")
|
|
|
|
let nextRedemptionState: RedemptionAttemptState = .unattempted
|
|
|
|
try await db.awaitableWrite { tx in
|
|
try self.setInProgressRedemptionState(nextRedemptionState, tx: tx)
|
|
}
|
|
|
|
return try await redeemSubscription(
|
|
subscriberId: subscriberId,
|
|
latestTransaction: latestTransaction,
|
|
inProgressRedemptionState: nextRedemptionState
|
|
)
|
|
case 204:
|
|
logger.info("Receipt credential redeemed successfully.")
|
|
|
|
await markTransactionAsRedeemed()
|
|
return .success
|
|
default:
|
|
/// We don't expect to recover from any of these unexpected
|
|
/// statuses, so we'll mark the transaction as redeemed so we
|
|
/// don't try again indefinitely before throwing.
|
|
let error = OWSAssertionError(
|
|
"Unexpected response status code: \(response.responseStatusCode)",
|
|
logger: logger
|
|
)
|
|
|
|
await markTransactionAsRedeemed()
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private enum StoreKeys {
|
|
/// Our "subscriber ID" for Backups, which we give to the server to
|
|
/// associate with an identifier for our IAP. Like the donations
|
|
/// subscriber ID, this value is not associated with our account, and
|
|
/// thereby creates a separation between our account and any IAP
|
|
/// identifiers.
|
|
static let backupSubscriberId = "backupSubscriberId"
|
|
|
|
/// The StoreKit ID of the latest `Transaction` we've successfully
|
|
/// redeemed. Stored such that we can avoid attempting to redeem the
|
|
/// same transaction multiple times.
|
|
static let latestRedeemedTransactionId = "latestRedeemedTransactionId"
|
|
|
|
/// The latest state of any in-progress attempts at redeeming a StoreKit
|
|
/// subscription with Signal servers.
|
|
///
|
|
/// See ``RedemptionAttemptState`` for more details.
|
|
static let inProgressRedemptionAttemptState = "redemptionAttemptState"
|
|
}
|
|
|
|
func getBackupSubscriberId(tx: DBReadTransaction) -> Data? {
|
|
return kvStore.getData(StoreKeys.backupSubscriberId, transaction: tx)
|
|
}
|
|
|
|
func setBackupSubscriberId(_ backupSubscriberId: Data, tx: DBWriteTransaction) {
|
|
kvStore.setData(backupSubscriberId, key: StoreKeys.backupSubscriberId, transaction: tx)
|
|
}
|
|
|
|
private func getLatestRedeemedTransactionId(tx: DBReadTransaction) -> UInt64? {
|
|
return kvStore.getUInt64(StoreKeys.latestRedeemedTransactionId, transaction: tx)
|
|
}
|
|
|
|
private func setLatestRedeemedTransactionId(_ transactionId: UInt64, tx: DBWriteTransaction) {
|
|
kvStore.removeValue(forKey: StoreKeys.inProgressRedemptionAttemptState, transaction: tx)
|
|
kvStore.setUInt64(transactionId, key: StoreKeys.latestRedeemedTransactionId, transaction: tx)
|
|
}
|
|
|
|
private func getInProgressRedemptionState(tx: DBReadTransaction) throws -> RedemptionAttemptState {
|
|
guard let persistedValue: RedemptionAttemptState = try? kvStore.getCodableValue(
|
|
forKey: StoreKeys.inProgressRedemptionAttemptState,
|
|
transaction: tx
|
|
) else {
|
|
return .unattempted
|
|
}
|
|
|
|
return persistedValue
|
|
}
|
|
|
|
private func setInProgressRedemptionState(_ state: RedemptionAttemptState, tx: DBWriteTransaction) throws {
|
|
try kvStore.setCodable(
|
|
state,
|
|
key: StoreKeys.inProgressRedemptionAttemptState,
|
|
transaction: tx
|
|
)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
/// Represents the state of an in-progress attempt at redeeming a StoreKit
|
|
/// subscription.
|
|
///
|
|
/// It's important that we store this, because (generally) once we've made a
|
|
/// receipt-credential-related request to the server, remote state has been
|
|
/// set corresponding to the state we put in the request. If the app exits
|
|
/// between making two requests, we need to have stored the data we sent in
|
|
/// the first request so we can retry the second.
|
|
///
|
|
/// Broadly, there are two network requests required to redeem a receipt
|
|
/// credential for a StoreKit subscription.
|
|
///
|
|
/// The first is to "request a receipt credential", which takes a
|
|
/// locally-generated "receipt credential request" and returns us data we
|
|
/// can use to construct a "receipt credential presentation". Once we have
|
|
/// the receipt credential presentation, we can discard the receipt
|
|
/// credential request.
|
|
///
|
|
/// The second is to "redeem the receipt credential", which sends the
|
|
/// receipt credential presentation from the first request to the service,
|
|
/// which validates it and subsequently records that our account is now
|
|
/// eligible (or has extended its eligibility) for paid-tier Backups. When
|
|
/// this completes, the attempt is complete.
|
|
private enum RedemptionAttemptState: Codable {
|
|
/// This attempt is at a clean slate.
|
|
case unattempted
|
|
|
|
/// We need to request a receipt credential, using the associated
|
|
/// request and context objects.
|
|
///
|
|
/// Note that it is safe to request a receipt credential multiple times,
|
|
/// as long as the request/context are the same across retries. Receipt
|
|
/// credential requests do not expire, and the returned receipt
|
|
/// credential will always correspond to the latest entitling
|
|
/// transaction.
|
|
case receiptCredentialRequesting(
|
|
request: ReceiptCredentialRequest,
|
|
context: ReceiptCredentialRequestContext
|
|
)
|
|
|
|
/// We have a receipt credential, and need to redeem it.
|
|
///
|
|
/// Note that it is safe to attempt to redeem a receipt credential
|
|
/// multiple times for the same subscription period.
|
|
case receiptCredentialRedemption(ReceiptCredential)
|
|
|
|
// MARK: Codable
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case receiptCredentialRequest
|
|
case receiptCredentialRequestContext
|
|
case receiptCredential
|
|
}
|
|
|
|
init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
if
|
|
let requestData = try container.decodeIfPresent(Data.self, forKey: .receiptCredentialRequest),
|
|
let contextData = try container.decodeIfPresent(Data.self, forKey: .receiptCredentialRequestContext)
|
|
{
|
|
self = .receiptCredentialRequesting(
|
|
request: try ReceiptCredentialRequest(contents: [UInt8](requestData)),
|
|
context: try ReceiptCredentialRequestContext(contents: [UInt8](contextData))
|
|
)
|
|
} else
|
|
if let credentialData = try container.decodeIfPresent(Data.self, forKey: .receiptCredential)
|
|
{
|
|
self = .receiptCredentialRedemption(
|
|
try ReceiptCredential(contents: [UInt8](credentialData))
|
|
)
|
|
} else {
|
|
self = .unattempted
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
|
|
switch self {
|
|
case .receiptCredentialRequesting(let request, let context):
|
|
try container.encode(request.serialize().asData, forKey: .receiptCredentialRequest)
|
|
try container.encode(context.serialize().asData, forKey: .receiptCredentialRequestContext)
|
|
case .receiptCredentialRedemption(let credential):
|
|
try container.encode(credential.serialize().asData, forKey: .receiptCredential)
|
|
case .unattempted:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension TSRequest {
|
|
static func registerSubscriberId(subscriberId: Data) -> TSRequest {
|
|
return OWSRequestFactory.setSubscriberID(subscriberId)
|
|
}
|
|
|
|
static func associateSubscriberId(
|
|
_ subscriberId: Data,
|
|
withOriginalTransactionId originalTransactionId: UInt64
|
|
) -> TSRequest {
|
|
let request = TSRequest(
|
|
url: URL(string: "v1/subscription/\(subscriberId.asBase64Url)/appstore/\(originalTransactionId)")!,
|
|
method: "POST",
|
|
parameters: nil
|
|
)
|
|
request.shouldHaveAuthorizationHeaders = false
|
|
request.applyRedactionStrategy(.redactURLForSuccessResponses())
|
|
return request
|
|
}
|
|
|
|
static func backupRedeemReceiptCredential(
|
|
receiptCredentialPresentation: ReceiptCredentialPresentation
|
|
) -> TSRequest {
|
|
return TSRequest(
|
|
url: URL(string: "v1/archives/redeem-receipt")!,
|
|
method: "POST",
|
|
parameters: [
|
|
"receiptCredentialPresentation": receiptCredentialPresentation
|
|
.serialize().asData.base64EncodedString(),
|
|
]
|
|
)
|
|
}
|
|
}
|