diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e90ea4a..fb05143af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index 6707eb471..edeb1bdf4 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -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 @@ -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 @@ -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 } @@ -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( @@ -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. diff --git a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift index 5f8efaa58..6298bf67b 100644 --- a/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift +++ b/Tests/SuperwallKitTests/Network/DeviceHelperTests.swift @@ -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) @@ -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()), @@ -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())) @@ -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) ) } @@ -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()), @@ -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) diff --git a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift index a7c64b2ec..7cd2ac267 100644 --- a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift +++ b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift @@ -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) + } + + /// 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:") } }