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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
- Fixes network requests that can never succeed, such as those with an invalid API key, taking up to a minute to fail instead of failing straight away. Timeouts and server errors still retry as before.
- Fixes failed network requests being reported as a decoding error rather than the HTTP error that actually occurred.
- Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls.
- Fixes network requests on iOS 16 and earlier briefly waiting on the main thread.
- Fixes Main Thread Checker warnings caused by reading the device's interface style and text size from a background thread.
- Prevents unused App Tracking Transparency support from triggering App Store Connect tracking warnings.
- Stops Apple's microphone, location, and contacts class and selector names appearing in your app's binary when you don't use those permissions.
Expand Down
65 changes: 44 additions & 21 deletions Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,24 @@ class DeviceHelper {
/// forever. Only app bundles run `UIApplicationMain`, so the bundle type
/// disambiguates.
static var isUIKitReadSafe: Bool {
if UIApplication.sharedApplication != nil {
return isUIKitReadSafe(
hasApplication: UIApplication.sharedApplication != nil,
bundleURL: Bundle.main.bundleURL
)
}

/// The decision above, split from the globals it reads.
///
/// Both facts come from process-wide state that a test can't stage: the runner has
/// no application object and isn't an app bundle, so the deciding arm — an app
/// bundle with no application yet — is unreachable through the property. Taking
/// them as parameters lets both arms be pinned, so loosening `"app"` can't leave
/// the suite green while re-opening the accent-color bug.
static func isUIKitReadSafe(hasApplication: Bool, bundleURL: URL) -> Bool {
if hasApplication {
return true
}
return Bundle.main.bundleURL.pathExtension != "app"
return bundleURL.pathExtension != "app"
}

/// The instance's view of ``isUIKitReadSafe``, injected at init. Every
Expand Down Expand Up @@ -265,9 +279,9 @@ class DeviceHelper {
/// or the template and the header disagree on a backend-contract field.
///
/// Not folded into a shared `interfaceStyle(from:)` helper on purpose: passing
/// `currentUITraits` would evaluate it eagerly, and below iOS 17 that read is a
/// blocking main-queue hop — one per network request whenever an override is set.
/// The early return here avoids it.
/// `currentUITraits` would evaluate it eagerly, scheduling a needless cache
/// refresh — one per network request whenever an override is set, and a blocking
/// fill on the first pre-launch read. The early return here avoids it.
var interfaceStyle: String {
if let interfaceStyleOverride = interfaceStyleOverride {
return interfaceStyleOverride.description
Expand Down Expand Up @@ -318,22 +332,22 @@ class DeviceHelper {
/// never fire it. Both show up as this no longer matching the active scene.
private weak var observedTraitScene: UIWindowScene?

/// The current traits.
/// The current traits, served from the cache on every version.
///
/// Before iOS 17 there is no trait hook, so nothing invalidates the cache when the
/// appearance flips while the app stays active. These values were read live on
/// every access before caching was introduced, and serving a stale one would
/// regress `X-Device-Interface-Style` and the audience filters keyed on it — so
/// those versions read live. `makeUITraits()` makes the main-thread hop itself, and
/// off the main thread that hop *blocks* the caller until the main queue drains.
/// Read this once and reuse the snapshot rather than touching it per field.
/// `refreshUITraits()` keeps the cache fresh: main-thread reads refresh it
/// synchronously, off-main reads schedule one coalesced main-queue refresh —
/// blocking only to fill an empty cache, which happens when init ran before
/// `UIApplicationMain`.
///
/// From iOS 17 the hook keeps the cache current, and the scheduled refresh covers
/// the gap between launch and registration.
/// From iOS 17 the trait hook also updates the cache at the moment the appearance
/// flips. Below 17 there is no hook, so an in-place automatic light/dark change —
/// sunset while the app is frontmost — lands one read late via the refresh-on-read
/// backstop. That bounded lag is accepted in exchange for not blocking a
/// cooperative-pool thread on the main queue for every network request
/// (`X-Device-Interface-Style`) and template build; earlier releases read live here
/// and paid that hop each time. Text-size changes and flips made while backgrounded
/// still land immediately via the notification observers.
private var currentUITraits: UITraits {
guard #available(iOS 17.0, *) else {
return DeviceHelper.makeUITraits(isUIKitReadSafe: isUIKitReadSafe) ?? .unavailable
}
refreshUITraits()
return uiTraits ?? .unavailable
}
Expand Down Expand Up @@ -460,7 +474,16 @@ class DeviceHelper {
}
let category = UIApplication.sharedApplication?.preferredContentSizeCategory
?? UIScreen.main.traitCollection.preferredContentSizeCategory
let scaledValue = UIFontMetrics.default.scaledValue(for: 16.0)
// Scale against `category` explicitly. The implicit `scaledValue(for:)` resolves
// against `UITraitCollection.current`, which Apple documents as undefined outside
// a view or view-controller trait callback and stores per thread — so the two
// font numbers could disagree with the category on the line above, which is read
// from `UIApplication` and doesn't depend on trait context. One source, so the
// three values in a snapshot can't contradict each other in the audience filters.
let scaledValue = UIFontMetrics.default.scaledValue(
for: 16.0,
compatibleWith: UITraitCollection(preferredContentSizeCategory: category)
)

return UITraits(
interfaceStyle: interfaceStyleToken(
Expand Down Expand Up @@ -933,8 +956,8 @@ class DeviceHelper {
let aliases = [identityInfo.aliasId]

// Snapshot once. The four trait-derived fields below each go through
// `currentUITraits`, which before iOS 17 reads live behind a blocking
// main-queue hop, so reading them separately would make four of those per call.
// `currentUITraits`, so reading them separately would schedule four cache
// refreshes per call and could interleave with one, tearing the snapshot.
// Taking the snapshot means `interfaceStyle` below resolves the override
// inline rather than going through ``interfaceStyle``, so the two resolve it
// the same way by hand — keep them in step.
Expand Down
58 changes: 54 additions & 4 deletions Tests/SuperwallKitTests/Network/DeviceHelperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import UIKit
@testable import SuperwallKit

struct DeviceHelperTests {
/// Scales the way `makeUITraits()` does — explicitly against the resolved category,
/// not the ambient `UITraitCollection.current`. Computing an expectation with the
/// implicit overload would make both sides inherit the same ambient-trait fault, so
/// the assertion couldn't fail on it.
@MainActor
private static func expectedScaledValue(for category: UIContentSizeCategory) -> Double {
return Double(
UIFontMetrics.default.scaledValue(
for: 16.0,
compatibleWith: UITraitCollection(preferredContentSizeCategory: category)
)
)
}

@Test func makePaddedSdkVersion_withBeta() {
let version = "3.0.0-beta.1"
let paddedVersion = DeviceHelper.makePaddedVersion(using: version)
Expand Down Expand Up @@ -111,7 +125,7 @@ struct DeviceHelperTests {
let expected = await MainActor.run { () -> (String, Int, Double, String) in
let category = UIApplication.sharedApplication?.preferredContentSizeCategory
?? UIScreen.main.traitCollection.preferredContentSizeCategory
let scaledValue = Double(UIFontMetrics.default.scaledValue(for: 16.0))
let scaledValue = Self.expectedScaledValue(for: category)
return (
DeviceHelper.interfaceStyleToken(for: UIScreen.main.traitCollection.userInterfaceStyle),
Int(scaledValue.rounded()),
Expand All @@ -137,7 +151,7 @@ struct DeviceHelperTests {

#expect(deviceHelper.preferredContentSizeCategory == DeviceHelper.contentSizeCategoryToken(for: category))
#expect(deviceHelper.interfaceStyle == DeviceHelper.interfaceStyleToken(for: UIScreen.main.traitCollection.userInterfaceStyle))
let scaledValue = Double(UIFontMetrics.default.scaledValue(for: 16.0))
let scaledValue = Self.expectedScaledValue(for: category)
let expectedScale: Double = ((scaledValue / 16.0) * 100).rounded() / 100

#expect(deviceHelper.fontSize == Int(scaledValue.rounded()))
Expand Down Expand Up @@ -167,7 +181,7 @@ struct DeviceHelperTests {
?? UIScreen.main.traitCollection.preferredContentSizeCategory
return (
DeviceHelper.interfaceStyleToken(for: UIScreen.main.traitCollection.userInterfaceStyle),
Int(UIFontMetrics.default.scaledValue(for: 16.0).rounded()),
Int(Self.expectedScaledValue(for: category).rounded()),
DeviceHelper.contentSizeCategoryToken(for: category)
)
}
Expand All @@ -192,7 +206,7 @@ struct DeviceHelperTests {
let expected = await MainActor.run { () -> (style: String, fontSize: Int, fontScale: Double, category: String) in
let category = UIApplication.sharedApplication?.preferredContentSizeCategory
?? UIScreen.main.traitCollection.preferredContentSizeCategory
let scaledValue = Double(UIFontMetrics.default.scaledValue(for: 16.0))
let scaledValue = Self.expectedScaledValue(for: category)
return (
DeviceHelper.interfaceStyleToken(for: UIScreen.main.traitCollection.userInterfaceStyle),
Int(scaledValue.rounded()),
Expand Down Expand Up @@ -250,6 +264,42 @@ struct DeviceHelperTests {
#expect(DeviceHelper.isUIKitReadSafe)
}

/// The arm that actually prevents #493 — an app bundle whose application object
/// doesn't exist yet, i.e. `configure` from a SwiftUI `App.init`. The property
/// can't reach it: this runner is neither an app bundle nor ever gets an
/// application, so drive the decision through the parameterised overload. Without
/// this, loosening `"app"` would leave the whole suite green.
@Test func isUIKitReadSafe_insideAnAppBundleBeforeLaunch_defersReads() {
#expect(
DeviceHelper.isUIKitReadSafe(
hasApplication: false,
bundleURL: URL(fileURLWithPath: "/private/var/containers/Bundle/Application/Demo.app")
) == false
)
}

/// Once `UIApplicationMain` has built the application object the accent colour is
/// already registered, so an app bundle stops deferring.
@Test func isUIKitReadSafe_insideAnAppBundleAfterLaunch_allowsReads() {
#expect(
DeviceHelper.isUIKitReadSafe(
hasApplication: true,
bundleURL: URL(fileURLWithPath: "/private/var/containers/Bundle/Application/Demo.app")
)
)
}

/// Processes that never run `UIApplicationMain` — app extensions, this runner —
/// must not wait for an application that will never arrive.
@Test func isUIKitReadSafe_outsideAnAppBundleWithoutAnApplication_allowsReads() {
#expect(
DeviceHelper.isUIKitReadSafe(
hasApplication: false,
bundleURL: URL(fileURLWithPath: "/private/var/containers/Bundle/Application/Demo.appex")
)
)
}

@Test func makeScreenMetrics_whenReadsAreAllowed_readsTheScreen() {
let metrics = DeviceHelper.makeScreenMetrics()
#expect((metrics?.width ?? 0) > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,32 @@ struct AudioSessionProxyTests {
#expect(validValues.contains(result))
}

@Test func sharedInstance_returnsNonNil() {
/// Whether `AVAudioSession` is registered with the ObjC runtime is a property of
/// the runner image — `SuperwallKit` deliberately doesn't link AVFoundation and the
/// test target declares no `TEST_HOST` — so asserting non-nil outright would fail
/// CI on an environment fact rather than a defect. Tie both sides to the same fact:
/// the proxy returns an instance exactly when the class resolves.
@Test func sharedInstance_matchesWhetherTheClassResolves() {
let proxy = AudioSessionProxy()
// AVAudioSession resolves at runtime on every platform the tests run on.
let instance = proxy.sharedInstance()
#expect(instance != nil)
let classResolves = NSClassFromString(AudioSessionProxy.mangledClassName.rot13()) != nil

#expect((proxy.sharedInstance() != nil) == classResolves)
}
Comment thread
pullfrog[bot] marked this conversation as resolved.

/// The check above ties both sides to `mangledClassName`, so a typo in the constant
/// sends them nil together and leaves the suite green — while every microphone
/// permission read silently degrades to the unavailable sentinel. Pin the decoded
/// names deterministically instead, the way the Contacts, Location, and Tracking
/// proxy suites do. The literals land in the test binary, not the shipped SDK, so
/// they don't undo the mangling.
@Test func mangledClassName_decodesCorrectly() {
#expect(AudioSessionProxy.mangledClassName.rot13() == "AVAudioSession")
}

@Test func selectorNames_areCorrectlyDecoded() {
#expect(AudioSessionProxy.mangledSharedInstanceSelector.rot13() == "sharedInstance")
#expect(AudioSessionProxy.mangledRecordPermissionSelector.rot13() == "recordPermission")
#expect(AudioSessionProxy.mangledRequestPermissionSelector.rot13() == "requestRecordPermission:")
}
}

Expand Down
Loading