Files
purchases-ios/Sources/Networking/Backend.swift
T
Pol MiroandRevenueCat Git Bot abad547968 AdMob SSV: add @_spi(Internal) poll endpoint on Purchases (#6641)
* Add AdMob SSV poll endpoint and `pollAdMobSSVStatus` SPI

* Trim verbose AdMob SSV transport comments.

Align AdsAPI and cache-key inline documentation with existing repository comment density while preserving behavior.

* Trim pollAdMobSSVStatus SPI documentation.

Reduce prescriptive guidance in the internal API comment while keeping the key cancellation behavior note.

* [skip ci] Generating new test snapshots (#6648)

* [skip ci] Generating new test snapshots (#6647)

* [skip ci] Generating new test snapshots (#6646)

* [skip ci] Generating new test snapshots (#6645)

* [skip ci] Generating new test snapshots (#6644)

* [skip ci] Generating new test snapshots (#6642)

* [skip ci] Generating new test snapshots (#6643)

* Trigger CI for PR 6641 checks.

Add a non-skip commit so required CircleCI statuses are reported on the current head.

* Validate non-empty client transaction ID for AdMob SSV polling.

Guard against empty client transaction IDs in the SPI and backend operation and add focused backend tests for the new error path.

* [skip ci] Generating new test snapshots (#6652)

* [skip ci] Generating new test snapshots (#6653)

* Trigger CI for PR 6641 checks.

Add a non-skip commit so required CircleCI statuses are reported on the current head.

* Enable signature verification for AdMob SSV status path.

Update HTTP request path tests to cover AdMob SSV signature and nonce behavior.

* fix: address review — move AdMob SPI extension to dedicated file

* fix: address review — fail fast when AdMob status mock is unstubbed

* fix: address review — dedupe client transaction validation in backend operation

* fix: address review — use enum for AdMob SSV poll status

* fix: address review — allow extracted Purchases extensions to access backend

* fix: address review — log unknown AdMob SSV statuses

* fix: address review — add Purchases AdMob SSV mapping tests

* fix: address review — map missing client transaction ID to configuration error

* fix: address review — keep backend private by co-locating AdMob extension

* fix: address review — remove unused raw-value conformance

* fix: address review — assert warning log for unknown AdMob status

* fix: address review — surface unknown AdMob SSV status to adapters

* fix: address review — enable eTag support for AdMob SSV status path

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
2026-04-22 15:55:13 +02:00

283 lines
12 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// Backend.swift
//
// Created by Joshua Liebowitz on 8/2/21.
import Foundation
class Backend {
let identity: IdentityAPI
let offerings: OfferingsAPI
let webBilling: WebBillingAPI
let offlineEntitlements: OfflineEntitlementsAPI
let customer: CustomerAPI
let internalAPI: InternalAPI
let customerCenterConfig: CustomerCenterConfigAPI
let redeemWebPurchaseAPI: RedeemWebPurchaseAPI
let virtualCurrenciesAPI: VirtualCurrenciesAPI
let workflowsAPI: WorkflowsAPI
let adsAPI: AdsAPI
private let config: BackendConfiguration
convenience init(
systemInfo: SystemInfo,
httpClientTimeout: TimeInterval = Configuration.networkTimeoutDefault,
eTagManager: ETagManager,
operationDispatcher: OperationDispatcher,
attributionFetcher: AttributionFetcher,
offlineCustomerInfoCreator: OfflineCustomerInfoCreator?,
diagnosticsTracker: DiagnosticsTrackerType?,
dateProvider: DateProvider = DateProvider()
) {
let httpClient = HTTPClient(systemInfo: systemInfo,
eTagManager: eTagManager,
signing: Signing(apiKey: systemInfo.apiKey, clock: systemInfo.clock),
diagnosticsTracker: diagnosticsTracker,
requestTimeout: httpClientTimeout,
operationDispatcher: OperationDispatcher.default)
let config = BackendConfiguration(httpClient: httpClient,
operationDispatcher: operationDispatcher,
operationQueue: QueueProvider.createBackendQueue(),
diagnosticsQueue: QueueProvider.createDiagnosticsQueue(),
systemInfo: systemInfo,
offlineCustomerInfoCreator: offlineCustomerInfoCreator,
dateProvider: dateProvider)
self.init(backendConfig: config, attributionFetcher: attributionFetcher)
}
convenience init(backendConfig: BackendConfiguration, attributionFetcher: AttributionFetcher) {
let customer = CustomerAPI(backendConfig: backendConfig, attributionFetcher: attributionFetcher)
let identity = IdentityAPI(backendConfig: backendConfig)
let offerings = OfferingsAPI(backendConfig: backendConfig)
let webBilling = WebBillingAPI(backendConfig: backendConfig)
let offlineEntitlements = OfflineEntitlementsAPI(backendConfig: backendConfig)
let internalAPI = InternalAPI(backendConfig: backendConfig)
let customerCenterConfig = CustomerCenterConfigAPI(backendConfig: backendConfig)
let redeemWebPurchaseAPI = RedeemWebPurchaseAPI(backendConfig: backendConfig)
let virtualCurrenciesAPI = VirtualCurrenciesAPI(backendConfig: backendConfig)
let workflowsAPI = WorkflowsAPI(backendConfig: backendConfig)
let adsAPI = AdsAPI(backendConfig: backendConfig)
self.init(backendConfig: backendConfig,
customerAPI: customer,
identityAPI: identity,
offeringsAPI: offerings,
webBillingAPI: webBilling,
offlineEntitlements: offlineEntitlements,
internalAPI: internalAPI,
customerCenterConfig: customerCenterConfig,
redeemWebPurchaseAPI: redeemWebPurchaseAPI,
virtualCurrenciesAPI: virtualCurrenciesAPI,
workflowsAPI: workflowsAPI,
adsAPI: adsAPI)
}
required init(backendConfig: BackendConfiguration,
customerAPI: CustomerAPI,
identityAPI: IdentityAPI,
offeringsAPI: OfferingsAPI,
webBillingAPI: WebBillingAPI,
offlineEntitlements: OfflineEntitlementsAPI,
internalAPI: InternalAPI,
customerCenterConfig: CustomerCenterConfigAPI,
redeemWebPurchaseAPI: RedeemWebPurchaseAPI,
virtualCurrenciesAPI: VirtualCurrenciesAPI,
workflowsAPI: WorkflowsAPI,
adsAPI: AdsAPI) {
self.config = backendConfig
self.customer = customerAPI
self.identity = identityAPI
self.offerings = offeringsAPI
self.webBilling = webBillingAPI
self.offlineEntitlements = offlineEntitlements
self.internalAPI = internalAPI
self.customerCenterConfig = customerCenterConfig
self.redeemWebPurchaseAPI = redeemWebPurchaseAPI
self.virtualCurrenciesAPI = virtualCurrenciesAPI
self.workflowsAPI = workflowsAPI
self.adsAPI = adsAPI
}
func clearHTTPClientCaches() {
self.config.clearCache()
}
func post(attributionData: [String: Any],
network: AttributionNetwork,
appUserID: String,
completion: CustomerAPI.SimpleResponseHandler?) {
self.customer.post(attributionData: attributionData,
network: network,
appUserID: appUserID,
completion: completion)
}
func post(adServicesToken: String,
appUserID: String,
completion: CustomerAPI.SimpleResponseHandler?) {
self.customer.post(adServicesToken: adServicesToken,
appUserID: appUserID,
completion: completion)
}
func isPurchaseAllowedByRestoreBehavior(
appUserID: String,
transactionJWS: String,
isAppBackgrounded: Bool,
completion: @escaping CustomerAPI.IsPurchaseAllowedByRestoreBehaviorResponseHandler
) {
self.customer.isPurchaseAllowedByRestoreBehavior(appUserID: appUserID,
transactionJWS: transactionJWS,
isAppBackgrounded: isAppBackgrounded,
completion: completion)
}
func getCustomerInfo(appUserID: String,
isAppBackgrounded: Bool,
allowComputingOffline: Bool = true,
completion: @escaping CustomerAPI.CustomerInfoResponseHandler) {
self.customer.getCustomerInfo(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
allowComputingOffline: allowComputingOffline,
completion: completion)
}
// swiftlint:disable:next function_parameter_count
func post(receipt: EncodedAppleReceipt,
productData: ProductRequestData?,
transactionData: PurchasedTransactionData,
postReceiptSource: PostReceiptSource,
observerMode: Bool,
// Value at the time of the purchase (which might come from the `LocalTransactionMetadataStore`)
originalPurchaseCompletedBy: PurchasesAreCompletedBy?,
appTransaction: String? = nil,
associatedTransactionId: String? = nil,
sdkOriginated: Bool = false,
appUserID: String,
containsAttributionData: Bool = false,
completion: @escaping CustomerAPI.CustomerInfoResponseHandler) {
self.customer.post(receipt: receipt,
productData: productData,
transactionData: transactionData,
postReceiptSource: postReceiptSource,
observerMode: observerMode,
originalPurchaseCompletedBy: originalPurchaseCompletedBy,
appTransaction: appTransaction,
associatedTransactionId: associatedTransactionId,
sdkOriginated: sdkOriginated,
appUserID: appUserID,
containsAttributionData: containsAttributionData,
completion: completion)
}
func post(subscriberAttributes: SubscriberAttribute.Dictionary,
appUserID: String,
completion: CustomerAPI.SimpleResponseHandler?) {
self.customer.post(subscriberAttributes: subscriberAttributes, appUserID: appUserID, completion: completion)
}
#if DEBUG
/// Checks if the SDK should log the status of the health report to the console.
/// - Parameter appUserID: An `appUserID` that allows the Backend to check for health report availability
/// - Returns: Whether the health report should be reported to the console for the given `appUserID`.
func healthReportAvailabilityRequest(appUserID: String) async throws -> HealthReportAvailability {
try await Async.call { (completion: @escaping (Result<HealthReportAvailability, BackendError>) -> Void) in
self.internalAPI.healthReportAvailabilityRequest(
appUserID: appUserID,
completion: completion
)
}
}
/// Call the `/health_report` endpoint and perform a full validation of the SDK's configuration
/// - Parameter appUserID: An `appUserID` that allows the Backend to fetch offerings
/// - Returns: A report with all validation checks along with their status
func healthReportRequest(appUserID: String) async throws -> HealthReport {
try await Async.call { (completion: @escaping (Result<HealthReport, BackendError>) -> Void) in
self.internalAPI.healthReportRequest(appUserID: appUserID, completion: completion)
}
}
#endif
}
extension Backend {
/// - Throws: `NetworkError`
func healthRequest(signatureVerification: Bool) async throws {
try await Async.call { completion in
self.internalAPI.healthRequest(signatureVerification: signatureVerification) { error in
completion(.init(error))
}
}
}
}
// @unchecked because:
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
extension Backend: @unchecked Sendable {}
// MARK: - Internal
extension Backend {
typealias ResponseHandler<Response> = @Sendable (Swift.Result<Response, BackendError>) -> Void
}
extension Backend {
@objc var signatureVerificationEnabled: Bool {
return self.config.httpClient.signatureVerificationEnabled
}
}
extension Backend {
enum QueueProvider {
static func createBackendQueue() -> OperationQueue {
let operationQueue = OperationQueue()
operationQueue.name = "RC Backend Queue"
operationQueue.maxConcurrentOperationCount = 1
return operationQueue
}
static func createDiagnosticsQueue() -> OperationQueue {
let operationQueue = OperationQueue()
operationQueue.name = "RC Diagnostics Queue"
operationQueue.maxConcurrentOperationCount = 1
operationQueue.qualityOfService = .background
return operationQueue
}
}
}
// MARK: - Testing extensions
extension Backend {
var networkTimeout: TimeInterval {
return self.config.httpClient.timeout
}
var offlineCustomerInfoEnabled: Bool {
return self.config.offlineCustomerInfoCreator != nil
}
}