Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
920d3f0
Use AppTransaction to get environment
ajpallares Jan 29, 2026
60bb269
Renames
ajpallares Jan 29, 2026
8795ce8
Merge branch 'main' into use-app-transaction-for-environment
ajpallares Jan 29, 2026
226a4a4
Merge branch 'main' into use-app-transaction-for-environment
ajpallares Jan 29, 2026
64e2a90
Simplification
ajpallares Jan 29, 2026
3fd3f9b
More tests
ajpallares Jan 29, 2026
1f3b71c
Add tests on mac
ajpallares Jan 29, 2026
8acebda
Add more tests for behavior before prefetch finishes
ajpallares Jan 29, 2026
800a92b
Fix public `Purchases.shared.isSandbox` to not cache the information …
ajpallares Jan 29, 2026
ae6ad14
Merge branch 'main' into use-app-transaction-for-environment
ajpallares Jan 29, 2026
3b89a94
Merge branch 'main' into use-app-transaction-for-environment
ajpallares Jan 29, 2026
a7c0778
Try this
ajpallares Jan 29, 2026
007f227
TEMP: test this (temporary disable it)
ajpallares Jan 30, 2026
d859574
temp: run-installation-tests CI parameter
ajpallares Jan 30, 2026
ca24312
Remove duplication
ajpallares Jan 30, 2026
349656c
TEMP: disable task
ajpallares Jan 30, 2026
16fb9a3
Bring back `Atomic` for `Bundle`
ajpallares Jan 30, 2026
7d01ebc
TEMP: Test directly SK api + no Atomic
ajpallares Jan 30, 2026
0cce26b
TEMP: Test this (no SK)
ajpallares Jan 30, 2026
e358952
TEMP fix
ajpallares Jan 30, 2026
4034cef
try with long wait time
ajpallares Jan 30, 2026
641d686
Force try to make sure
ajpallares Jan 30, 2026
1c73095
Now with AppTransaction.shared
ajpallares Jan 30, 2026
159d81b
Atomic `cachedAppTransactionEnvironment`
ajpallares Jan 30, 2026
3816411
Try background
ajpallares Jan 30, 2026
c3dfa67
Try long timeout
ajpallares Jan 30, 2026
5f233cf
Fix
ajpallares Jan 30, 2026
774b318
Revert "Try long timeout"
ajpallares Jan 30, 2026
3e87541
TEMP: only spm installation tests
ajpallares Jan 30, 2026
18dc976
actual implementation
ajpallares Jan 30, 2026
55f1c5f
test no background in Task.detached
ajpallares Jan 30, 2026
827d210
Check this
ajpallares Jan 30, 2026
33c1f60
Check this now
ajpallares Jan 30, 2026
5b3a639
This seems to be working
ajpallares Jan 30, 2026
d98e118
Reduce flakiness
ajpallares Jan 30, 2026
1ceb404
Fix AppTransaction Environment Detection Test Flakiness on MacOS (#6173)
fire-at-will Jan 30, 2026
dc6ee86
Merge branch 'main' into use-app-transaction-for-environment
fire-at-will Jan 30, 2026
4d62a16
Make `appTransactionEnvironment` iOS 16+
ajpallares Jan 30, 2026
3f8a896
Fix iOS 14 + 15 Tests (#6174)
fire-at-will Jan 30, 2026
05f8b8d
Make Installation tests async await (#6178)
fire-at-will Jan 30, 2026
e553474
Inject `SandboxEnvironmentDetector` for integration test (#6177)
ajpallares Jan 30, 2026
85de6e1
use SK1 receipt environment value for isSandbox
fire-at-will Feb 11, 2026
da0e545
test fixes
fire-at-will Feb 11, 2026
66b9037
Merge branch 'main' into use-sk1-receipt-for-environment
fire-at-will Feb 11, 2026
12d0ea8
fix tests
fire-at-will Feb 11, 2026
f585e79
Merge branch 'use-sk1-receipt-for-environment' of https://github.com/…
fire-at-will Feb 11, 2026
741c1a3
try this
fire-at-will Feb 11, 2026
aa82465
Revert "try this"
fire-at-will Feb 11, 2026
7ff2882
dont prefetch for test store
fire-at-will Feb 11, 2026
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
1 change: 0 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2000,4 +2000,3 @@ workflows:
- emerge_binary_size_analysis:
context:
- slack-secrets

6 changes: 3 additions & 3 deletions Sources/Identity/CustomerInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ public typealias ProductIdentifier = String
/// Initializes a `CustomerInfo` with the underlying data in the current schema version
convenience init(response: CustomerInfoResponse,
entitlementVerification: VerificationResult,
sandboxEnvironmentDetector: SandboxEnvironmentDetector,
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType,
httpResponseOriginalSource: HTTPResponseOriginalSource?) {
let originalSource = OriginalSource(entitlementVerification: entitlementVerification,
httpResponseOriginalSource: httpResponseOriginalSource)
Expand Down Expand Up @@ -248,14 +248,14 @@ public typealias ProductIdentifier = String

/// Initializes a `CustomerInfo` creating a copy.
convenience init(customerInfo: CustomerInfo,
sandboxEnvironmentDetector: SandboxEnvironmentDetector) {
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType) {
self.init(data: customerInfo.data, sandboxEnvironmentDetector: sandboxEnvironmentDetector)
}

// swiftlint:disable:next function_body_length
fileprivate convenience init(
data: Contents,
sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType = SandboxEnvironmentDetector.default
) {
let response = data.response
let subscriber = response.subscriber
Expand Down
2 changes: 1 addition & 1 deletion Sources/Identity/CustomerInfoManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ extension CustomerInfoManager {
rawData: [:])
let previewCustomerInfo = CustomerInfo(response: previewCustomerInfoResponse,
entitlementVerification: .verified,
sandboxEnvironmentDetector: BundleSandboxEnvironmentDetector.default,
sandboxEnvironmentDetector: SandboxEnvironmentDetector.default,
httpResponseOriginalSource: .mainServer)
return previewCustomerInfo
}
Expand Down
8 changes: 7 additions & 1 deletion Sources/Misc/DangerousSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@ import Foundation
let forceSignatureFailures: Bool
let disableHeaderSignatureVerification: Bool
let testReceiptIdentifier: String?
let testSandboxEnvironmentDetector: SandboxEnvironmentDetectorType?

init(
enableReceiptFetchRetry: Bool = false,
forceServerErrorStrategy: ForceServerErrorStrategy? = nil,
forceSignatureFailures: Bool = false,
disableHeaderSignatureVerification: Bool = false,
testReceiptIdentifier: String? = nil
testReceiptIdentifier: String? = nil,
testSandboxEnvironmentDetector: SandboxEnvironmentDetectorType? = nil
) {
self.enableReceiptFetchRetry = enableReceiptFetchRetry
self.forceServerErrorStrategy = forceServerErrorStrategy
self.forceSignatureFailures = forceSignatureFailures
self.disableHeaderSignatureVerification = disableHeaderSignatureVerification
self.testReceiptIdentifier = testReceiptIdentifier
self.testSandboxEnvironmentDetector = testSandboxEnvironmentDetector
}
#else
init(
Expand Down Expand Up @@ -156,6 +159,9 @@ internal protocol InternalDangerousSettingsType: Sendable {
/// This allows the backend to disambiguate between receipts created across separate test invocations.
var testReceiptIdentifier: String? { get }

/// Allows injecting a custom `SandboxEnvironmentDetector` for integration tests only.
var testSandboxEnvironmentDetector: SandboxEnvironmentDetectorType? { get }

#endif

}
Expand Down
140 changes: 126 additions & 14 deletions Sources/Misc/SandboxEnvironmentDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,158 @@
// Created by Nacho Soto on 6/2/22.

import Foundation
import StoreKit

/// A type that can determine if the current environment is sandbox.
protocol SandboxEnvironmentDetector: Sendable {
protocol SandboxEnvironmentDetectorType: Sendable {

var isSandbox: Bool { get }

}

/// ``SandboxEnvironmentDetector`` that uses a `Bundle` to detect the environment
final class BundleSandboxEnvironmentDetector: SandboxEnvironmentDetector {
/// Object used to detect the sandbox environment
///
/// This attempts to prefetch and parse the local receipt environment.
/// While prefetching (or in case of failure), it falls back to existing non-prefetched checks.
final class SandboxEnvironmentDetector: SandboxEnvironmentDetectorType {

private let bundle: Atomic<Bundle>
private let isRunningInSimulator: Bool
private let receiptFetcher: LocalReceiptFetcherType
private let macAppStoreDetector: MacAppStoreDetector?

private let operationDispatcher: OperationDispatcher

/// Cached `isSandbox` computed from a prefetched and parsed local receipt.
/// This is populated asynchronously and used when available.
private let cachedIsSandboxFromPrefetchedReceipt: Atomic<Bool?> = .init(nil)

/// Cached result of receipt path-based sandbox detection.
private let cachedIsSandboxBasedOnReceiptPath: Atomic<Bool?> = .init(nil)

/// Creates a new detector that prefetches the local receipt environment.
///
/// - Parameters:
/// - bundle: The bundle to use for receipt URL detection.
/// - isRunningInSimulator: Whether the app is running in a simulator.
/// - receiptFetcher: The receipt fetcher for macOS receipt parsing.
/// - macAppStoreDetector: Detector for macOS App Store detection.
/// - requestFetcher: The request fetcher used to refresh the StoreKit 1 receipt.
/// - shouldPrefetchReceiptEnvironment: Whether to prefetch receipt environment asynchronously.
/// - operationDispatcher: The dispatcher to use when dispatching work
init(
bundle: Bundle = .main,
isRunningInSimulator: Bool = SystemInfo.isRunningInSimulator,
receiptFetcher: LocalReceiptFetcherType = LocalReceiptFetcher(),
macAppStoreDetector: MacAppStoreDetector? = nil
macAppStoreDetector: MacAppStoreDetector? = nil,
requestFetcher: StoreKitRequestFetcher,
shouldPrefetchReceiptEnvironment: Bool = true,
operationDispatcher: OperationDispatcher = OperationDispatcher.default
) {
self.bundle = .init(bundle)
self.isRunningInSimulator = isRunningInSimulator
self.receiptFetcher = receiptFetcher
self.macAppStoreDetector = macAppStoreDetector
self.operationDispatcher = operationDispatcher

if shouldPrefetchReceiptEnvironment {
self.prefetchReceiptEnvironment(requestFetcher: requestFetcher)
}
}

private init() {
self.bundle = .init(Bundle.main)
self.isRunningInSimulator = SystemInfo.isRunningInSimulator
self.receiptFetcher = LocalReceiptFetcher()
self.macAppStoreDetector = nil
self.operationDispatcher = OperationDispatcher.default
}

var isSandbox: Bool {
guard !self.isRunningInSimulator else {
return true
}

// Prefer prefetched receipt environment when available.
if let cachedIsSandbox = self.cachedIsSandboxFromPrefetchedReceipt.value {
return cachedIsSandbox
}

// Fallback to the legacy path-based detection.
if let cachedIsSandbox = self.cachedIsSandboxBasedOnReceiptPath.value {
return cachedIsSandbox
}

// Cache the result to avoid recomputing it
let isSandboxBasedOnReceiptPath = self.getIsSandboxBasedOnReceiptPath()
self.cachedIsSandboxBasedOnReceiptPath.value = isSandboxBasedOnReceiptPath
return isSandboxBasedOnReceiptPath
}

// MARK: - Default Instance

/// The default sandbox environment detector.
///
/// By default, this uses a simplified `SandboxEnvironmentDetector` that only relies on
/// the legacy receipt path detection. When the SDK is initialized via `Purchases.configure()`,
/// this is replaced with a full `SandboxEnvironmentDetector` that includes
/// prefetched receipt environment detection.
private static let _default: Atomic<SandboxEnvironmentDetectorType> = .init(SandboxEnvironmentDetector())

static var `default`: SandboxEnvironmentDetectorType {
get { _default.value }
set { _default.value = newValue }
}

}

// MARK: - Prefetched Receipt Environment Detection

private extension SandboxEnvironmentDetector {

func prefetchReceiptEnvironment(requestFetcher: StoreKitRequestFetcher) {
// If there's already a receipt on disk, use it and avoid refreshing.
guard !self.hasLocalReceiptOnDisk else {
self.cacheIsSandboxFromLocalReceiptEnvironment()
return
}

requestFetcher.fetchReceiptData {
self.cacheIsSandboxFromLocalReceiptEnvironment()
}
}

var hasLocalReceiptOnDisk: Bool {
guard let receiptURL = self.bundle.value.appStoreReceiptURL else {
return false
}

return FileManager.default.fileExists(atPath: receiptURL.path)
}

func cacheIsSandboxFromLocalReceiptEnvironment() {
operationDispatcher.dispatchOnWorkerThread {
// Parsing the receipt must be performed off of the main thread
guard let environment = try? self.receiptFetcher.fetchAndParseLocalReceipt().environment else {
return
}

guard environment != .unknown else {
return
}

self.operationDispatcher.dispatchOnMainActor {
self.cachedIsSandboxFromPrefetchedReceipt.value = environment != .production
}
}
}

}

// MARK: - Legacy Receipt Path-Based Detection

private extension SandboxEnvironmentDetector {

func getIsSandboxBasedOnReceiptPath() -> Bool {
guard let path = self.bundle.value.appStoreReceiptURL?.path else {
return false
}
Expand All @@ -63,22 +182,15 @@ final class BundleSandboxEnvironmentDetector: SandboxEnvironmentDetector {
#endif
}

#if DEBUG
// Mutable in tests so it can be overriden
static var `default`: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector()
#else
static let `default`: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector()
#endif

}

extension BundleSandboxEnvironmentDetector: Sendable {}
extension SandboxEnvironmentDetector: Sendable {}

// MARK: -

#if os(macOS) || targetEnvironment(macCatalyst)

private extension BundleSandboxEnvironmentDetector {
private extension SandboxEnvironmentDetector {

var isProductionReceipt: Bool? {
do {
Expand Down
12 changes: 4 additions & 8 deletions Sources/Misc/SystemInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class SystemInfo {

var observerMode: Bool { return !self.finishTransactions }

private let sandboxEnvironmentDetector: SandboxEnvironmentDetector
private let sandboxEnvironmentDetector: SandboxEnvironmentDetectorType
private let storefrontProvider: StorefrontProviderType
private let _finishTransactions: Atomic<Bool>
private let _isAppBackgroundedState: Atomic<Bool>
Expand All @@ -81,12 +81,8 @@ class SystemInfo {
static let defaultApiBaseURL = URL(string: "https://api.revenuecat.com")!
private static let _apiBaseURL: Atomic<URL> = .init(defaultApiBaseURL)

private lazy var _isSandbox: Bool = {
return self.sandboxEnvironmentDetector.isSandbox
}()

var isSandbox: Bool {
return self._isSandbox
return self.sandboxEnvironmentDetector.isSandbox
}

var isDebugBuild: Bool {
Expand Down Expand Up @@ -188,7 +184,7 @@ class SystemInfo {
finishTransactions: Bool,
operationDispatcher: OperationDispatcher = .default,
bundle: Bundle = .main,
sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default,
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType = SandboxEnvironmentDetector.default,
storefrontProvider: StorefrontProviderType = DefaultStorefrontProvider(),
storeKitVersion: StoreKitVersion = .default,
apiKeyValidationResult: Configuration.APIKeyValidationResult = .validApplePlatform,
Expand Down Expand Up @@ -314,7 +310,7 @@ extension SystemInfo {
}
#endif

extension SystemInfo: SandboxEnvironmentDetector {}
extension SystemInfo: SandboxEnvironmentDetectorType {}

// @unchecked because:
// - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ extension CustomerInfo {
from purchasedSK2Products: [PurchasedSK2Product],
mapping: ProductEntitlementMapping,
userID: String,
sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType = SandboxEnvironmentDetector.default
) {
let subscriber = CustomerInfoResponse.Subscriber(
originalAppUserId: userID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ class OfflineCustomerInfoCreator {
private let creator: Creator

static func createPurchasedProductsFetcherIfAvailable(
diagnosticsTracker: DiagnosticsTrackerType?
diagnosticsTracker: DiagnosticsTrackerType?,
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType
) -> PurchasedProductsFetcherType? {
if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) {
return PurchasedProductsFetcher(
storeKit2TransactionFetcher: StoreKit2TransactionFetcher(diagnosticsTracker: diagnosticsTracker)
storeKit2TransactionFetcher: StoreKit2TransactionFetcher(diagnosticsTracker: diagnosticsTracker),
sandboxDetector: sandboxEnvironmentDetector
)
} else {
return nil
Expand Down
4 changes: 2 additions & 2 deletions Sources/OfflineEntitlements/PurchasedProductsFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ final class PurchasedProductsFetcher: PurchasedProductsFetcherType {
private typealias Transactions = [StoreKit.VerificationResult<StoreKit.Transaction>]

private let transactionFetcher: StoreKit2TransactionFetcherType
private let sandboxDetector: SandboxEnvironmentDetector
private let sandboxDetector: SandboxEnvironmentDetectorType
private let cache: InMemoryCachedObject<Transactions>

init(
storeKit2TransactionFetcher: StoreKit2TransactionFetcherType,
sandboxDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector()
sandboxDetector: SandboxEnvironmentDetectorType = SandboxEnvironmentDetector.default
) {
self.sandboxDetector = sandboxDetector
self.transactionFetcher = storeKit2TransactionFetcher
Expand Down
2 changes: 1 addition & 1 deletion Sources/OfflineEntitlements/PurchasedSK2Product.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ extension PurchasedSK2Product {

init(
from transaction: StoreKit.Transaction,
sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType = SandboxEnvironmentDetector.default
) {
let expiration = transaction.expirationDate

Expand Down
6 changes: 3 additions & 3 deletions Sources/Purchasing/EntitlementInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ extension PeriodType: DefaultValueProvider {
identifier: String,
entitlement: CustomerInfoResponse.Entitlement,
subscription: CustomerInfoResponse.Subscription,
sandboxEnvironmentDetector: SandboxEnvironmentDetector,
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType,
verification: VerificationResult,
requestDate: Date
) {
Expand Down Expand Up @@ -313,13 +313,13 @@ extension PeriodType: DefaultValueProvider {
verification: verification
)
self.rawData = [:]
self.sandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default
self.sandboxEnvironmentDetector = SandboxEnvironmentDetector.default
}

// MARK: -

private let contents: Contents
private let sandboxEnvironmentDetector: SandboxEnvironmentDetector
private let sandboxEnvironmentDetector: SandboxEnvironmentDetectorType

}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Purchasing/EntitlementInfos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ extension EntitlementInfos {
entitlements: [String: CustomerInfoResponse.Entitlement],
purchases: [String: CustomerInfoResponse.Subscription],
requestDate: Date,
sandboxEnvironmentDetector: SandboxEnvironmentDetector = BundleSandboxEnvironmentDetector.default,
sandboxEnvironmentDetector: SandboxEnvironmentDetectorType = SandboxEnvironmentDetector.default,
verification: VerificationResult
) {
let allEntitlements: [String: EntitlementInfo] = .init(
Expand Down
Loading