Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
### Fixes

- Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading.
- Test mode no longer turns on in App Store builds because the app's bundle ID differs from the one on the Superwall dashboard. A wrong bundle ID on the dashboard used to put every customer in test mode, replacing their real subscription with simulated purchases. Development, simulator and TestFlight builds still enter test mode on a mismatch, and test users marked on the dashboard still get it in any build.
- Fixes the SDK waiting forever, with every `register` call held, when the test mode sheet was accepted for presentation but never reached the screen.
- Fixes subscribers staying in test mode on every launch after the dashboard setting that caused it had been corrected. The SDK no longer saves a config that turns test mode on, and no longer starts a launch from one saved by an earlier version.
- Stops matching installs for attribution unless the Superwall MMP is turned on for your app.
- Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish.

Expand Down
47 changes: 40 additions & 7 deletions Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class ConfigManager {
let startAt = Date()

// Step 1: Determine fetch strategy based on subscription status and cached data
let cachedConfig = storage.get(LatestConfig.self)
let cachedConfig = cachedConfigForLaunch()
let isSubscribed = hasActiveCachedSubscription()

let shouldFetchAsync = cachedConfig != nil && isSubscribed
Expand Down Expand Up @@ -447,11 +447,41 @@ class ConfigManager {
)
}

/// Saves `config` and applies the parts that don't depend on purchases.
private func storeAndApply(_ config: Config) {
/// The config the previous launch saved, unless it would put this launch in
/// test mode.
///
/// A subscriber launches from the saved config without waiting on the
/// network, and the refresh that replaces it is only scheduled once that
/// config has been processed. A saved config that turns test mode on would
/// therefore do so on every launch, even after the dashboard setting behind
/// it had been corrected. So test mode is only ever entered from a config
/// fetched during the current launch. Versions before 4.18.0 saved these
/// configs, which is why one can still be found here.
private func cachedConfigForLaunch() -> Config? {
guard let cachedConfig = storage.get(LatestConfig.self) else {
return nil
}
let testModeManager = factory.makeTestModeManager()
if testModeManager.isPutInTestMode(by: cachedConfig, options: options) {
storage.delete(LatestConfig.self)
return nil
}
return cachedConfig
}

/// Applies the parts of `config` that don't depend on purchases, and saves
/// it for the next launch to start from when `keepingForNextLaunch` is true.
private func storeAndApply(_ config: Config, keepingForNextLaunch: Bool) {
storage.save(
config.featureFlags.disableVerbosePlacements, forType: DisableVerbosePlacements.self)
storage.save(config, forType: LatestConfig.self)
if keepingForNextLaunch {
storage.save(config, forType: LatestConfig.self)
} else {
// Also drops the config saved before this one. Starting the next launch
// from that would show paywalls with real products for a moment, before
// the refresh turned test mode back on.
storage.delete(LatestConfig.self)
}
triggersByPlacementName = ConfigLogic.getTriggersByPlacementName(from: config.triggers)
choosePaywallVariants(from: config.triggers)
deviceHelper.startIPCollectionIfEnabled(for: config)
Expand All @@ -467,12 +497,15 @@ class ConfigManager {
isFirstTime: Bool,
publishingEarlyFrom savedCustomerInfo: CustomerInfo? = nil
) async {
storeAndApply(config)
let testModeManager = factory.makeTestModeManager()

// Evaluate test mode before loading products
let testModeManager = factory.makeTestModeManager()
let wasTestMode = testModeManager.isTestMode
testModeManager.evaluateTestMode(config: config, options: options)
let testModeReason = testModeManager.evaluateTestMode(config: config, options: options)
storeAndApply(
config,
keepingForNextLaunch: testModeReason?.comesFromConfig != true
)
let testModeJustActivated = !wasTestMode && testModeManager.isTestMode
let testModeJustDeactivated = wasTestMode && !testModeManager.isTestMode

Expand Down
6 changes: 4 additions & 2 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ public final class SuperwallOptions: NSObject, Encodable {
@objc(SWKTestModeBehavior)
public enum TestModeBehavior: Int, Encodable, CustomStringConvertible {
/// Activates test mode when enabled for a user via the dashboard or when a bundle ID
/// mismatch is detected, but never during UI tests.
/// mismatch is detected, but never during UI tests. A bundle ID mismatch is ignored in
/// App Store builds, so a wrong bundle ID on the dashboard can't put customers in test mode.
case automatic

/// Activates test mode only when specifically enabled for a user via the dashboard.
Expand Down Expand Up @@ -409,7 +410,8 @@ public final class SuperwallOptions: NSObject, Encodable {
/// Controls when the SDK enters test mode. Defaults to `.automatic`.
///
/// - `.automatic`: Activates test mode when enabled for a user via the dashboard or when
/// a bundle ID mismatch is detected, but never during UI tests.
/// a bundle ID mismatch is detected, but never during UI tests. A bundle ID mismatch is
/// ignored in App Store builds.
/// - `.whenEnabledForUser`: Activates test mode only when specifically enabled for a
/// user via the dashboard.
/// - `.never`: Test mode is never activated, regardless of configuration.
Expand Down
44 changes: 44 additions & 0 deletions Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,46 @@ enum TestModeModal {
message: "Couldn't show the test mode sheet, so using the saved test mode settings."
)
modal.finish()
} else {
// That only catches a refusal UIKit makes on the spot. A sheet it
// accepted can still never reach the screen, and then nothing would
// ever close it, so stop waiting for that one too.
giveUpIfNeverShown(navController, modal: modal)
}
}
}

/// How long an accepted sheet gets to start appearing. UIKit starts a
/// presentation on the next turn of the run loop, so this only passes for a
/// sheet that is never going to show.
static let noShowTimeout: TimeInterval = 3

/// Falls back to the saved settings if `navController` still hasn't started
/// appearing after `timeout`. Config waits on this sheet, so one that never
/// shows would otherwise leave every `register` call waiting for good.
static func giveUpIfNeverShown(
_ navController: TestModeNavigationController,
modal: TestModeModalViewController,
after timeout: TimeInterval = noShowTimeout
) {
Task { @MainActor in
Comment thread
yusuftor marked this conversation as resolved.
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
if navController.hasStartedAppearing {
return
}
Logger.debug(
logLevel: .warn,
scope: .superwallCore,
message: "The test mode sheet never appeared, so using the saved test mode settings."
)
// Take it back from UIKit so it can't turn up later with nothing listening.
if navController.presentingViewController != nil {
navController.dismiss(animated: false, completion: nil)
}
modal.finish()
}
}

/// Walks up from `viewController` to the screen on top, skipping any that
/// are on their way out, so the modal can sit above sheets like the
/// Customer Center.
Expand All @@ -91,6 +127,14 @@ enum TestModeModal {
final class TestModeNavigationController: UINavigationController {
var onClose: (() -> Void)?

/// Set once UIKit starts putting the sheet on screen.
private(set) var hasStartedAppearing = false

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
hasStartedAppearing = true
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// Still around if it's only covered by something presented on top.
Expand Down
148 changes: 99 additions & 49 deletions Sources/SuperwallKit/TestMode/TestModeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,23 @@ enum TestModeReason: Sendable {
/// Test mode is always enabled via SuperwallOptions.
case testModeOption

/// The app's bundle ID doesn't match the config's `bundleIds.ios`.
/// The app's bundle ID doesn't match the config's `bundleIds.ios`, in a
/// build that isn't from the App Store.
case bundleIdMismatch(expected: String, actual: String)

/// Whether the config itself asked for test mode, as opposed to an option
/// the app set. Only a config can change between launches, so only these
/// reasons can come from a config saved by an earlier launch.
var comesFromConfig: Bool {
switch self {
case .configMatch,
.bundleIdMismatch:
return true
case .testModeOption:
return false
}
}

var description: String {
switch self {
case .configMatch:
Expand Down Expand Up @@ -92,6 +106,10 @@ final class TestModeManager {
return NSClassFromString("XCTestCase") != nil
}()

/// Whether the app is running outside App Store production: simulator,
/// TestFlight or a development build. Replaced in tests.
var isSandboxEnvironment: () -> Bool = { DeviceHelper.isSandboxEnvironment }

unowned let identityManager: IdentityManager
private unowned let deviceHelper: DeviceHelper
private unowned let storage: Storage
Expand All @@ -108,53 +126,68 @@ final class TestModeManager {

/// Evaluates whether the current user should be in test mode based on the config
/// and the `testModeBehavior` option. Called on every config refresh.
func evaluateTestMode(config: Config, options: SuperwallOptions) {
if DevMode.isActive(options) {
isTestMode = true
testModeReason = .testModeOption
return
}
switch options.testModeBehavior {
case .never:
///
/// - Returns: The reason test mode is now on, or `nil` if it's off.
@discardableResult
func evaluateTestMode(config: Config, options: SuperwallOptions) -> TestModeReason? {
guard let reason = reasonForTestMode(
config: config,
options: options,
warnAboutIgnoredMismatch: true
) else {
isTestMode = false
testModeReason = nil
clearTestModeState()
return
return nil
}
isTestMode = true
testModeReason = reason
return reason
}

case .always:
isTestMode = true
testModeReason = .testModeOption
return
/// Why `config` and `options` would put the current user in test mode, or
/// `nil` if they wouldn't. Changes nothing, so a config can be checked before
/// it is used.
func reasonForTestMode(config: Config, options: SuperwallOptions) -> TestModeReason? {
return reasonForTestMode(config: config, options: options, warnAboutIgnoredMismatch: false)
}

private func reasonForTestMode(
config: Config,
options: SuperwallOptions,
warnAboutIgnoredMismatch: Bool
) -> TestModeReason? {
if DevMode.isActive(options) {
return .testModeOption
}
switch options.testModeBehavior {
case .never:
return nil
case .always:
return .testModeOption
case .whenEnabledForUser:
// Only check user ID match, skip bundle ID check
if checkConfigMatch(config: config) { return }
isTestMode = false
testModeReason = nil
clearTestModeState()
return

return configMatchReason(config: config)
case .automatic:
// Skip entirely if in UI tests
if Self.isTestEnvironment {
isTestMode = false
testModeReason = nil
clearTestModeState()
return
return nil
}
// Check user match, then bundle ID mismatch
if checkConfigMatch(config: config) { return }
if checkBundleIdMismatch(config: config) { return }
isTestMode = false
testModeReason = nil
clearTestModeState()
return
return configMatchReason(config: config)
?? bundleIdMismatchReason(config: config, warnIfIgnored: warnAboutIgnoredMismatch)
}
}

/// Checks if the current user's ID or alias matches any test store user in the config.
/// Returns `true` and activates test mode if a match is found.
private func checkConfigMatch(config: Config) -> Bool {
/// Whether `config` itself would put the current user in test mode. Such a
/// config must never be the one a launch starts from: see
/// `ConfigManager.cachedConfigForLaunch()`.
func isPutInTestMode(by config: Config, options: SuperwallOptions) -> Bool {
return reasonForTestMode(config: config, options: options)?.comesFromConfig == true
}

/// The reason if the current user's ID or alias matches any test store user in the config.
private func configMatchReason(config: Config) -> TestModeReason? {
let testModeUserIds = config.testModeUserIds ?? []
let aliasId = identityManager.aliasId
let appUserId = identityManager.appUserId
Expand All @@ -163,35 +196,52 @@ final class TestModeManager {
switch testUser.type {
case .userId:
if let appUserId, appUserId == testUser.value {
isTestMode = true
testModeReason = .configMatch
return true
return .configMatch
}
case .aliasId:
if aliasId == testUser.value {
isTestMode = true
testModeReason = .configMatch
return true
return .configMatch
}
}
}
return false
return nil
}

/// Checks if the app's bundle ID differs from the config's expected bundle ID.
/// Returns `true` and activates test mode if a mismatch is found.
/// The reason if the app's bundle ID differs from the config's expected bundle ID.
/// App extensions are allowed because their bundle ID uses the main app's
/// bundle ID as a prefix (e.g., `com.example.app.widget-extension`).
private func checkBundleIdMismatch(config: Config) -> Bool {
if let expectedBundleId = config.bundleIdConfig,
///
/// - Parameter warnIfIgnored: Whether to log when an App Store build ignores
/// the mismatch. Only set when the config is being applied, so the warning
/// shows once per config rather than on every check.
private func bundleIdMismatchReason(config: Config, warnIfIgnored: Bool) -> TestModeReason? {
guard
let expectedBundleId = config.bundleIdConfig,
let actualBundleId = Bundle.main.bundleIdentifier,
expectedBundleId != actualBundleId,
!actualBundleId.hasPrefix(expectedBundleId + ".") {
isTestMode = true
testModeReason = .bundleIdMismatch(expected: expectedBundleId, actual: actualBundleId)
return true
!actualBundleId.hasPrefix(expectedBundleId + ".")
else {
return nil
}
// A mismatch is how a developer's own build gets into test mode without
// any setup. An App Store install is a customer's, and the mismatch there
// means the bundle ID on the dashboard was edited, which must not replace
// their real purchases with simulated ones. Test users marked on the
// dashboard still get test mode in an App Store build.
guard isSandboxEnvironment() else {
if !warnIfIgnored {
return nil
}
Logger.debug(
Comment thread
yusuftor marked this conversation as resolved.
logLevel: .warn,
scope: .superwallCore,
message: "The app's bundle ID is \(actualBundleId) but the Superwall dashboard expects "
+ "\(expectedBundleId). Ignoring the mismatch because this is an App Store build, "
+ "so test mode stays off. Correct the bundle ID in the dashboard's app settings."
)
return nil
}
return false
return .bundleIdMismatch(expected: expectedBundleId, actual: actualBundleId)
}

/// Clears all test mode state including entitlements, products,
Expand Down
Loading
Loading