mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
Chore: Signal purchases-ui on purchases configuration (#7407)
* Send signal from purchases to purchases-ui and retain symbol * make more Generic * update comment * get rid of debug test only surface in the ui service * remove noisy comment * remove purchases.IsConfigured check * symbol stripping test * revert package resolved * Update Dangerfile * Update PurchasesUIServiceTests.swift * Update Sources/Purchasing/Purchases/PurchasesConfiguredNotifier.swift Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com> * Update RevenueCatUI/Purchasing/PurchasesUIService.swift Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com> --------- Co-authored-by: Álvaro Brey <alvaro.brey@revenuecat.com>
This commit is contained in:
1 parent
6d7cd08bcc
commit
687d1185ed
17 files changed
+470
-5
No files matched your search
@@ -755,6 +755,14 @@ jobs:
|
||||
name: SPM RevenueCatUI Release Build (Xcode 16)
|
||||
command: swift build -c release --target RevenueCatUI
|
||||
no_output_timeout: 5m
|
||||
- run:
|
||||
name: PurchasesUIService stripped-symbol integration tests
|
||||
command: bundle exec fastlane test_purchases_ui_service_stripped
|
||||
no_output_timeout: 15m
|
||||
environment:
|
||||
DEVICE: iPhone 16,OS=18.5
|
||||
- store_test_results:
|
||||
path: fastlane/test_output/purchases_ui_service_stripped/tests.xml
|
||||
|
||||
# === RevenueCatUI iOS 18 tests ===
|
||||
- run:
|
||||
|
||||
@@ -44,6 +44,7 @@ custom_rules:
|
||||
- Tests/UnitTests/RulesEngine
|
||||
- Tests/v3LoadShedderIntegration
|
||||
- Tests/v4LoadShedderIntegration
|
||||
- Tests/PurchasesUIServiceIntegrationTests
|
||||
regex: "\\: XCTestCase \\{"
|
||||
name: "XCTestCase Superclass"
|
||||
message: "Test classes must inherit `TestCase` instead."
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1540"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "RevenueCatUI"
|
||||
BuildableName = "RevenueCatUI"
|
||||
BlueprintName = "RevenueCatUI"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "NO"
|
||||
buildForProfiling = "NO"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PurchasesUIServiceIntegrationTests"
|
||||
BuildableName = "PurchasesUIServiceIntegrationTests"
|
||||
BlueprintName = "PurchasesUIServiceIntegrationTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "PurchasesUIServiceIntegrationTests"
|
||||
BuildableName = "PurchasesUIServiceIntegrationTests"
|
||||
BlueprintName = "PurchasesUIServiceIntegrationTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "RevenueCatUI"
|
||||
BuildableName = "RevenueCatUI"
|
||||
BlueprintName = "RevenueCatUI"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Release">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -35,6 +35,7 @@ tuist generate PurchaseTester
|
||||
|
||||
tuist generate UnitTests
|
||||
tuist generate RevenueCatUITests
|
||||
tuist generate PurchasesUIServiceIntegrationTests
|
||||
tuist generate StoreKitUnitTests
|
||||
tuist generate BackendIntegrationTests
|
||||
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@ EXCLUDED_SWIFT_PATH_PREFIXES = [
|
||||
# Tuist-only: these targets exist solely in Projects/PaywallFixtures/Project.swift and are
|
||||
# deliberately absent from RevenueCat.xcodeproj.
|
||||
'Tests/TestingApps/PaywallFixtures/',
|
||||
'Tests/TestingApps/PaywallFixturesUITests/'
|
||||
'Tests/TestingApps/PaywallFixturesUITests/',
|
||||
# SPM / Tuist-only: Release-stripped integration tests are not in RevenueCat.xcodeproj.
|
||||
'Tests/PurchasesUIServiceIntegrationTests/'
|
||||
].freeze
|
||||
|
||||
COMMENT_MARKER = "<!-- purchases-ios-danger -->"
|
||||
|
||||
+13
-1
@@ -143,6 +143,18 @@ let package = Package(
|
||||
.copy("Resources/header.heic"),
|
||||
.copy("Resources/background.heic"),
|
||||
.copy("PaywallsV2/__PreviewResources__")
|
||||
])
|
||||
]),
|
||||
// Isolated from RevenueCatUITests so a Swift reference to PurchasesUIService
|
||||
// cannot keep the ObjC class alive. Run via the RevenueCatUI-Stripped scheme.
|
||||
.testTarget(
|
||||
name: "PurchasesUIServiceIntegrationTests",
|
||||
dependencies: [
|
||||
"RevenueCat",
|
||||
"RevenueCatUI"
|
||||
],
|
||||
linkerSettings: [
|
||||
.unsafeFlags(["-dead_strip"], .when(configuration: .release))
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -141,6 +141,8 @@
|
||||
16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; };
|
||||
16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; };
|
||||
16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; };
|
||||
16E147A22F3B4C010089B609 /* PurchasesConfiguredNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E147A12F3B4C010089B609 /* PurchasesConfiguredNotifier.swift */; };
|
||||
16E147A42F3B4C010089B609 /* PurchasesUIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E147A32F3B4C010089B609 /* PurchasesUIService.swift */; };
|
||||
16E146B22E99FC7E0089B609 /* PurchaseResultComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146B12E99FC7E0089B609 /* PurchaseResultComparator.swift */; };
|
||||
16E2B7F12EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E2B7F02EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift */; };
|
||||
16E4A3C89791426AAD027A49 /* PlatformInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043FB8686FEB4A31B9CF48C8 /* PlatformInfoTests.swift */; };
|
||||
@@ -1758,6 +1760,9 @@
|
||||
16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = "<group>"; };
|
||||
16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = "<group>"; };
|
||||
16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = "<group>"; };
|
||||
16E147A12F3B4C010089B609 /* PurchasesConfiguredNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesConfiguredNotifier.swift; sourceTree = "<group>"; };
|
||||
16E147A32F3B4C010089B609 /* PurchasesUIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesUIService.swift; sourceTree = "<group>"; };
|
||||
16E147A52F3B4C010089B609 /* PurchasesUIServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesUIServiceTests.swift; sourceTree = "<group>"; };
|
||||
16E146B12E99FC7E0089B609 /* PurchaseResultComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseResultComparator.swift; sourceTree = "<group>"; };
|
||||
16E146B42E99FF640089B609 /* MockStoreTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreTransaction.swift; sourceTree = "<group>"; };
|
||||
16E2B7F02EA01DFA00F04A7A /* SynchronizedLargeItemCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizedLargeItemCacheTests.swift; sourceTree = "<group>"; };
|
||||
@@ -5965,6 +5970,7 @@
|
||||
887A5FED2C1D037000E1A461 /* MockPurchases.swift */,
|
||||
887A5FEE2C1D037000E1A461 /* PaywallPurchasesType.swift */,
|
||||
887A5FEF2C1D037000E1A461 /* PurchaseHandler.swift */,
|
||||
16E147A32F3B4C010089B609 /* PurchasesUIService.swift */,
|
||||
887A5FF02C1D037000E1A461 /* PurchaseHandler+TestData.swift */,
|
||||
F598F832810DB534092FBED3 /* WorkflowContext.swift */,
|
||||
9FA3AA2BC4C1B8CE7E9A3B59 /* WorkflowPreview.swift */,
|
||||
@@ -6162,6 +6168,7 @@
|
||||
57F4B2C12F8E10A600BB46C9 /* ExitOfferHelperTests.swift */,
|
||||
16E146B42E99FF640089B609 /* MockStoreTransaction.swift */,
|
||||
887A61372C1D168B00E1A461 /* PurchaseHandlerTests.swift */,
|
||||
16E147A52F3B4C010089B609 /* PurchasesUIServiceTests.swift */,
|
||||
45010544C32C2A21A2F323D7 /* OnWebCheckoutOpenedModifierTests.swift */,
|
||||
4B7C1E3A5D2F41908A6C0D11 /* OnURLOpenedModifierTests.swift */,
|
||||
B10A136C03A9FF34FB805EFA /* ExitOfferPresenterTests.swift */,
|
||||
@@ -6669,6 +6676,7 @@
|
||||
4F8038322A1EA7C300D21039 /* TransactionPoster.swift */,
|
||||
757E73FF2F1700000066DCDC /* TransactionMetadataSyncHelper.swift */,
|
||||
16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */,
|
||||
16E147A12F3B4C010089B609 /* PurchasesConfiguredNotifier.swift */,
|
||||
757E73CC2F0FE2410066DCDC /* LocalTransactionMetadata.swift */,
|
||||
757E73E32F101EAF0066DCDC /* LocalTransactionMetadataStore.swift */,
|
||||
);
|
||||
@@ -7935,6 +7943,7 @@
|
||||
03E37BEA2D30B32200CD9678 /* PaywallTabsComponent.swift in Sources */,
|
||||
FD20467F2CB82F2000166727 /* StoreKit2PurchaseIntentListener.swift in Sources */,
|
||||
16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */,
|
||||
16E147A22F3B4C010089B609 /* PurchasesConfiguredNotifier.swift in Sources */,
|
||||
B34605BD279A6E380031CA74 /* CallbackCacheStatus.swift in Sources */,
|
||||
42F1DF385E3C1F9903A07FBF /* ProductsFetcherSK1.swift in Sources */,
|
||||
805B60C97993B311CEC93EAF /* ProductsFetcherSK2.swift in Sources */,
|
||||
@@ -8517,6 +8526,7 @@
|
||||
887A60BE2C1D037000E1A461 /* PaywallFooterViewController.swift in Sources */,
|
||||
DBD2A168300E81E20019DB7F /* VideoAudioSessionHandler.swift in Sources */,
|
||||
887A608A2C1D037000E1A461 /* PurchaseHandler.swift in Sources */,
|
||||
16E147A42F3B4C010089B609 /* PurchasesUIService.swift in Sources */,
|
||||
2D2AFE8D2C6A834D00D1B0B4 /* TestData.swift in Sources */,
|
||||
03A98D382D2AC63B009BCA61 /* UIConfigProvider.swift in Sources */,
|
||||
887A60C92C1D037000E1A461 /* PurchaseButton.swift in Sources */,
|
||||
|
||||
@@ -93,6 +93,7 @@ public struct CustomerCenterView: View {
|
||||
self._viewModel = .init(wrappedValue: CustomerCenterViewModel(actionWrapper: actionWrapper))
|
||||
self.mode = mode
|
||||
self.navigationOptions = navigationOptions
|
||||
PurchasesUIService.activateIfNeeded()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next missing_docs
|
||||
|
||||
@@ -50,6 +50,8 @@ struct PaywallViewConfiguration {
|
||||
self.introEligibility = introEligibility
|
||||
self.purchaseHandler = purchaseHandler
|
||||
self.promoOfferCache = promoOfferCache
|
||||
|
||||
PurchasesUIService.activateIfNeeded()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ enum Strings {
|
||||
case workflow_package_context_unresolvable(stepId: String)
|
||||
case workflow_fetch_failed_falling_back_to_offerings_paywall(offeringIdentifier: String, error: Error)
|
||||
case restored_paywall_components_for_disabled_remote_config(offeringIdentifier: String)
|
||||
case purchases_did_configure
|
||||
|
||||
}
|
||||
|
||||
@@ -440,6 +441,8 @@ extension Strings: CustomStringConvertible {
|
||||
case let .restored_paywall_components_for_disabled_remote_config(offeringIdentifier):
|
||||
return "Remote config is disabled, so offering '\(offeringIdentifier)' was re-resolved to restore " +
|
||||
"its offerings-provided paywall."
|
||||
case .purchases_did_configure:
|
||||
return "Purchases notified purchases-ui of configuration"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// PurchasesUIService.swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@_spi(Internal) import RevenueCat
|
||||
|
||||
/// Receives a message from `Purchases` after `Purchases.shared` is configured.
|
||||
///
|
||||
/// Looked up by ObjC name so RevenueCat does not need to import this module.
|
||||
///
|
||||
/// Reachable RevenueCatUI entry points call ``activateIfNeeded()`` so this class is
|
||||
/// retained by a real, observable use of runtime state. Linking happens before
|
||||
/// execution, so that call can run after ``Purchases/configure`` and still keep
|
||||
/// the class in the binary. The same path replays if RevenueCatUI loads after
|
||||
/// configuration.
|
||||
///
|
||||
/// The `RevenueCatUI-Stripped` scheme runs `PurchasesUIServiceIntegrationTests`
|
||||
/// in Release with dead-code stripping to catch regressions.
|
||||
@objc(RCPurchasesUIService)
|
||||
final class PurchasesUIService: NSObject, PurchasesPostConfigurationStep {
|
||||
|
||||
private static let lock = NSLock()
|
||||
private static var didConfigure = false
|
||||
|
||||
/// Retains this class from a public RevenueCatUI entry point.
|
||||
///
|
||||
/// Reads runtime state and may mutate locked state, so it cannot be
|
||||
/// optimized away as a no-op keep-alive.
|
||||
static func activateIfNeeded() {
|
||||
Self.purchasesDidConfigure()
|
||||
}
|
||||
|
||||
/// Cheap wake-up. Must stay cheap: start subscriptions here later, do not perform heavy work synchronously here.
|
||||
@objc static func purchasesDidConfigure() {
|
||||
lock.withLock {
|
||||
guard !didConfigure else { return }
|
||||
self.didConfigure = true
|
||||
Logger.debug(Strings.purchases_did_configure)
|
||||
// Next PR: subscribe to publishers
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func resetForTests() {
|
||||
lock.withLock {
|
||||
Self.didConfigure = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
@@ -1003,12 +1003,12 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
|
||||
_ purchases: @autoclosure () -> Purchases,
|
||||
dedupingAgainst configuration: Configuration? = nil
|
||||
) -> Purchases {
|
||||
return self.purchases.modify { currentInstance in
|
||||
let (newInstance, didConfigure): (Purchases, Bool) = self.purchases.modify { currentInstance in
|
||||
if let configuration,
|
||||
let existingInstance = currentInstance,
|
||||
existingInstance.currentConfiguration == configuration {
|
||||
Logger.info(Strings.configure.instance_already_exists_with_same_config)
|
||||
return existingInstance
|
||||
return (existingInstance, false)
|
||||
}
|
||||
|
||||
if currentInstance != nil {
|
||||
@@ -1025,8 +1025,15 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
|
||||
|
||||
let newInstance = purchases()
|
||||
currentInstance = newInstance
|
||||
return newInstance
|
||||
return (newInstance, true)
|
||||
}
|
||||
|
||||
// Outside the singleton lock so RevenueCatUI can safely use ``Purchases/shared``.
|
||||
if didConfigure {
|
||||
PurchasesConfiguredNotifier.notify()
|
||||
}
|
||||
|
||||
return newInstance
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// PurchasesConfiguredNotifier.swift
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// RevenueCatUI implements this so ``Purchases`` can invoke it without importing that module.
|
||||
@_spi(Internal)
|
||||
@objc public protocol PurchasesPostConfigurationStep: NSObjectProtocol {
|
||||
|
||||
/// Called after ``Purchases/shared`` has been set by ``Purchases/configure(withAPIKey:)``.
|
||||
static func purchasesDidConfigure()
|
||||
|
||||
}
|
||||
|
||||
/// Sends a message to listeners when ``Purchases`` is configured.
|
||||
enum PurchasesConfiguredNotifier {
|
||||
|
||||
// OBJC symbols must be linked for them to receive the notification
|
||||
static let serviceClassNames = ["RCPurchasesUIService"]
|
||||
|
||||
static func notify() {
|
||||
for name in serviceClassNames {
|
||||
guard let service = NSClassFromString(name) as? PurchasesPostConfigurationStep.Type else {
|
||||
continue
|
||||
}
|
||||
|
||||
service.purchasesDidConfigure()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
disabled_rules:
|
||||
- missing_docs
|
||||
- force_unwrapping
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// PurchasesUIServiceStrippedTests.swift
|
||||
//
|
||||
|
||||
@_spi(Internal) import RevenueCat
|
||||
import RevenueCatUI
|
||||
import XCTest
|
||||
|
||||
/// Verifies `RCPurchasesUIService` survives Release dead-stripping / symbol stripping.
|
||||
///
|
||||
/// Run with the `RevenueCatUI-Stripped` scheme (Release). This target must not
|
||||
/// mention `PurchasesUIService` by its Swift name: a Swift reference would keep
|
||||
/// the class even if the ObjC keep-alive path failed, hiding the production bug.
|
||||
///
|
||||
/// `Purchases` looks the class up with `NSClassFromString("RCPurchasesUIService")`.
|
||||
/// SPM links RevenueCatUI statically, so the class is only in the binary when a
|
||||
/// public RevenueCatUI entry point is reachable.
|
||||
///
|
||||
/// Release modules are built without `-enable-testing`, so this file uses public
|
||||
/// / SPI APIs only — no `@testable import`.
|
||||
final class PurchasesUIServiceStrippedTests: XCTestCase {
|
||||
|
||||
func testServiceSurvivesSymbolStrippingAndIsDiscoverable() throws {
|
||||
try skipIfNoPublicKeepAlive()
|
||||
|
||||
self.retainPublicRevenueCatUIEntryPoints()
|
||||
|
||||
// Must match `PurchasesConfiguredNotifier.serviceClassNames`.
|
||||
let className = "RCPurchasesUIService"
|
||||
let service = NSClassFromString(className)
|
||||
|
||||
XCTAssertNotNil(
|
||||
service,
|
||||
"\(className) was stripped from the binary. Purchases.configure cannot notify RevenueCatUI."
|
||||
)
|
||||
XCTAssertNotNil(
|
||||
service as? PurchasesPostConfigurationStep.Type,
|
||||
"\(className) is present but does not conform to PurchasesPostConfigurationStep."
|
||||
)
|
||||
|
||||
_ = Purchases.configure(withAPIKey: "test_purchases_ui_service_stripped")
|
||||
|
||||
XCTAssertNotNil(
|
||||
NSClassFromString(className) as? PurchasesPostConfigurationStep.Type,
|
||||
"\(className) must still be discoverable after Purchases.configure."
|
||||
)
|
||||
}
|
||||
|
||||
/// Touches the same public types an app would. That keeps `RCPurchasesUIService`
|
||||
/// through `activateIfNeeded()` without naming the class in this test target.
|
||||
private func retainPublicRevenueCatUIEntryPoints() {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#else
|
||||
if #available(iOS 15.0, macOS 12.0, watchOS 8.0, *) {
|
||||
_ = PaywallView()
|
||||
#if os(iOS)
|
||||
_ = CustomerCenterView()
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func skipIfNoPublicKeepAlive() throws {
|
||||
#if os(tvOS)
|
||||
throw XCTSkip("RevenueCatUI has no public keep-alive for RCPurchasesUIService on tvOS.")
|
||||
#else
|
||||
if #available(iOS 15.0, macOS 12.0, watchOS 8.0, *) {
|
||||
return
|
||||
}
|
||||
throw XCTSkip("PaywallView requires iOS 15 / macOS 12 / watchOS 8.")
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// PurchasesUIServiceTests.swift
|
||||
//
|
||||
|
||||
import Nimble
|
||||
@_spi(Internal) @testable import RevenueCat
|
||||
@testable import RevenueCatUI
|
||||
import XCTest
|
||||
|
||||
class PurchasesUIServiceTests: TestCase {
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
Purchases.clearSingleton()
|
||||
PurchasesUIService.resetForTests()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
Purchases.clearSingleton()
|
||||
PurchasesUIService.resetForTests()
|
||||
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testServiceConformsAndIsDiscoverable() {
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
let service: AnyClass? = NSClassFromString(PurchasesConfiguredNotifier.serviceClassNames.first!)
|
||||
|
||||
expect(service).toNot(beNil())
|
||||
expect(service as? PurchasesPostConfigurationStep.Type).toNot(beNil())
|
||||
}
|
||||
|
||||
func testConfigurePingsRevenueCatUIOnce() {
|
||||
_ = Purchases.configure(withAPIKey: "test_purchases_ui_service")
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
|
||||
// same config: dedup must not ping again
|
||||
_ = Purchases.configure(withAPIKey: "test_purchases_ui_service")
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
}
|
||||
|
||||
func testActivateIfNeededPingsEvenWhenPurchasesIsNotConfigured() {
|
||||
// Keep-alive must actually run: a no-op would be stripped, and UI may
|
||||
// load before ``Purchases/configure``.
|
||||
PurchasesUIService.activateIfNeeded()
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
}
|
||||
|
||||
func testActivateIfNeededReplaysWhenPurchasesAlreadyConfigured() {
|
||||
_ = Purchases.configure(withAPIKey: "test_purchases_ui_service")
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
|
||||
// Simulate RevenueCatUI loading after configuration: the class is now
|
||||
// present, but its once-only flag has not run yet.
|
||||
PurchasesUIService.resetForTests()
|
||||
self.logger.clearMessages()
|
||||
|
||||
PurchasesUIService.activateIfNeeded()
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
}
|
||||
|
||||
func testPaywallViewConfigurationActivateIfNeededWhenConfigured() throws {
|
||||
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
|
||||
|
||||
_ = Purchases.configure(withAPIKey: "test_purchases_ui_service")
|
||||
PurchasesUIService.resetForTests()
|
||||
self.logger.clearMessages()
|
||||
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) {
|
||||
_ = PaywallViewConfiguration(purchaseHandler: .default())
|
||||
}
|
||||
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func testCustomerCenterViewActivateIfNeededWhenConfigured() throws {
|
||||
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()
|
||||
|
||||
_ = Purchases.configure(withAPIKey: "test_purchases_ui_service")
|
||||
PurchasesUIService.resetForTests()
|
||||
self.logger.clearMessages()
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
_ = CustomerCenterView()
|
||||
}
|
||||
|
||||
self.verifyPurchasesDidConfigureLogged(count: 1)
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
private extension PurchasesUIServiceTests {
|
||||
|
||||
func verifyPurchasesDidConfigureLogged(count: Int) {
|
||||
if count == 0 {
|
||||
self.logger.verifyMessageWasNotLogged(
|
||||
Strings.purchases_did_configure,
|
||||
allowNoMessages: true
|
||||
)
|
||||
} else {
|
||||
self.logger.verifyMessageWasLogged(
|
||||
Strings.purchases_did_configure,
|
||||
level: .debug,
|
||||
expectedCount: count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -954,6 +954,26 @@ platform :ios do
|
||||
end
|
||||
end
|
||||
|
||||
desc "Runs PurchasesUIService integration tests against a Release, symbol-stripped RevenueCatUI build"
|
||||
lane :test_purchases_ui_service_stripped do
|
||||
platform = ENV['PLATFORM'] || 'iOS Simulator'
|
||||
destination = ENV['DEVICE'] || "iPhone 16,OS=18.5"
|
||||
sdk = ENV['BUILD_SDK'] || 'iphonesimulator'
|
||||
|
||||
xcodebuild(
|
||||
workspace: '.',
|
||||
scheme: 'RevenueCatUI-Stripped',
|
||||
destination: "platform=" + platform + ",name=" + destination,
|
||||
sdk: sdk,
|
||||
configuration: 'Release',
|
||||
result_bundle_path: 'fastlane/test_output/purchases_ui_service_stripped.xcresult',
|
||||
report_formats: [:junit],
|
||||
report_path: 'fastlane/test_output/purchases_ui_service_stripped/tests.xml',
|
||||
test: true,
|
||||
xcargs: "DEAD_CODE_STRIPPING=YES STRIP_SWIFT_SYMBOLS=YES ONLY_ACTIVE_ARCH=YES"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Release checks"
|
||||
lane :release_checks do |options|
|
||||
version_number = current_version_number
|
||||
|
||||
Reference in new issue
Block a user