diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 32da8f3859..d61761b6d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: paths: - '.github/workflows/tests.yml' - 'project.yml' + - 'scripts/**' - '**/*.swift' - '!Examples/**' workflow_dispatch: @@ -33,4 +34,10 @@ jobs: uses: xavierLowmiller/xcodegen-action@1.2.3 - name: Run Tests run: | - xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' test + xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' -derivedDataPath .build test + # Whether a permission API name reaches the shipped binary is invisible to the + # test suite — it depends on what the compiler emits, not on what the code + # returns. Scan the binary the tests just built. + - name: Scan for privacy API signatures + run: | + ./scripts/scan-privacy-signatures.sh .build/Build/Products/Debug-iphonesimulator/SuperwallKit.framework/SuperwallKit diff --git a/CHANGELOG.md b/CHANGELOG.md index c24139d61b..c11469892d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 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. ## 4.16.1 diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift index ce2437cc91..ba69dff0e4 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Contacts/ContactStoreProxy.swift @@ -29,15 +29,25 @@ final class ContactStoreProxy: NSObject { // CNEntityType.contacts == 0 static let contactsEntityType = 0 - static var contactStoreClass: AnyClass? { - NSClassFromString(mangledContactStoreClassName.rot13()) + private let contactStoreClass: AnyClass? + + init( + contactStoreClass: AnyClass? = NSClassFromString( + ContactStoreProxy.mangledContactStoreClassName.rot13() + ) + ) { + self.contactStoreClass = contactStoreClass + super.init() } - @objc var authorizationStatusSelectorName: String { + // Deliberately not `@objc`: an `@objc` member emits its name into the binary's + // Objective-C method metadata, which is the section this file's mangling exists + // to keep Apple's API names out of. These are read from Swift only. + var authorizationStatusSelectorName: String { Self.mangledAuthorizationStatusSelector.rot13() } - @objc var requestAccessSelectorName: String { + var requestAccessSelectorName: String { Self.mangledRequestAccessSelector.rot13() } @@ -51,8 +61,10 @@ final class ContactStoreProxy: NSObject { return method_getImplementation(method) } - @objc func authorizationStatus() -> Int { - let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self + func authorizationStatus() -> Int { + guard let cls = contactStoreClass else { + return -1 + } let sel = NSSelectorFromString(authorizationStatusSelectorName) guard let imp = Self.classIMP(cls, sel) else { @@ -66,10 +78,11 @@ final class ContactStoreProxy: NSObject { return function(cls as AnyObject, sel, Self.contactsEntityType) } - func requestAccess() async throws -> Bool { - let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self - - guard let storeType = cls as? NSObject.Type else { + // Named away from Apple's `requestAccess` deliberately: + // `withCheckedThrowingContinuation`'s `function: String = #function` default + // expands the enclosing method name into a string literal in the binary. + func requestPermission() async throws -> Bool { + guard let storeType = contactStoreClass as? NSObject.Type else { return false } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift deleted file mode 100644 index 23388e05b4..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/FakeContactsStore.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// FakeContactsStore.swift -// SuperwallKit -// -// Created by Yusuf Tör on 13/01/2026. -// - -import Foundation - -final class FakeContactStore: NSObject { - // Class method - @objc static func authorizationStatusForEntityType(_ entityType: Int) -> Int { - -1 - } - - // Instance method - @objc func requestAccessForEntityType( - _ entityType: Int, - completionHandler: @escaping (Bool, NSError?) -> Void - ) { - completionHandler(false, nil) - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift b/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift index b9953f0b59..983520685b 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Contacts/PermissionsHandler+Contacts.swift @@ -28,7 +28,7 @@ extension PermissionHandler { do { let proxy = ContactStoreProxy() - let granted = try await proxy.requestAccess() + let granted = try await proxy.requestPermission() return granted ? .granted : .denied } catch { Logger.debug( diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift deleted file mode 100644 index 44bc4f2b5b..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/FakeLocationManager.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FakeLocationManager.swift -// SuperwallKit -// -// Created by Yusuf Tör on 13/01/2026. -// - -import Foundation - -final class FakeLocationManager: NSObject { - weak var delegate: AnyObject? - - // Instance property (iOS 14+) - @objc var authorizationStatus: Int { - return FakeLocationAuthorizationStatus.notDetermined.rawValue - } - - @objc func requestWhenInUseAuthorization() { - // No-op in fake implementation - } - - @objc func requestAlwaysAuthorization() { - // No-op in fake implementation - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift index 19bfb45cc7..af44a6fe0e 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationManagerProxy.swift @@ -30,23 +30,36 @@ final class LocationManagerProxy: NSObject { // ROT13("setDelegate:") static let mangledSetDelegateSelector = "frgQryrtngr:" - static var locationManagerClass: AnyClass? { - NSClassFromString(mangledLocationManagerClassName.rot13()) + private var locationManager: NSObject? + + init( + locationManagerClass: AnyClass? = NSClassFromString( + LocationManagerProxy.mangledLocationManagerClassName.rot13() + ) + ) { + super.init() + guard let managerType = locationManagerClass as? NSObject.Type else { + return + } + locationManager = managerType.init() } - @objc var authorizationStatusSelectorName: String { + // Deliberately not `@objc`: an `@objc` member emits its name into the binary's + // Objective-C method metadata, which is the section this file's mangling exists + // to keep Apple's API names out of. These are read from Swift only. + var authorizationStatusSelectorName: String { Self.mangledAuthorizationStatusSelector.rot13() } - @objc var requestWhenInUseSelectorName: String { + var requestWhenInUseSelectorName: String { Self.mangledRequestWhenInUseSelector.rot13() } - @objc var requestAlwaysSelectorName: String { + var requestAlwaysSelectorName: String { Self.mangledRequestAlwaysSelector.rot13() } - @objc var setDelegateSelectorName: String { + var setDelegateSelectorName: String { Self.mangledSetDelegateSelector.rot13() } @@ -55,17 +68,6 @@ final class LocationManagerProxy: NSObject { return method_getImplementation(method) } - private var locationManager: NSObject? - - override init() { - super.init() - let cls: AnyClass = Self.locationManagerClass ?? FakeLocationManager.self - guard let managerType = cls as? NSObject.Type else { - return - } - locationManager = managerType.init() - } - func authorizationStatus() -> Int { guard let manager = locationManager else { return FakeLocationAuthorizationStatus.notDetermined.rawValue diff --git a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift index 38968fdaad..4867efc98b 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Location/LocationPermissionDelegate.swift @@ -9,6 +9,15 @@ import Foundation /// Delegate class to handle location authorization callbacks. /// Implements both iOS 14+ and iOS 13 delegate methods dynamically. +/// +/// The two `@objc` methods below put CoreLocation's exact delegate selectors into +/// the binary's Objective-C metadata — the section the proxies' mangling otherwise +/// keeps Apple's names out of. That's accepted, not overlooked: `CLLocationManager` +/// dispatches its delegate callbacks by these selectors at runtime, so the metadata +/// must carry them for the callbacks to arrive. Removing them would mean assembling +/// this class at runtime with `objc_allocateClassPair`. They're also callback names, +/// not request-API names or usage-description keys — nothing scanners are known to +/// react to. `scan-privacy-signatures.sh` deliberately leaves them off its list. final class LocationPermissionDelegate: NSObject { private let onStatusChange: (Int) -> Void private var hasCompleted = false @@ -37,8 +46,10 @@ final class LocationPermissionDelegate: NSObject { #endif private func currentAuthorizationStatus(from manager: AnyObject) -> Int { - // Try instance property first (iOS 14+) - if let status = manager.value(forKey: "authorizationStatus") as? Int { + // Try instance property first (iOS 14+). The key is decoded at runtime so the + // name doesn't sit in the binary as a plaintext literal. + let key = LocationManagerProxy.mangledAuthorizationStatusSelector.rot13() + if let status = manager.value(forKey: key) as? Int { return status } return FakeLocationAuthorizationStatus.notDetermined.rawValue diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift index 4e623c8966..6921ff9f7d 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Microphone/AudioSessionProxy.swift @@ -27,8 +27,15 @@ final class AudioSessionProxy: NSObject { // ROT13("requestRecordPermission:") static let mangledRequestPermissionSelector = "erdhrfgErpbeqCrezvffvba:" - static var audioSessionClass: AnyClass? { - NSClassFromString(mangledClassName.rot13()) + private let audioSessionClass: AnyClass? + + init( + audioSessionClass: AnyClass? = NSClassFromString( + AudioSessionProxy.mangledClassName.rot13() + ) + ) { + self.audioSessionClass = audioSessionClass + super.init() } private static func classIMP(_ cls: AnyClass, _ sel: Selector) -> IMP? { @@ -42,7 +49,7 @@ final class AudioSessionProxy: NSObject { } func sharedInstance() -> AnyObject? { - let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self + guard let cls = audioSessionClass else { return nil } let sel = NSSelectorFromString(Self.mangledSharedInstanceSelector.rot13()) guard let imp = Self.classIMP(cls, sel) else { return nil } @@ -58,9 +65,10 @@ final class AudioSessionProxy: NSObject { // 0x64656e79 ('deny') = denied // 0x67726e74 ('grnt') = granted func recordPermission() -> Int { - let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self - - guard let instance = sharedInstance() else { + guard + let cls = audioSessionClass, + let instance = sharedInstance() + else { return -1 } @@ -73,10 +81,15 @@ final class AudioSessionProxy: NSObject { return function(instance, sel) } - func requestRecordPermission() async -> Bool { - let cls: AnyClass = Self.audioSessionClass ?? FakeAudioSession.self - - guard let instance = sharedInstance() else { + // Named away from Apple's `requestRecordPermission` deliberately: + // `withCheckedContinuation`'s `function: String = #function` default expands the + // enclosing method name into a string literal in the binary, which is the same + // leak the mangling exists to prevent. + func requestPermission() async -> Bool { + guard + let cls = audioSessionClass, + let instance = sharedInstance() + else { return false } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift deleted file mode 100644 index 0e0c63ef59..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/FakeAudioSession.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FakeAudioSession.swift -// SuperwallKit -// -// Created by Yusuf Tör on 19/01/2026. -// - -import Foundation - -final class FakeAudioSession: NSObject { - // Class method - returns a shared instance - @objc static func sharedInstance() -> FakeAudioSession { - return FakeAudioSession() - } - - // Instance method - returns -1 to indicate unsupported - @objc func recordPermission() -> Int { - return -1 - } - - // Instance method - @objc func requestRecordPermission(_ completion: @escaping (Bool) -> Void) { - completion(false) - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift b/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift index 625b7ffe25..4e9515744f 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Microphone/PermissionHandler+Microphone.swift @@ -30,7 +30,7 @@ extension PermissionHandler { } let proxy = AudioSessionProxy() - let granted = await proxy.requestRecordPermission() + let granted = await proxy.requestPermission() return granted ? .granted : .denied } } diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift deleted file mode 100644 index 01a00e2fef..0000000000 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/FakeTrackingManager.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// FakeTrackingManager.swift -// SuperwallKit -// -// Created by Yusuf Tör on 13/01/2026. -// - -import Foundation - -final class FakeTrackingManager: NSObject { - /// Class property returning notDetermined - @objc static var trackingAuthorizationStatus: Int { - return FakeTrackingAuthorizationStatus.notDetermined.rawValue - } - - /// Class method for requesting authorization - @objc static func requestTrackingAuthorization( - completionHandler: @escaping (Int) -> Void - ) { - completionHandler(FakeTrackingAuthorizationStatus.notDetermined.rawValue) - } -} diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift index 9f193e7947..b8223237d1 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Tracking/PermissionsHandler+Tracking.swift @@ -44,7 +44,7 @@ extension PermissionHandler { return currentStatus } - let status = await proxy.requestTrackingAuthorization() + let status = await proxy.requestAuthorization() let permissionStatus = status.toTrackingPermissionStatus return permissionStatus diff --git a/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift b/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift index 761a9fdd66..ac730d27e8 100644 --- a/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift +++ b/Sources/SuperwallKit/Permissions/Handlers/Tracking/TrackingManagerProxy.swift @@ -25,15 +25,25 @@ final class TrackingManagerProxy: NSObject { static let mangledRequestTrackingSelector = "erdhrfgGenpxvatNhgubevmngvbaJvguPbzcyrgvbaUnaqyre:" - static var trackingManagerClass: AnyClass? { - NSClassFromString(mangledTrackingManagerClassName.rot13()) + private let trackingManagerClass: AnyClass? + + init( + trackingManagerClass: AnyClass? = NSClassFromString( + TrackingManagerProxy.mangledTrackingManagerClassName.rot13() + ) + ) { + self.trackingManagerClass = trackingManagerClass + super.init() } - @objc var trackingStatusSelectorName: String { + // Deliberately not `@objc`: an `@objc` member emits its name into the binary's + // Objective-C method metadata, which is the section this file's mangling exists + // to keep Apple's API names out of. These are read from Swift only. + var trackingStatusSelectorName: String { Self.mangledTrackingStatusSelector.rot13() } - @objc var requestTrackingSelectorName: String { + var requestTrackingSelectorName: String { Self.mangledRequestTrackingSelector.rot13() } @@ -43,23 +53,27 @@ final class TrackingManagerProxy: NSObject { } func trackingAuthorizationStatus() -> Int { - let cls: AnyClass = Self.trackingManagerClass ?? FakeTrackingManager.self + guard let trackingManagerClass else { + return FakeTrackingAuthorizationStatus.notDetermined.rawValue + } let sel = NSSelectorFromString(trackingStatusSelectorName) - guard let imp = Self.classIMP(cls, sel) else { + guard let imp = Self.classIMP(trackingManagerClass, sel) else { return FakeTrackingAuthorizationStatus.notDetermined.rawValue } typealias Function = @convention(c) (AnyObject, Selector) -> Int let function = unsafeBitCast(imp, to: Function.self) - return function(cls as AnyObject, sel) + return function(trackingManagerClass as AnyObject, sel) } - func requestTrackingAuthorization() async -> Int { - let cls: AnyClass = Self.trackingManagerClass ?? FakeTrackingManager.self + func requestAuthorization() async -> Int { + guard let trackingManagerClass else { + return FakeTrackingAuthorizationStatus.notDetermined.rawValue + } let sel = NSSelectorFromString(requestTrackingSelectorName) - guard let imp = Self.classIMP(cls, sel) else { + guard let imp = Self.classIMP(trackingManagerClass, sel) else { return FakeTrackingAuthorizationStatus.notDetermined.rawValue } @@ -70,7 +84,7 @@ final class TrackingManagerProxy: NSObject { typealias Function = @convention(c) (AnyObject, Selector, AnyObject) -> Void let function = unsafeBitCast(imp, to: Function.self) - function(cls as AnyObject, sel, completion as AnyObject) + function(trackingManagerClass as AnyObject, sel, completion as AnyObject) } } } diff --git a/Sources/SuperwallKit/Permissions/PermissionHandler.swift b/Sources/SuperwallKit/Permissions/PermissionHandler.swift index 9675457978..2380d9f54b 100644 --- a/Sources/SuperwallKit/Permissions/PermissionHandler.swift +++ b/Sources/SuperwallKit/Permissions/PermissionHandler.swift @@ -20,7 +20,17 @@ final class PermissionHandler: PermissionHandling { static let contacts = "NSContactsUsageDescription" static let locationWhenInUse = "NSLocationWhenInUseUsageDescription" static let locationAlways = "NSLocationAlwaysAndWhenInUseUsageDescription" - static let tracking = "NSUserTrackingUsageDescription" + // Only the tracking key is encoded. App Store Connect scans binaries for this + // one specifically and warns that the app "may request permission to track" + // — which blocked apps that link the SDK but never request tracking from + // declaring no tracking in App Privacy. The other keys above are read the same + // way but trigger no such scan, so they stay legible. Don't mangle them + // without a reason, and don't un-mangle this one. + // ROT13("NSUserTrackingUsageDescription") + static let mangledTracking = "AFHfreGenpxvatHfntrQrfpevcgvba" + static var tracking: String { + mangledTracking.rot13() + } static let microphone = "NSMicrophoneUsageDescription" } diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d395f3ccee..fedc0f9731 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -376,7 +376,6 @@ B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */; }; B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */; }; - B0F7F66E24C7AEDE460F34FE /* FakeTrackingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F38AD737B1CFECEB2AAF85 /* FakeTrackingManager.swift */; }; B10030CC414C2C341487F4B8 /* PaywallViewControllerDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990461F7A9B2F3ED62B3A628 /* PaywallViewControllerDelegateAdapter.swift */; }; B146C134ABE092C3C9ACADEC /* PurchaseResult+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */; }; B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; }; @@ -408,12 +407,10 @@ BCF808C7AC319C2B1F0AD52D /* ConfigResponseLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4827295A4E093CAEE2207DDF /* ConfigResponseLogicTests.swift */; }; BCFF20903199DDDE379D81E0 /* InAppReceiptPayloadContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862888AB2869D09AA55A017D /* InAppReceiptPayloadContainer.swift */; }; BD152F3BA0BC197A5C6C8CC1 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0695B39826F85AACBA833B77 /* InAppReceipt.swift */; }; - BD6ABB9DB883BC62D2407392 /* FakeLocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */; }; BDBEE781EC4910025379F0B6 /* ASN1Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588892D6DD1C4437CF507DE /* ASN1Serialization.swift */; }; BDECE549960DB9A5662939BE /* Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6AFBC7C60A5074ACE8DF88 /* Tracking.swift */; }; BE5BE4ECDE6505182DD92AA1 /* PaywallViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */; }; BEC00305295F34E7D6B4E82A /* SWDebugManagerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76CE4D7606C896027C76520E /* SWDebugManagerLogic.swift */; }; - BF47C2693495CB8C9DAD7C8D /* FakeContactsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */; }; BF9ADF5761C34F584EC416B1 /* PaywallWebEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260AE52B33B132D5A5C04911 /* PaywallWebEvent.swift */; }; BFBE808BCA151FAE95AE3276 /* PaywallRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */; }; BFCA9FE6639175011D0369D9 /* PaywallCacheLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9423D7BA0604D88E30161BFB /* PaywallCacheLogic.swift */; }; @@ -421,7 +418,6 @@ C053DEA1266E78107F828B19 /* StoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 910786130E2D7EDE2ED5452D /* StoreKitManager.swift */; }; C09B8BBB7002DA446E9F74E1 /* FactoryProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E30544869C5469AA31832 /* FactoryProtocols.swift */; }; C18384B9272067CFE7C7610D /* StoreProductType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A959F1F550446C980DC5E5 /* StoreProductType.swift */; }; - C22C4011CB9F4D4FA99F8206 /* FakeAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */; }; C23744AAAF31A533693281B6 /* SurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5ABEC694245E0DC00E409 /* SurveyManager.swift */; }; C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */; }; C32766E26E92FD03B1BA60A3 /* ProductPurchaserSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */; }; @@ -445,6 +441,7 @@ C9F6A490E3E09401494E7DCB /* CheckDebuggerPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9777790D2B73EFF94E7C648 /* CheckDebuggerPresentation.swift */; }; CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E6016BF483A4C47FF7A7C0 /* Encodable+Dictionary.swift */; }; CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA0BD57CE7F03A50ACA9D25 /* DeepLinkRouter.swift */; }; + CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */; }; CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */; }; CD324457E6206D0E303A6B15 /* ArchiveURLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81C5A241FC9EF921D4E08FF1 /* ArchiveURLFetcher.swift */; }; CD7A815C87F9AFF406BFE9A0 /* AuthorizationStatus+PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E97A346D7F7C59DF55F62B /* AuthorizationStatus+PermissionStatus.swift */; }; @@ -671,7 +668,6 @@ 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerWrapper.swift; sourceTree = ""; }; 23307BBFD80385233DDD4C43 /* AssetResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResource.swift; sourceTree = ""; }; 236900A8A8F95CE92E612458 /* IdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityManager.swift; sourceTree = ""; }; - 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeLocationManager.swift; sourceTree = ""; }; 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandlerTests.swift; sourceTree = ""; }; 2446E377C8C87A1C087B16B4 /* CELEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CELEvaluator.swift; sourceTree = ""; }; 24B8D7F537204EAB13BB7F10 /* FeatureGatingBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGatingBehaviour.swift; sourceTree = ""; }; @@ -730,6 +726,7 @@ 3A7BF742EBC950966662849F /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 3B8C73737C103C168647DCFB /* ExpressionLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogicTests.swift; sourceTree = ""; }; 3C1EB433A4E4342E03BB7744 /* IdentityInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityInfo.swift; sourceTree = ""; }; + 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxyTests.swift; sourceTree = ""; }; 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWConsoleViewController.swift; sourceTree = ""; }; 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackableSuperwallEvent.swift; sourceTree = ""; }; 3E3E1BAFC4A22DC46C49F00C /* String+RemoveChars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RemoveChars.swift"; sourceTree = ""; }; @@ -814,7 +811,6 @@ 65EDE0BAE33F351217CC8E2D /* TemplateLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateLogicTests.swift; sourceTree = ""; }; 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSchedulerTests.swift; sourceTree = ""; }; 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerCacheTests.swift; sourceTree = ""; }; - 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeAudioSession.swift; sourceTree = ""; }; 6752063E4547657E20072CE7 /* ConfirmHoldoutAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmHoldoutAssignment.swift; sourceTree = ""; }; 67602AF9B2543CAD0B42F3CF /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = ""; }; 67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfig.swift; sourceTree = ""; }; @@ -911,7 +907,6 @@ 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandler.swift; sourceTree = ""; }; 8DE36D141F461F6E945823FA /* Future+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Future+Async.swift"; sourceTree = ""; }; 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewData.swift; sourceTree = ""; }; - 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeContactsStore.swift; sourceTree = ""; }; 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallDelegateAdapter.swift; sourceTree = ""; }; 8E9E1A8F57B5DCA4CD16296F /* Dictionary+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Keys.swift"; sourceTree = ""; }; 8EF6F057A58A42193F279BE6 /* GetPaywallComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallComponents.swift; sourceTree = ""; }; @@ -1114,7 +1109,6 @@ D5E2D026C30691F11D4E839F /* SurveyShowCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyShowCondition.swift; sourceTree = ""; }; D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementPriorityTests.swift; sourceTree = ""; }; D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandler.swift; sourceTree = ""; }; - D6F38AD737B1CFECEB2AAF85 /* FakeTrackingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTrackingManager.swift; sourceTree = ""; }; D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Microphone.swift"; sourceTree = ""; }; D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerTests.swift; sourceTree = ""; }; D7E232690489360042465DB2 /* Redeemable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redeemable.swift; sourceTree = ""; }; @@ -1378,7 +1372,6 @@ isa = PBXGroup; children = ( 019FA4010BA11D24C68B8544 /* FakeTrackingAuthorizationStatus.swift */, - D6F38AD737B1CFECEB2AAF85 /* FakeTrackingManager.swift */, 20DA18E503D82C213BC5567B /* PermissionsHandler+Tracking.swift */, A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */, ); @@ -1448,6 +1441,14 @@ path = Purchasing; sourceTree = ""; }; + 1D9B1E5662EEA7F02C67C4A6 /* Contacts */ = { + isa = PBXGroup; + children = ( + 3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */, + ); + path = Contacts; + sourceTree = ""; + }; 1DE8CF6D5ACEFA6B6349B142 /* Loading */ = { isa = PBXGroup; children = ( @@ -1480,6 +1481,7 @@ ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */, 988E0E3F8D992744C9AC196F /* PermissionStatusTests.swift */, DFD2580D6C95C96CC3051BCB /* PermissionTypeTests.swift */, + 1D9B1E5662EEA7F02C67C4A6 /* Contacts */, 8E656889D53C36053C472FF2 /* Location */, 267D9A6611768B7308E16E21 /* Tracking */, ); @@ -1827,7 +1829,6 @@ isa = PBXGroup; children = ( D198C8645A213EEAD622C881 /* FakeLocationAuthorizationStatus.swift */, - 2378D0EF4F79DDF0BC45B389 /* FakeLocationManager.swift */, 80F46655FBF0A6425DA8EF4B /* LocationManagerProxy.swift */, 6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */, C937320625239F3E10FE8D8E /* PermissionsHandler+Location.swift */, @@ -2417,7 +2418,6 @@ children = ( 6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */, 8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */, - 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */, D561728FDB68F572D5E33223 /* PermissionsHandler+Contacts.swift */, ); path = Contacts; @@ -2525,7 +2525,6 @@ isa = PBXGroup; children = ( B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */, - 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */, D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */, ); path = Microphone; @@ -3258,6 +3257,7 @@ E63BD2E9AA7F1CCC70C889D5 /* ConfigResponseTests.swift in Sources */, AE1D15070BC159212967CAD4 /* ConfirmHoldoutAssignmentTests.swift in Sources */, 61EC2A032D127B323D4A8124 /* ConfirmPaywallAssignmentOperatorTests.swift in Sources */, + CBC90EC17DC1EC4C2FE9DFC8 /* ContactStoreProxyTests.swift in Sources */, AEAB0C0C168B0B3D864109EA /* CoreDataManagerFakeDataMock.swift in Sources */, A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */, C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */, @@ -3486,13 +3486,9 @@ 6DA0EC8307544D09C2478EBB /* ExperimentTemplate.swift in Sources */, 44829144E9EFA0CE4A75BBA1 /* ExpressionLogic.swift in Sources */, C09B8BBB7002DA446E9F74E1 /* FactoryProtocols.swift in Sources */, - C22C4011CB9F4D4FA99F8206 /* FakeAudioSession.swift in Sources */, 07CAD1B0A849A7593B1EF6D4 /* FakeContactsAuthorizationStatus.swift in Sources */, - BF47C2693495CB8C9DAD7C8D /* FakeContactsStore.swift in Sources */, 11798EDE58E5D225E5414F2E /* FakeLocationAuthorizationStatus.swift in Sources */, - BD6ABB9DB883BC62D2407392 /* FakeLocationManager.swift in Sources */, E9D95044254D79D2439D7B3E /* FakeTrackingAuthorizationStatus.swift in Sources */, - B0F7F66E24C7AEDE460F34FE /* FakeTrackingManager.swift in Sources */, E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */, ED1C693657DA7FCBAE2DDDC6 /* FeatureGatingBehaviour.swift in Sources */, 767974DF68CE67AE2066E3D6 /* FileManagerMigrator.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift b/Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift new file mode 100644 index 0000000000..404ef1426b --- /dev/null +++ b/Tests/SuperwallKitTests/Permissions/Contacts/ContactStoreProxyTests.swift @@ -0,0 +1,43 @@ +// +// ContactStoreProxyTests.swift +// SuperwallKitTests +// +// Created by Yusuf Tör on 11/08/2026. +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite +struct ContactStoreProxyTests { + @Test func mangledClassName_decodesCorrectly() { + let className = ContactStoreProxy.mangledContactStoreClassName.rot13() + #expect(className == "CNContactStore") + } + + @Test func selectorNames_areCorrectlyDecoded() { + let proxy = ContactStoreProxy() + + #expect(proxy.authorizationStatusSelectorName == "authorizationStatusForEntityType:") + #expect(proxy.requestAccessSelectorName == "requestAccessForEntityType:completionHandler:") + } +} + +/// `ContactStoreProxy` carried the same `@objc` fake as the other handlers. Pin its +/// guarded path, so the deleted `FakeContactStore` can't quietly return. +@Suite +struct ContactStoreProxyMissingClassTests { + /// -1 is the "unavailable" sentinel `checkContactsPermission()` maps to + /// `.unsupported`, and is what the fake's class method returned. + @Test func missingStore_authorizationStatus_returnsUnavailable() { + let proxy = ContactStoreProxy(contactStoreClass: nil) + #expect(proxy.authorizationStatus() == -1) + } + + @Test func missingStore_requestPermission_returnsFalse() async throws { + let proxy = ContactStoreProxy(contactStoreClass: nil) + let granted = try await proxy.requestPermission() + #expect(granted == false) + } +} diff --git a/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift b/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift index b8322522d5..6680349be6 100644 --- a/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift +++ b/Tests/SuperwallKitTests/Permissions/Location/LocationManagerProxyTests.swift @@ -50,45 +50,36 @@ struct LocationManagerProxyTests { } } -// MARK: - FakeLocationManager Tests +// MARK: - Missing CoreLocation Tests +/// These replace the tests for the deleted `FakeLocationManager`. The fake stood in +/// for `CLLocationManager` when CoreLocation was unavailable, but its `@objc` members +/// emitted Apple's real selector names into the binary — the leak this SDK's mangling +/// exists to prevent. The proxy now guards on a missing class instead, so pin what it +/// returns down that path. @Suite -struct FakeLocationManagerTests { - @Test func authorizationStatus_returnsNotDetermined() { - let manager = FakeLocationManager() - #expect(manager.authorizationStatus == FakeLocationAuthorizationStatus.notDetermined.rawValue) +struct LocationManagerProxyMissingClassTests { + @Test func missingManager_authorizationStatus_returnsNotDetermined() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + #expect(proxy.authorizationStatus() == FakeLocationAuthorizationStatus.notDetermined.rawValue) } - @Test func requestWhenInUseAuthorization_doesNotCrash() { - let manager = FakeLocationManager() - manager.requestWhenInUseAuthorization() - // Should complete without crashing + /// Reports failure rather than the fake's silent success. The caller resumes with + /// `.unsupported` on `false`; with the fake it saw `true` and then waited forever + /// for a delegate callback the fake never made. + @Test func missingManager_requestWhenInUseAuthorization_reportsFailure() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + #expect(proxy.requestWhenInUseAuthorization() == false) } - @Test func requestAlwaysAuthorization_doesNotCrash() { - let manager = FakeLocationManager() - manager.requestAlwaysAuthorization() - // Should complete without crashing + @Test func missingManager_requestAlwaysAuthorization_reportsFailure() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + #expect(proxy.requestAlwaysAuthorization() == false) } - @Test func delegate_canBeSet() { - let manager = FakeLocationManager() - let delegate = NSObject() - - manager.delegate = delegate - #expect(manager.delegate === delegate) - } - - @Test func delegate_isWeak() { - let manager = FakeLocationManager() - - autoreleasepool { - let delegate = NSObject() - manager.delegate = delegate - #expect(manager.delegate != nil) - } - - // After autoreleasepool, the delegate should be deallocated - #expect(manager.delegate == nil) + @Test func missingManager_setDelegate_doesNotCrash() { + let proxy = LocationManagerProxy(locationManagerClass: nil) + proxy.setDelegate(LocationPermissionDelegate { _ in }) + proxy.setDelegate(nil) } } diff --git a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift index 659f471883..a7c64b2ece 100644 --- a/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift +++ b/Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift @@ -78,32 +78,34 @@ struct AudioSessionProxyTests { @Test func sharedInstance_returnsNonNil() { let proxy = AudioSessionProxy() - // In test environment, this should return either real AVAudioSession or FakeAudioSession + // AVAudioSession resolves at runtime on every platform the tests run on. let instance = proxy.sharedInstance() #expect(instance != nil) } } +/// These replace the tests for the deleted `FakeAudioSession`. The fake stood in for +/// `AVAudioSession` when AVFoundation was unavailable, but its `@objc` members emitted +/// Apple's real selector names into the binary — the leak this SDK's mangling exists to +/// prevent. The proxy now guards on a missing class instead, so pin what it returns +/// down that path. @Suite -struct FakeAudioSessionTests { - @Test func sharedInstance_returnsFakeAudioSession() { - let instance = FakeAudioSession.sharedInstance() - #expect(instance is FakeAudioSession) +struct AudioSessionProxyMissingClassTests { + @Test func missingSession_sharedInstance_returnsNil() { + let proxy = AudioSessionProxy(audioSessionClass: nil) + #expect(proxy.sharedInstance() == nil) } - @Test func recordPermission_returnsNegativeOne() { - let fake = FakeAudioSession() - #expect(fake.recordPermission() == -1) + /// -1 is the "unavailable" sentinel `checkMicrophonePermission()` maps to + /// `.unsupported`, and is what the fake's `recordPermission()` returned. + @Test func missingSession_recordPermission_returnsUnavailable() { + let proxy = AudioSessionProxy(audioSessionClass: nil) + #expect(proxy.recordPermission() == -1) } - @Test func requestRecordPermission_callsCompletionWithFalse() { - let fake = FakeAudioSession() - var result: Bool? - - fake.requestRecordPermission { granted in - result = granted - } - - #expect(result == false) + @Test func missingSession_requestPermission_returnsFalse() async { + let proxy = AudioSessionProxy(audioSessionClass: nil) + let granted = await proxy.requestPermission() + #expect(granted == false) } } diff --git a/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift b/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift index 860c0cdb8d..0963847d19 100644 --- a/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift +++ b/Tests/SuperwallKitTests/Permissions/Tracking/TrackingManagerProxyTests.swift @@ -33,23 +33,22 @@ struct TrackingManagerProxyTests { // Should return a valid ATTrackingManager.AuthorizationStatus value (0-3) #expect(status >= 0 && status <= 3) } -} -// MARK: - FakeTrackingManager Tests + @Test func trackingPlistKey_isCorrectlyDecoded() { + #expect(PermissionHandler.PlistKey.tracking == "NSUserTrackingUsageDescription") + } + + @Test func missingManager_trackingAuthorizationStatus_returnsNotDetermined() { + let proxy = TrackingManagerProxy(trackingManagerClass: nil) + let status = proxy.trackingAuthorizationStatus() -@Suite -struct FakeTrackingManagerTests { - @Test func trackingAuthorizationStatus_returnsNotDetermined() { - let status = FakeTrackingManager.trackingAuthorizationStatus #expect(status == FakeTrackingAuthorizationStatus.notDetermined.rawValue) } - @Test func requestTrackingAuthorization_callsCompletionWithNotDetermined() async { - await withCheckedContinuation { continuation in - FakeTrackingManager.requestTrackingAuthorization { status in - #expect(status == FakeTrackingAuthorizationStatus.notDetermined.rawValue) - continuation.resume() - } - } + @Test func missingManager_requestAuthorization_returnsNotDetermined() async { + let proxy = TrackingManagerProxy(trackingManagerClass: nil) + let status = await proxy.requestAuthorization() + + #expect(status == FakeTrackingAuthorizationStatus.notDetermined.rawValue) } } diff --git a/scripts/scan-privacy-signatures.sh b/scripts/scan-privacy-signatures.sh new file mode 100755 index 0000000000..e15154a74e --- /dev/null +++ b/scripts/scan-privacy-signatures.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Copyright (c) Nest22. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +# +# Fails when a permission API name Apple's App Store Connect scanner reacts to is +# compiled into the framework binary. +# +# The SDK reaches these APIs through the Objective-C runtime with ROT13-encoded class +# and selector names, so a correct build contains none of them in plaintext. They come +# back in two ways, both of which this catches: a string literal (an un-mangled plist +# key) lands in __TEXT,__cstring, and an `@objc` member (a fake stand-in class) lands +# in __TEXT,__objc_methname. Unit tests can't see either — only the built binary can. +# +# Scans those two sections rather than running `strings` over the whole binary: Swift +# mangles internal symbol names using the source names, so a debug binary legitimately +# contains "trackingAuthorizationStatus" inside `TrackingManagerProxy`'s own symbols. +# Matching on that would fail forever with nothing to fix. + +set -e + +cd "$(dirname "$0")/.." + +BINARY="$1" + +if [ -z "$BINARY" ]; then + echo "usage: $0 " + exit 2 +fi + +if [ ! -f "$BINARY" ]; then + echo "❌ Not a file: $BINARY" + exit 2 +fi + +# Add a name here when the SDK starts reaching a new permission API by runtime lookup. +# +# Entries are bare names, not full selectors: a leaked name can wear several suffixes +# — "requestRecordPermission:" from an `@objc` member, "requestRecordPermission()" +# from `withCheckedContinuation`'s `#function` default — and the bare form matches +# them all. An entry must never legitimately appear in the scanned sections, so a hit +# is always a real leak. +# +# Deliberately absent: `LocationPermissionDelegate`'s callback selectors +# (locationManagerDidChangeAuthorization:, locationManager:didChangeAuthorization:). +# CLLocationManager dispatches those by selector at runtime, so their metadata has to +# exist for the callbacks to arrive. See the note on that class. +# +# Also absent: names from the camera, photos, and notification handlers. Those call +# their frameworks directly rather than through mangled runtime lookups — e.g. +# `AVCaptureDevice.requestAccess(for:)` legitimately emits +# `requestAccessForMediaType:completionHandler:` — so their names in the binary are +# how the SDK works today, not a leak. If they ever join the proxy scheme, add them. +# +# Also absent, and unlike the above this one is a genuine `@objc` shim: `FakeASIdManager` +# (its class name, and its `sharedManager` selector). It survives where the four +# permission fakes were deleted because it is load-bearing — it's what makes +# `classType.sharedManager()` typecheck through AnyObject lookup — and `sharedManager` +# is a generic Cocoa selector shared by many classes, so it fingerprints nothing. The +# AdSupport names that would fingerprint (the class and property below) are mangled, and +# guarded here. +FORBIDDEN=( + # Tracking — the one App Store Connect actively warns about. + "NSUserTrackingUsageDescription" + "ATTrackingManager" + "requestTrackingAuthorization" + "trackingAuthorizationStatus" + # Microphone, location, and contacts — reached the same way, so hold them to the + # same standard even though no scanner is known to flag them. + "AVAudioSession" + "recordPermission" + "requestRecordPermission" + "CLLocationManager" + "requestWhenInUseAuthorization" + "requestAlwaysAuthorization" + "CNContactStore" + "requestAccessForEntityType" + # AdSupport (IDFA) — reached through the same runtime-lookup proxy, which mangles + # both the class name and the `advertisingIdentifier` property. + "ASIdentifierManager" + "advertisingIdentifier" +) + +echo "🔍 Scanning $(basename "$BINARY") for privacy API signatures..." + +SECTIONS=$( + { + otool -v -s __TEXT __objc_methname "$BINARY" 2>/dev/null + otool -v -s __TEXT __objc_classname "$BINARY" 2>/dev/null + otool -v -s __TEXT __cstring "$BINARY" 2>/dev/null + } +) + +if [ -z "$SECTIONS" ]; then + echo "❌ Could not read Objective-C metadata from $BINARY." + exit 2 +fi + +FOUND=() +for name in "${FORBIDDEN[@]}"; do + if grep -qF "$name" <<< "$SECTIONS"; then + FOUND+=("$name") + fi +done + +if [ ${#FOUND[@]} -ne 0 ]; then + echo "❌ Found permission API signatures in the compiled binary:" + for name in "${FOUND[@]}"; do + echo " - $name" + done + echo "" + echo " These reach the binary from a plaintext string literal or an @objc member." + echo " Store the name ROT13-encoded and decode it at runtime, and don't declare" + echo " @objc stand-in classes that mirror Apple's selectors — guard on the missing" + echo " class instead. See Sources/SuperwallKit/Permissions/Handlers/." + exit 1 +fi + +echo "✅ No privacy API signatures found."