Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Subscriptions/Backups/BackupSubscriptionRedeemer.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient

/// Responsible for redeeming receipt credentials for Backups subscriptions.
class BackupSubscriptionRedeemer {
    private enum Constants {
        /// A "receipt level" baked by the server into the receipt credentials
        /// used for Backups, representing the free (messages) tier.
        static let freeTierBackupReceiptLevel = 200
        /// A "receipt level" baked by the server into the receipt credentials
        /// used for Backups, representing the paid (media) tier.
        static let paidTierBackupReceiptLevel = 201
    }

    private let authCredentialStore: AuthCredentialStore
    private let backupPlanManager: BackupPlanManager
    private let backupSubscriptionIssueStore: BackupSubscriptionIssueStore
    private let db: any DB
    private let logger: PrefixedLogger
    private let reachabilityManager: SSKReachabilityManager
    private let receiptCredentialManager: ReceiptCredentialManager
    private let networkManager: NetworkManager

    private var networkRetryWaitingTask: AtomicValue<Task<Void, Never>?>
    private var notificationObservers: [NotificationCenter.Observer]
    private var transientFailureCount: UInt

    init(
        authCredentialStore: AuthCredentialStore,
        backupPlanManager: BackupPlanManager,
        backupSubscriptionIssueStore: BackupSubscriptionIssueStore,
        dateProvider: @escaping DateProvider,
        db: any DB,
        reachabilityManager: SSKReachabilityManager,
        networkManager: NetworkManager,
    ) {
        self.authCredentialStore = authCredentialStore
        self.backupPlanManager = backupPlanManager
        self.backupSubscriptionIssueStore = backupSubscriptionIssueStore
        self.db = db
        self.logger = PrefixedLogger(prefix: "[Backups]")
        self.reachabilityManager = reachabilityManager
        self.receiptCredentialManager = ReceiptCredentialManager(
            dateProvider: dateProvider,
            logger: logger,
            networkManager: networkManager,
        )
        self.networkManager = networkManager

        self.networkRetryWaitingTask = AtomicValue(nil, lock: .init())
        self.notificationObservers = []
        self.transientFailureCount = 0

        notificationObservers.append(NotificationCenter.default.addObserver(
            name: SSKReachability.owsReachabilityDidChange,
            block: { [weak self] _ in
                guard let self else { return }

                networkRetryWaitingTask.update { task in
                    if let task, reachabilityManager.isReachable {
                        task.cancel()
                    }
                }
            },
        ))
    }

    deinit {
        notificationObservers.forEach { NotificationCenter.default.removeObserver($0) }
    }

    // MARK: -

    /// Returns an exponential-backoff retry delay that increases with each
    /// subsequent call to this method.
    private func waitForIncrementedExponentialRetry() async {
        transientFailureCount += 1

        let retryDelay: TimeInterval = OWSOperation.retryIntervalForExponentialBackoff(
            failureCount: transientFailureCount,
            maxAverageBackoff: .day,
        )

        do {
            try await Task.sleep(nanoseconds: retryDelay.clampedNanoseconds)
        } catch {
            owsPrecondition(error is CancellationError)
        }
    }

    // MARK: -

    func redeem(context: BackupSubscriptionRedemptionContext) async throws {
        struct TerminalRedemptionError: Error {}

        switch await _redeemBackupReceiptCredential(context: context) {
        case .success:
            await db.awaitableWrite { tx in
                context.delete(tx: tx)

                /// We're now a paid-tier Backups user according to the server.
                /// If our local thinks we're free-tier, upgrade it.
                switch backupPlanManager.backupPlan(tx: tx) {
                case .free:
                    // "Optimize Media" is off by default when you first upgrade.
                    backupPlanManager.setBackupPlan(
                        .paid(optimizeLocalStorage: false),
                        tx: tx,
                    )
                case .disabled, .disabling:
                    // Don't sneakily enable Backups!
                    break
                case .paid, .paidExpiringSoon, .paidAsTester:
                    break
                }

                /// Clear out any cached Backup auth credentials, since we
                /// may now be able to fetch credentials with a higher level
                /// of access than we had cached.
                authCredentialStore.removeAllBackupAuthCredentials(tx: tx)

                /// We've successfully redeemed, so any "already redeemed"
                /// errors are by definition obsolete.
                backupSubscriptionIssueStore.setStopWarningIAPSubscriptionAlreadyRedeemed(tx: tx)
            }
            logger.info("Redemption successful!")

        case .needsReattempt:
            // Try again, without a delay.
            try await redeem(context: context)

        case .paymentStillProcessing:
            // Try again, with a delay.
            await waitForIncrementedExponentialRetry()
            try await redeem(context: context)

        case .networkError:
            // Try again, with an interruptable delay.
            let waitingTask = networkRetryWaitingTask.update {
                let task = Task {
                    await waitForIncrementedExponentialRetry()
                    networkRetryWaitingTask.set(nil)
                }
                $0 = task
                return task
            }
            await waitingTask.value
            try await redeem(context: context)

        case .redemptionUnsuccessful:
            Logger.warn("Failed to redeem subscription.")
            await db.awaitableWrite { context.delete(tx: $0) }
            throw TerminalRedemptionError()
        }
    }

    // MARK: -

    private enum RedeemBackupReceiptCredentialResult {
        case success
        case networkError
        case needsReattempt
        case paymentStillProcessing
        case redemptionUnsuccessful
    }

