Skip to content

Commit 9f912c8

Browse files
authored
Merge pull request #414 from superwall/develop
4.12.4
2 parents cba81af + 051eadb commit 9f912c8

30 files changed

+1364
-70
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.
44

5+
## 4.12.4
6+
7+
### Enhancements
8+
9+
- Adds back in contacts and location permission requests but this time will not get flagged in App Store review if they're not being used.
10+
- Adds App Tracking Transparency permission request.
11+
512
## 4.12.3
613

714
### Fixes

Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/SuperwallKit/Misc/Constants.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ let sdkVersion = """
1818
*/
1919

2020
let sdkVersion = """
21-
4.12.3
21+
4.12.4
2222
"""

Sources/SuperwallKit/Permissions/AuthorizationStatus+PermissionStatus.swift

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
//
77

88
import AVFoundation
9-
import Contacts
10-
import CoreLocation
119
import Photos
1210
import UserNotifications
1311

@@ -24,74 +22,104 @@ extension UNAuthorizationStatus {
2422
}
2523
}
2624

27-
extension CLAuthorizationStatus {
25+
26+
extension PHAuthorizationStatus {
2827
var toPermissionStatus: PermissionStatus {
2928
switch self {
30-
case .authorizedWhenInUse, .authorizedAlways:
29+
case .authorized,
30+
.limited:
3131
return .granted
32-
case .denied, .restricted, .notDetermined:
32+
case .denied,
33+
.restricted,
34+
.notDetermined:
3335
return .denied
3436
@unknown default:
3537
return .unsupported
3638
}
3739
}
40+
}
3841

39-
var toBackgroundPermissionStatus: PermissionStatus {
42+
@available(macCatalyst 14.0, *)
43+
extension AVAuthorizationStatus {
44+
var toPermissionStatus: PermissionStatus {
4045
switch self {
41-
case .authorizedAlways:
46+
case .authorized:
4247
return .granted
43-
case .authorizedWhenInUse, .denied, .restricted, .notDetermined:
48+
case .denied,
49+
.restricted,
50+
.notDetermined:
4451
return .denied
4552
@unknown default:
4653
return .unsupported
4754
}
4855
}
4956
}
5057

51-
extension PHAuthorizationStatus {
52-
var toPermissionStatus: PermissionStatus {
58+
extension Int {
59+
var toContactsPermissionStatus: PermissionStatus {
60+
// CNAuthorizationStatus:
61+
// 0 notDetermined
62+
// 1 restricted
63+
// 2 denied
64+
// 3 authorized
65+
// 4 limited (iOS 18+)
5366
switch self {
54-
case .authorized,
55-
.limited:
67+
case 3, 4:
5668
return .granted
57-
case .denied,
58-
.restricted,
59-
.notDetermined:
69+
case 0, 1, 2:
6070
return .denied
61-
@unknown default:
62-
return .unsupported
71+
default:
72+
// Mirrors your @unknown default policy
73+
return .granted
6374
}
6475
}
65-
}
6676

67-
extension CNAuthorizationStatus {
68-
var toPermissionStatus: PermissionStatus {
77+
var toLocationPermissionStatus: PermissionStatus {
78+
// CLAuthorizationStatus:
79+
// 0 notDetermined
80+
// 1 restricted
81+
// 2 denied
82+
// 3 authorizedAlways
83+
// 4 authorizedWhenInUse
6984
switch self {
70-
case .authorized,
71-
.limited:
85+
case 3, 4:
7286
return .granted
73-
case .denied,
74-
.restricted,
75-
.notDetermined:
87+
case 0, 1, 2:
7688
return .denied
77-
@unknown default:
78-
// Handles .limited on iOS 18+ and future cases
89+
default:
90+
return .unsupported
91+
}
92+
}
93+
94+
var toBackgroundLocationPermissionStatus: PermissionStatus {
95+
// CLAuthorizationStatus:
96+
// 0 notDetermined
97+
// 1 restricted
98+
// 2 denied
99+
// 3 authorizedAlways
100+
// 4 authorizedWhenInUse
101+
switch self {
102+
case 3:
79103
return .granted
104+
case 0, 1, 2, 4:
105+
return .denied
106+
default:
107+
return .unsupported
80108
}
81109
}
82-
}
83110

84-
@available(macCatalyst 14.0, *)
85-
extension AVAuthorizationStatus {
86-
var toPermissionStatus: PermissionStatus {
111+
var toTrackingPermissionStatus: PermissionStatus {
112+
// ATTrackingManager.AuthorizationStatus:
113+
// 0 notDetermined
114+
// 1 restricted
115+
// 2 denied
116+
// 3 authorized
87117
switch self {
88-
case .authorized:
118+
case 3:
89119
return .granted
90-
case .denied,
91-
.restricted,
92-
.notDetermined:
120+
case 0, 1, 2:
93121
return .denied
94-
@unknown default:
122+
default:
95123
return .unsupported
96124
}
97125
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// ContactStoreProxy.swift
3+
// SuperwallKit
4+
//
5+
// Created by Yusuf Tör on 13/01/2026.
6+
//
7+
8+
// This proxy accesses CNContactStore using Objective-C runtime to avoid
9+
// directly importing Contacts framework. This prevents the framework from being
10+
// automatically linked, which could cause App Store review issues for apps
11+
// that don't actually use contacts. Class and selector names are ROT13-encoded
12+
// to avoid static analysis detection.
13+
14+
import Foundation
15+
import ObjectiveC.runtime
16+
17+
final class ContactStoreProxy: NSObject {
18+
// ROT13("CNContactStore")
19+
static let mangledContactStoreClassName = "PAPbagnpgFgber"
20+
21+
// ROT13("authorizationStatusForEntityType:")
22+
static let mangledAuthorizationStatusSelector =
23+
"nhgubevmngvbaFgnghfSbeRagvglGlcr:"
24+
25+
// ROT13("requestAccessForEntityType:completionHandler:")
26+
static let mangledRequestAccessSelector =
27+
"erdhrfgNpprffSbeRagvglGlcr:pbzcyrgvbaUnaqyre:"
28+
29+
// CNEntityType.contacts == 0
30+
static let contactsEntityType = 0
31+
32+
static var contactStoreClass: AnyClass? {
33+
NSClassFromString(mangledContactStoreClassName.rot13())
34+
}
35+
36+
@objc var authorizationStatusSelectorName: String {
37+
Self.mangledAuthorizationStatusSelector.rot13()
38+
}
39+
40+
@objc var requestAccessSelectorName: String {
41+
Self.mangledRequestAccessSelector.rot13()
42+
}
43+
44+
private static func classIMP(_ cls: AnyClass, _ sel: Selector) -> IMP? {
45+
guard let method = class_getClassMethod(cls, sel) else { return nil }
46+
return method_getImplementation(method)
47+
}
48+
49+
private static func instanceIMP(_ cls: AnyClass, _ sel: Selector) -> IMP? {
50+
guard let method = class_getInstanceMethod(cls, sel) else { return nil }
51+
return method_getImplementation(method)
52+
}
53+
54+
@objc func authorizationStatus() -> Int {
55+
let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self
56+
let sel = NSSelectorFromString(authorizationStatusSelectorName)
57+
58+
guard let imp = Self.classIMP(cls, sel) else {
59+
return -1
60+
}
61+
62+
typealias Function = @convention(c) (AnyObject, Selector, Int) -> Int
63+
let function = unsafeBitCast(imp, to: Function.self)
64+
65+
// For class methods, the receiver is the class object itself.
66+
return function(cls as AnyObject, sel, Self.contactsEntityType)
67+
}
68+
69+
func requestAccess() async throws -> Bool {
70+
let cls: AnyClass = Self.contactStoreClass ?? FakeContactStore.self
71+
72+
guard let storeType = cls as? NSObject.Type else {
73+
return false
74+
}
75+
76+
let store = storeType.init()
77+
let sel = NSSelectorFromString(requestAccessSelectorName)
78+
79+
guard let imp = Self.instanceIMP(type(of: store), sel) else {
80+
return false
81+
}
82+
83+
return try await withCheckedThrowingContinuation { continuation in
84+
let completion: @convention(block) (Bool, AnyObject?) -> Void = { granted, error in
85+
if let error = error as? Error {
86+
continuation.resume(throwing: error)
87+
} else {
88+
continuation.resume(returning: granted)
89+
}
90+
}
91+
92+
typealias Function = @convention(c) (AnyObject, Selector, Int, AnyObject) -> Void
93+
let function = unsafeBitCast(imp, to: Function.self)
94+
95+
function(store, sel, Self.contactsEntityType, completion as AnyObject)
96+
}
97+
}
98+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// PermissionHandler+Contacts.swift
3+
// SuperwallKit
4+
//
5+
// Created by Yusuf Tör on 13/01/2026.
6+
//
7+
8+
import Foundation
9+
10+
@objc enum FakeContactsAuthorizationStatus: Int {
11+
case notDetermined = 0
12+
case restricted
13+
case denied
14+
case authorized
15+
}
16+
17+
extension FakeContactsAuthorizationStatus: CustomStringConvertible {
18+
var description: String {
19+
switch self {
20+
case .notDetermined: return "notDetermined"
21+
case .restricted: return "restricted"
22+
case .denied: return "denied"
23+
case .authorized: return "authorized"
24+
}
25+
}
26+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// FakeContactsStore.swift
3+
// SuperwallKit
4+
//
5+
// Created by Yusuf Tör on 13/01/2026.
6+
//
7+
8+
import Foundation
9+
10+
final class FakeContactStore: NSObject {
11+
// Class method
12+
@objc static func authorizationStatusForEntityType(_ entityType: Int) -> Int {
13+
-1
14+
}
15+
16+
// Instance method
17+
@objc func requestAccessForEntityType(
18+
_ entityType: Int,
19+
completionHandler: @escaping (Bool, NSError?) -> Void
20+
) {
21+
completionHandler(false, nil)
22+
}
23+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//
2+
// PermissionsHandler+Contacts.swift
3+
// SuperwallKit
4+
//
5+
// Created by Yusuf Tör on 13/01/2026.
6+
//
7+
8+
extension PermissionHandler {
9+
func checkContactsPermission() -> PermissionStatus {
10+
let proxy = ContactStoreProxy()
11+
let raw = proxy.authorizationStatus()
12+
guard raw >= 0 else {
13+
return .unsupported
14+
}
15+
return raw.toContactsPermissionStatus
16+
}
17+
18+
@MainActor
19+
func requestContactsPermission() async -> PermissionStatus {
20+
guard hasPlistKey(PlistKey.contacts) else {
21+
await showMissingPlistKeyAlert(for: PlistKey.contacts, permissionName: "Contacts")
22+
return .unsupported
23+
}
24+
25+
if checkContactsPermission() == .granted {
26+
return .granted
27+
}
28+
29+
do {
30+
let proxy = ContactStoreProxy()
31+
let granted = try await proxy.requestAccess()
32+
return granted ? .granted : .denied
33+
} catch {
34+
Logger.debug(
35+
logLevel: .error,
36+
scope: .paywallViewController,
37+
message: "Error requesting contacts permission",
38+
error: error
39+
)
40+
return .denied
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)