Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -4108,7 +4108,6 @@
354235D624C11160008C84EE /* Purchasing */ = {
isa = PBXGroup;
children = (
4D2A00672CD1EED2008318CA /* PurchaseParamsTests.swift */,
57E0474B27729A1E0082FE91 /* __Snapshots__ */,
2D1015DF275A67560086173F /* StoreKitAbstractions */,
5766AABB283E809D00FA6091 /* Purchases */,
Expand All @@ -4133,6 +4132,7 @@
57554C61282ABFD9009A7E58 /* StoreTests.swift */,
57554C83282AC273009A7E58 /* PeriodTypeTests.swift */,
57554C87282AC293009A7E58 /* PurchaseOwnershipTypeTests.swift */,
4D2A00672CD1EED2008318CA /* PurchaseParamsTests.swift */,
57BA943028330ACA00CD5FC5 /* ConfigurationTests.swift */,
57BB071528D282A4007F5DF0 /* CachingProductsManagerTests.swift */,
57E9CF10290B2ADC00EE12D1 /* CachingTrialOrIntroPriceEligibilityCheckerTests.swift */,
Expand Down
2 changes: 1 addition & 1 deletion Sources/Attribution/AttributionNetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import Foundation
/**
Enum of supported attribution networks
*/
@objc(RCAttributionNetwork) public enum AttributionNetwork: Int {
@objc(RCAttributionNetwork) public enum AttributionNetwork: Int, Sendable {

/**
Apple's search ads
Expand Down
10 changes: 9 additions & 1 deletion Sources/Misc/SystemInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,15 @@ class SystemInfo {
}

var storefront: StorefrontType? {
return self.storefrontProvider.currentStorefront
get async {
return await self.storefrontProvider.currentStorefront
}
}

/// - Important: This is a synchronous API that uses StoreKit 1, and may block the current thread.
/// The preferred way to access the current storefront is via `storefront`.
var syncStorefront: StorefrontType? {
return self.storefrontProvider.syncStorefront
}

static var frameworkVersion: String {
Expand Down
20 changes: 19 additions & 1 deletion Sources/Networking/HTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class HTTPClient {
"X-Is-Debug-Build": "\(self.systemInfo.isDebugBuild)"
]

if let storefront = self.systemInfo.storefront {
if let storefront = self.getStorefrontSynchronously() {
headers["X-Storefront"] = storefront.countryCode
}

Expand All @@ -160,6 +160,24 @@ class HTTPClient {
return headers
}

/// Returns the current storefront synchronously.
///
/// Because this method is synchronous, it should be used only from a background thread to avoid blocking
/// the main thread.
private func getStorefrontSynchronously() -> StorefrontType? {
let storefront: Atomic<StorefrontType?> = .init(nil)
let semaphore = DispatchSemaphore(value: 0)

Task {
storefront.value = await self.systemInfo.storefront
semaphore.signal()
}

semaphore.wait()

return storefront.value
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to introduce an async call in HTTPClient got more complicated than I expected. Introducing a Task to asynchronous get the Storefront for the defaultHeaders in this call

self.perform(request: .init(httpRequest: request,
authHeaders: self.authHeaders,
defaultHeaders: self.defaultHeaders,
verificationMode: verificationMode ?? self.systemInfo.responseVerificationMode,
internalSettings: self.systemInfo.dangerousSettings.internalSettings,
completionHandler: completionHandler))

cannot be done without breaking the expectation of requests happening serially, as tested by

func testPerformSerialRequestWaitsUntilRequestsAreDoneBeforeStartingNext() {

A deeper migration of HTTPClient to support async/await tasks would be needed, I think.

For now, I just went with this workaround to keep the storefront getter synchronous by using a Semaphore. Note that while this would block the current thread, this is OK as long as this blocking never happens in the main thread, which is true since all network operations are added to a background queue. See

let config = BackendConfiguration(httpClient: httpClient,
operationDispatcher: operationDispatcher,
operationQueue: QueueProvider.createBackendQueue(),
diagnosticsQueue: QueueProvider.createDiagnosticsQueue(),
systemInfo: systemInfo,
offlineCustomerInfoCreator: offlineCustomerInfoCreator,
dateProvider: dateProvider)

The (only) advantage here is that we are now using SK2's storefront when available.

The alternative would be keep using the existing (but deprecated since iOS 26) SK1's storefront getter which is synchronous but also blocking as per its documentation. The advantage is that we wouldn't need this workaround.

@RevenueCat/coresdk please, let me know what you think!


}

extension HTTPClient {
Expand Down
20 changes: 11 additions & 9 deletions Sources/Purchasing/ProductsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,17 @@ private extension ProductsManager {
?? error?.localizedDescription
let errorCode = error?.errorCode
let storeKitErrorDescription = StoreKitErrorUtils.extractStoreKitErrorDescription(from: error)
diagnosticsTracker.trackProductsRequest(wasSuccessful: error == nil,
storeKitVersion: storeKitVersion,
errorMessage: errorMessage,
errorCode: errorCode,
storeKitErrorDescription: storeKitErrorDescription,
storefront: self.systemInfo.storefront?.countryCode,
requestedProductIds: requestedProductIds,
notFoundProductIds: notFoundProductIds,
responseTime: responseTime)
Task {
diagnosticsTracker.trackProductsRequest(wasSuccessful: error == nil,
storeKitVersion: storeKitVersion,
errorMessage: errorMessage,
errorCode: errorCode,
storeKitErrorDescription: storeKitErrorDescription,
storefront: await self.systemInfo.storefront?.countryCode,
requestedProductIds: requestedProductIds,
notFoundProductIds: notFoundProductIds,
responseTime: responseTime)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
}

@objc public var storeFrontCountryCode: String? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we deprecate this property and provide an async/callback API?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, but wasn't really sure.

It can be done in a separate PR, with these considerations:

  • There's one downside: the new API wouldn't be ObjC-compatible. We could create a ObjC-compatible method with a completion handler for it if we think it's needed.
  • I can't think of a good name for it, as the most straightforward one is taken. We could "fix" the existing name by removing the capital case "F", having the new property be storefrontCountryCode, but I think it's too similar to the one we'd be deprecating.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a good point... I think this is something we might want to consider breaking in the next major. Should probably take note in Linear so we don't forget for the next major, whenever that is.

systemInfo.storefront?.countryCode
systemInfo.syncStorefront?.countryCode
}

private let attributionFetcher: AttributionFetcher
Expand Down
35 changes: 20 additions & 15 deletions Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1152,17 +1152,19 @@ private extension PurchasesOrchestrator {
?? error?.localizedDescription
let errorCode = error?.code
let storeKitErrorDescription = StoreKitErrorUtils.extractStoreKitErrorDescription(from: error)
diagnosticsTracker.trackPurchaseAttempt(wasSuccessful: successful,
storeKitVersion: storeKitVersion,
errorMessage: errorMessage,
errorCode: errorCode,
storeKitErrorDescription: storeKitErrorDescription,
storefront: self.systemInfo.storefront?.countryCode,
productId: productId,
promotionalOfferId: promotionalOfferId,
winBackOfferApplied: winBackOfferApplied,
purchaseResult: purchaseResult,
responseTime: responseTime)
Task {
diagnosticsTracker.trackPurchaseAttempt(wasSuccessful: successful,
storeKitVersion: storeKitVersion,
errorMessage: errorMessage,
errorCode: errorCode,
storeKitErrorDescription: storeKitErrorDescription,
storefront: await self.systemInfo.storefront?.countryCode,
productId: productId,
promotionalOfferId: promotionalOfferId,
winBackOfferApplied: winBackOfferApplied,
purchaseResult: purchaseResult,
responseTime: responseTime)
}
}
}

Expand Down Expand Up @@ -1861,11 +1863,14 @@ private extension PurchasesOrchestrator {
completion: @escaping (ProductRequestData?) -> Void
) {
self.productsManager.products(withIdentifiers: [productIdentifier]) { products in
let result = products.value?.first.map {
ProductRequestData(with: $0, storefront: self.systemInfo.storefront)
}
Task {
let storefront = await self.systemInfo.storefront
let result = products.value?.first.map {
ProductRequestData(with: $0, storefront: storefront)
}

completion(result)
completion(result)
}
}
}

Expand Down
8 changes: 6 additions & 2 deletions Sources/Purchasing/StoreKitAbstractions/Storefront.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,23 @@ extension Storefront: Sendable {}
public extension Storefront {

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *)
private static var currentStorefrontType: StorefrontType? {
internal static var currentStorefrontType: StorefrontType? {
get async {
if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) {
let sk2Storefront = await StoreKit.Storefront.current
return sk2Storefront.map(SK2Storefront.init)
} else {
return Self.sk1CurrentStorefrontType
return await Task.detached {
return Self.sk1CurrentStorefrontType
}.value
}
}
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *)
internal static var sk1CurrentStorefrontType: StorefrontType? {
// As stated in the documentation, SKPaymentQueue's storefront is a synchronous API that may take significant
// time to return. Please, use `currentStorefrontType` instead, if possible.
return SKPaymentQueue.default().storefront.map(SK1Storefront.init)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ import Foundation
/// A type that can determine the current `Storefront`.
protocol StorefrontProviderType {

var currentStorefront: StorefrontType? { get }
/// The current `StorefrontType`, if available.
///
/// In iOS 15+, it uses StoreKit 2's async API to retrieve the current storefront.
///
/// This is the preferred way to access the current storefront, as it prevents blocking the current thread.
var currentStorefront: StorefrontType? { get async }

/// The current `StorefrontType`, if available.
///
/// - Important: This is a synchronous API that uses StoreKit 1, and may block the current thread.
/// The preferred way to access the current storefront is via `currentStorefront`.
var syncStorefront: StorefrontType? { get }

}

Expand All @@ -25,11 +36,12 @@ protocol StorefrontProviderType {
final class DefaultStorefrontProvider: StorefrontProviderType {

var currentStorefront: StorefrontType? {
if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) {
return Storefront.sk1CurrentStorefrontType
} else {
return nil
get async {
return await Storefront.currentStorefrontType
}
}

var syncStorefront: StorefrontType? {
return Storefront.sk1CurrentStorefrontType
}
}
49 changes: 24 additions & 25 deletions Sources/Purchasing/TrialOrIntroPriceEligibilityChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,24 +307,20 @@ private extension TrialOrIntroPriceEligibilityChecker {
result: [String: IntroEligibility],
error: Error?,
storeKitVersion: StoreKitVersion) {
guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *),
let diagnosticsTracker = self.diagnosticsTracker else {
guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), let diagnosticsTracker else {
return
}

var unknownCount, ineligibleCount, eligibleCount, noIntroOfferCount: Int?
if !result.isEmpty {
(unknownCount, ineligibleCount, eligibleCount, noIntroOfferCount) = result.reduce(into: (0, 0, 0, 0)) {
switch $1.value.status {
case .unknown:
$0.0 += 1
case .ineligible:
$0.1 += 1
case .eligible:
$0.2 += 1
case .noIntroOfferExists:
$0.3 += 1
}
let (unknownCount, ineligibleCount, eligibleCount, noIntroOfferCount) = result.reduce(into: (0, 0, 0, 0)) {
switch $1.value.status {
case .unknown:
$0.0 += 1
case .ineligible:
$0.1 += 1
case .eligible:
$0.2 += 1
case .noIntroOfferExists:
$0.3 += 1
}
}

Expand All @@ -348,16 +344,19 @@ private extension TrialOrIntroPriceEligibilityChecker {

let responseTime = self.dateProvider.now().timeIntervalSince(startTime)

diagnosticsTracker.trackAppleTrialOrIntroEligibilityRequest(storeKitVersion: storeKitVersion,
requestedProductIds: requestedProductIds,
eligibilityUnknownCount: unknownCount,
eligibilityIneligibleCount: ineligibleCount,
eligibilityEligibleCount: eligibleCount,
eligibilityNoIntroOfferCount: noIntroOfferCount,
errorMessage: errorMessage,
errorCode: errorCode,
storefront: self.systemInfo.storefront?.countryCode,
responseTime: responseTime)
Task {
let storefront = await self.systemInfo.storefront?.countryCode
diagnosticsTracker.trackAppleTrialOrIntroEligibilityRequest(storeKitVersion: storeKitVersion,
requestedProductIds: requestedProductIds,
eligibilityUnknownCount: unknownCount,
eligibilityIneligibleCount: ineligibleCount,
eligibilityEligibleCount: eligibleCount,
eligibilityNoIntroOfferCount: noIntroOfferCount,
errorMessage: errorMessage,
errorCode: errorCode,
storefront: storefront,
responseTime: responseTime)
}
}

}
4 changes: 2 additions & 2 deletions Tests/StoreKitUnitTests/StorefrontTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ class StorefrontTests: StoreKitConfigTestCase {
let systemInfo = SystemInfo(platformInfo: nil,
finishTransactions: false,
preferredLocalesProvider: .mock())
let storefront = try XCTUnwrap(systemInfo.storefront)
let storefront = await systemInfo.storefront

expect(storefront.countryCode) == expected
expect(storefront?.countryCode) == expected
}

}
4 changes: 3 additions & 1 deletion Tests/UnitTests/Mocks/MockSystemInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ class MockSystemInfo: SystemInfo {
}

override var storefront: StorefrontType? {
return self.stubbedStorefront
get async {
return self.stubbedStorefront
}
}
}

Expand Down
10 changes: 5 additions & 5 deletions Tests/UnitTests/Networking/Backend/BaseBackendTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,11 @@ final class MockStorefrontProvider: StorefrontProviderType {

var currentStorefront: StorefrontType? {
// Simulate `DefaultStorefrontProvider` availability.
if #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, macCatalyst 13.1, *) {
return MockStorefront(countryCode: "USA")
} else {
return nil
}
return MockStorefront(countryCode: "USA")
}

var syncStorefront: StorefrontType? {
return currentStorefront
}

}