    /// Performs the steps required to redeem a Backup subscription.
    ///
    /// 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 _redeemBackupReceiptCredential(
        context: BackupSubscriptionRedemptionContext,
    ) async -> RedeemBackupReceiptCredentialResult {

        switch context.attemptState {
        case .unattempted:
            logger.info("Generating receipt credential request.")

            let (
                receiptCredentialRequestContext,
                receiptCredentialRequest,
            ) = ReceiptCredentialManager.generateReceiptRequest()

            await db.awaitableWrite { tx in
                context.attemptState = .receiptCredentialRequesting(
                    request: receiptCredentialRequest,
                    context: receiptCredentialRequestContext,
                )
                context.upsert(tx: tx)
            }
            return await _redeemBackupReceiptCredential(context: context)

        case .receiptCredentialRequesting(
            let receiptCredentialRequest,
            let receiptCredentialRequestContext,
        ):
            logger.info("Requesting receipt credential.")

            let receiptCredential: ReceiptCredential
            do {
                receiptCredential = try await receiptCredentialManager.requestReceiptCredential(
                    via: OWSRequestFactory.subscriptionReceiptCredentialsRequest(
                        subscriberID: context.subscriberId,
                        receiptCredentialRequest: receiptCredentialRequest,
                    ),
                    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.paidTierBackupReceiptLevel
                                || receiptLevel == Constants.freeTierBackupReceiptLevel

                    },
                    context: receiptCredentialRequestContext,
                )
            } catch let error as ReceiptCredentialRequestError {
                switch error.errorCode {
                case .paymentIntentRedeemed:
                    /// This error (a 409) indicates that we've already made the
                    /// maximum number of unique receipt credential requests for
                    /// the current "invoice", or subscription period. If we get
                    /// here, we're dead-ended: we won't be able to redeem the
                    /// subscription for this period.
                    ///
                    /// Accordingly, we persist that we hit this error (so we
                    /// can show appropriate error UX) and treat this as a
                    /// permanent failure.
                    ///
                    /// We only attempt redemption if our Backup entitlement
                    /// suggests we haven't yet redeemed for this subscription
                    /// period, and we're careful to only use one receipt
                    /// credential request through a given period's redemption.
                    /// Consequently, the most likely way we'll end up here is
                    /// if multiple Signal accounts are trying to share the same
                    /// IAP subscription.
                    logger.warn("Subscription had already been redeemed for this period!")

                    await db.awaitableWrite { tx in
                        backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionAlreadyRedeemed(
                            endOfCurrentPeriod: context.subscriptionEndOfCurrentPeriod ?? .distantPast,
                            tx: tx,
                        )
                    }
                    return .redemptionUnsuccessful
                case .paymentStillProcessing:
                    return .paymentStillProcessing
                case
                    .paymentFailed,
                    .localValidationFailed,
                    .serverValidationFailed,
                    .paymentNotFound:
                    owsFailDebug(
                        "Unexpected error code requesting receipt credentials! \(error.errorCode)",
                        logger: logger,
                    )
                    return .redemptionUnsuccessful
                }
            } catch where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse {
                return .networkError
            } catch let error {
                owsFailDebug(
                    "Unexpected error requesting receipt credential: \(error)",
                    logger: logger,
                )
                return .redemptionUnsuccessful
            }

            await db.awaitableWrite { tx in
                context.attemptState = .receiptCredentialRedemption(receiptCredential)
                context.upsert(tx: tx)
            }
            return await _redeemBackupReceiptCredential(context: context)

        case .receiptCredentialRedemption(let receiptCredential):
            logger.info("Redeeming receipt credential.")

            let presentation: ReceiptCredentialPresentation
            do {
                presentation = try ReceiptCredentialManager.generateReceiptCredentialPresentation(
                    receiptCredential: receiptCredential,
                )
            } catch let error {
                owsFailDebug(
                    "Failed to generate receipt credential presentation: \(error)",
                    logger: logger,
                )
                return .redemptionUnsuccessful
            }

            let response: HTTPResponse
            do {
                response = try await networkManager.asyncRequest(
                    .backupRedeemReceiptCredential(
                        receiptCredentialPresentation: presentation,
                    ),
                    retryPolicy: .hopefullyRecoverable,
                )
            } catch where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse {
                return .networkError
            } catch where error.httpStatusCode == 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 retrying the job.
                logger.warn("Receipt credential was expired!")

                await db.awaitableWrite { tx in
                    context.attemptState = .unattempted
                    context.upsert(tx: tx)
                }

                return .needsReattempt
            } catch {
                owsFailDebug(
                    "Unexpected error: \(error)",
                    logger: logger,
                )
                return .redemptionUnsuccessful
            }

            switch response.responseStatusCode {
            case 204:
                logger.info("Receipt credential redeemed successfully.")
                return .success

            default:
                owsFailDebug(
                    "Unexpected response status code: \(response.responseStatusCode)",
                    logger: logger,
                )
                return .redemptionUnsuccessful
            }
        }
    }
}

// MARK: -

private extension TSRequest {
    static func backupRedeemReceiptCredential(
        receiptCredentialPresentation: ReceiptCredentialPresentation,
    ) -> TSRequest {
        return TSRequest(
            url: URL(string: "v1/archives/redeem-receipt")!,
            method: "POST",
            parameters: [
                "receiptCredentialPresentation": receiptCredentialPresentation
                    .serialize().base64EncodedString(),
            ],
        )
    }
}