@virex-tech/paywallo-sdk 1.5.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PaywalloSdk.podspec +1 -1
- package/android/build.gradle +2 -1
- package/android/src/main/AndroidManifest.xml +21 -0
- package/android/src/main/java/com/paywallo/sdk/PaywalloFirebaseMessagingService.kt +67 -0
- package/android/src/main/java/com/paywallo/sdk/PaywalloNotificationsModule.kt +169 -0
- package/android/src/main/java/com/paywallo/sdk/PaywalloSdkPackage.kt +2 -1
- package/android/src/main/java/com/paywallo/sdk/PaywalloStoreKitModule.kt +103 -0
- package/dist/index.d.mts +560 -39
- package/dist/index.d.ts +560 -39
- package/dist/index.js +6841 -3715
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6829 -3705
- package/dist/index.mjs.map +1 -1
- package/ios/PaywalloNotifications.m +19 -0
- package/ios/PaywalloNotifications.swift +263 -0
- package/ios/PaywalloNotificationsSwizzle.swift +126 -0
- package/ios/PaywalloStoreKit.m +4 -1
- package/ios/PaywalloStoreKit.swift +152 -8
- package/ios/PaywalloWebView.m +2 -3
- package/package.json +11 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
#import <React/RCTEventEmitter.h>
|
|
3
|
+
|
|
4
|
+
@interface RCT_EXTERN_MODULE(PaywalloNotifications, RCTEventEmitter)
|
|
5
|
+
|
|
6
|
+
RCT_EXTERN_METHOD(requestPermission:(NSDictionary *)options
|
|
7
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
8
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
9
|
+
|
|
10
|
+
RCT_EXTERN_METHOD(hasPermission:(RCTPromiseResolveBlock)resolve
|
|
11
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
12
|
+
|
|
13
|
+
RCT_EXTERN_METHOD(getToken:(RCTPromiseResolveBlock)resolve
|
|
14
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
15
|
+
|
|
16
|
+
RCT_EXTERN_METHOD(getInitialNotification:(RCTPromiseResolveBlock)resolve
|
|
17
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
18
|
+
|
|
19
|
+
@end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
import UserNotifications
|
|
4
|
+
|
|
5
|
+
@objc(PaywalloNotifications)
|
|
6
|
+
class PaywalloNotifications: RCTEventEmitter {
|
|
7
|
+
|
|
8
|
+
// MARK: - Event Names
|
|
9
|
+
private static let tokenRefreshEvent = "PaywalloTokenRefresh"
|
|
10
|
+
private static let messageReceivedEvent = "PaywalloMessageReceived"
|
|
11
|
+
private static let notificationOpenedEvent = "PaywalloNotificationOpened"
|
|
12
|
+
|
|
13
|
+
// MARK: - Pre-subscribe buffer (same pattern as PaywalloStoreKit)
|
|
14
|
+
// hasListeners is read/written from bufferQueue to avoid races between
|
|
15
|
+
// startObserving (main thread) and bufferOrEmit (NotificationCenter delivery thread).
|
|
16
|
+
private var _hasListeners = false
|
|
17
|
+
private var preSubscribeBuffer: [[String: Any]] = []
|
|
18
|
+
private let bufferQueue = DispatchQueue(label: "com.paywallo.notifications.buffer")
|
|
19
|
+
private static let maxBufferSize = 100
|
|
20
|
+
|
|
21
|
+
// MARK: - State
|
|
22
|
+
private static var latestAPNSToken: String?
|
|
23
|
+
private static var initialNotification: [String: Any]?
|
|
24
|
+
|
|
25
|
+
// MARK: - RCTEventEmitter Overrides
|
|
26
|
+
|
|
27
|
+
override static func requiresMainQueueSetup() -> Bool { return false }
|
|
28
|
+
|
|
29
|
+
override func supportedEvents() -> [String] {
|
|
30
|
+
return [
|
|
31
|
+
PaywalloNotifications.tokenRefreshEvent,
|
|
32
|
+
PaywalloNotifications.messageReceivedEvent,
|
|
33
|
+
PaywalloNotifications.notificationOpenedEvent,
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override func startObserving() {
|
|
38
|
+
bufferQueue.sync { _hasListeners = true }
|
|
39
|
+
drainPreSubscribeBuffer()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override func stopObserving() {
|
|
43
|
+
bufferQueue.sync { _hasListeners = false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MARK: - Buffer Management
|
|
47
|
+
|
|
48
|
+
private func bufferOrEmit(eventName: String, body: [String: Any]) {
|
|
49
|
+
// Determine whether to emit or buffer inside the lock, but never call
|
|
50
|
+
// sendEvent (which may dispatch back to main) while holding the lock —
|
|
51
|
+
// that would risk deadlock or re-entrancy on a sync queue.
|
|
52
|
+
let shouldEmit: Bool = bufferQueue.sync {
|
|
53
|
+
if _hasListeners {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
var entry = body
|
|
57
|
+
entry["_eventName"] = eventName
|
|
58
|
+
if preSubscribeBuffer.count >= PaywalloNotifications.maxBufferSize {
|
|
59
|
+
preSubscribeBuffer.removeFirst()
|
|
60
|
+
}
|
|
61
|
+
preSubscribeBuffer.append(entry)
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
if shouldEmit {
|
|
65
|
+
sendEvent(withName: eventName, body: body)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func drainPreSubscribeBuffer() {
|
|
70
|
+
// Copy and clear while holding the lock; emit outside the lock.
|
|
71
|
+
let drained: [[String: Any]] = bufferQueue.sync {
|
|
72
|
+
let copy = preSubscribeBuffer
|
|
73
|
+
preSubscribeBuffer.removeAll()
|
|
74
|
+
return copy
|
|
75
|
+
}
|
|
76
|
+
for entry in drained {
|
|
77
|
+
var body = entry
|
|
78
|
+
guard let eventName = body.removeValue(forKey: "_eventName") as? String else { continue }
|
|
79
|
+
sendEvent(withName: eventName, body: body)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - Public Methods (exposed to JS)
|
|
84
|
+
|
|
85
|
+
@objc func requestPermission(
|
|
86
|
+
_ options: NSDictionary,
|
|
87
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
88
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
89
|
+
) {
|
|
90
|
+
let provisional = options["provisional"] as? Bool ?? false
|
|
91
|
+
let center = UNUserNotificationCenter.current()
|
|
92
|
+
|
|
93
|
+
var authOptions: UNAuthorizationOptions = [.alert, .sound, .badge]
|
|
94
|
+
if provisional {
|
|
95
|
+
authOptions.insert(.provisional)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
center.requestAuthorization(options: authOptions) { granted, error in
|
|
99
|
+
if let error = error {
|
|
100
|
+
reject("PERMISSION_ERROR", error.localizedDescription, error)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
DispatchQueue.main.async {
|
|
105
|
+
UIApplication.shared.registerForRemoteNotifications()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Return status string matching SDK's PushPermissionStatus type
|
|
109
|
+
self.getPermissionStatusString { status in
|
|
110
|
+
resolve(status)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@objc func hasPermission(
|
|
116
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
117
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
118
|
+
) {
|
|
119
|
+
getPermissionStatusString { status in
|
|
120
|
+
resolve(status)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@objc func getToken(
|
|
125
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
126
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
127
|
+
) {
|
|
128
|
+
if let token = PaywalloNotifications.latestAPNSToken {
|
|
129
|
+
resolve(token)
|
|
130
|
+
} else {
|
|
131
|
+
resolve(nil)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@objc func getInitialNotification(
|
|
136
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
137
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
138
|
+
) {
|
|
139
|
+
if let notification = PaywalloNotifications.initialNotification {
|
|
140
|
+
PaywalloNotifications.initialNotification = nil // consume once
|
|
141
|
+
resolve(notification)
|
|
142
|
+
} else {
|
|
143
|
+
resolve(nil)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// MARK: - Permission Helper
|
|
148
|
+
|
|
149
|
+
private func getPermissionStatusString(completion: @escaping (String) -> Void) {
|
|
150
|
+
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
151
|
+
let status: String
|
|
152
|
+
switch settings.authorizationStatus {
|
|
153
|
+
case .authorized: status = "granted"
|
|
154
|
+
case .denied: status = "denied"
|
|
155
|
+
case .provisional: status = "provisional"
|
|
156
|
+
case .notDetermined: status = "notDetermined"
|
|
157
|
+
case .ephemeral: status = "ephemeral"
|
|
158
|
+
@unknown default: status = "notDetermined"
|
|
159
|
+
}
|
|
160
|
+
completion(status)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// MARK: - Static methods called from swizzled AppDelegate
|
|
165
|
+
|
|
166
|
+
@objc static func didRegisterForRemoteNotifications(deviceToken: Data) {
|
|
167
|
+
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
|
|
168
|
+
latestAPNSToken = token
|
|
169
|
+
// Emit to JS — need to find the module instance through the bridge
|
|
170
|
+
// This is handled via NotificationCenter since we can't access the bridge statically
|
|
171
|
+
NotificationCenter.default.post(
|
|
172
|
+
name: NSNotification.Name("PaywalloAPNSTokenReceived"),
|
|
173
|
+
object: nil,
|
|
174
|
+
userInfo: ["token": token]
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@objc static func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) {
|
|
179
|
+
let payload = extractPayload(from: userInfo)
|
|
180
|
+
NotificationCenter.default.post(
|
|
181
|
+
name: NSNotification.Name("PaywalloRemoteNotificationReceived"),
|
|
182
|
+
object: nil,
|
|
183
|
+
userInfo: ["payload": payload]
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@objc static func didOpenNotification(userInfo: [AnyHashable: Any]) {
|
|
188
|
+
let payload = extractPayload(from: userInfo)
|
|
189
|
+
// If module not ready yet, store as initial notification
|
|
190
|
+
initialNotification = payload
|
|
191
|
+
NotificationCenter.default.post(
|
|
192
|
+
name: NSNotification.Name("PaywalloNotificationOpened"),
|
|
193
|
+
object: nil,
|
|
194
|
+
userInfo: ["payload": payload]
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// MARK: - Payload Extraction
|
|
199
|
+
|
|
200
|
+
private static func extractPayload(from userInfo: [AnyHashable: Any]) -> [String: Any] {
|
|
201
|
+
var payload: [String: Any] = [:]
|
|
202
|
+
|
|
203
|
+
if let aps = userInfo["aps"] as? [String: Any] {
|
|
204
|
+
if let alert = aps["alert"] as? [String: Any] {
|
|
205
|
+
payload["title"] = alert["title"] as? String ?? ""
|
|
206
|
+
payload["body"] = alert["body"] as? String ?? ""
|
|
207
|
+
} else if let alert = aps["alert"] as? String {
|
|
208
|
+
payload["body"] = alert
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Extract custom data (everything except "aps")
|
|
213
|
+
var data: [String: String] = [:]
|
|
214
|
+
for (key, value) in userInfo {
|
|
215
|
+
guard let key = key as? String, key != "aps" else { continue }
|
|
216
|
+
data[key] = "\(value)"
|
|
217
|
+
}
|
|
218
|
+
if !data.isEmpty {
|
|
219
|
+
payload["data"] = data
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return payload
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// MARK: - NotificationCenter observers (bridge between static calls and instance)
|
|
226
|
+
|
|
227
|
+
override init() {
|
|
228
|
+
super.init()
|
|
229
|
+
PaywalloNotificationsSwizzle.swizzleIfNeeded()
|
|
230
|
+
|
|
231
|
+
NotificationCenter.default.addObserver(
|
|
232
|
+
self, selector: #selector(onAPNSTokenReceived(_:)),
|
|
233
|
+
name: NSNotification.Name("PaywalloAPNSTokenReceived"), object: nil
|
|
234
|
+
)
|
|
235
|
+
NotificationCenter.default.addObserver(
|
|
236
|
+
self, selector: #selector(onRemoteNotificationReceived(_:)),
|
|
237
|
+
name: NSNotification.Name("PaywalloRemoteNotificationReceived"), object: nil
|
|
238
|
+
)
|
|
239
|
+
NotificationCenter.default.addObserver(
|
|
240
|
+
self, selector: #selector(onNotificationOpened(_:)),
|
|
241
|
+
name: NSNotification.Name("PaywalloNotificationOpened"), object: nil
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
deinit {
|
|
246
|
+
NotificationCenter.default.removeObserver(self)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@objc private func onAPNSTokenReceived(_ notification: Notification) {
|
|
250
|
+
guard let token = notification.userInfo?["token"] as? String else { return }
|
|
251
|
+
bufferOrEmit(eventName: PaywalloNotifications.tokenRefreshEvent, body: ["token": token])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@objc private func onRemoteNotificationReceived(_ notification: Notification) {
|
|
255
|
+
guard let payload = notification.userInfo?["payload"] as? [String: Any] else { return }
|
|
256
|
+
bufferOrEmit(eventName: PaywalloNotifications.messageReceivedEvent, body: payload)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@objc private func onNotificationOpened(_ notification: Notification) {
|
|
260
|
+
guard let payload = notification.userInfo?["payload"] as? [String: Any] else { return }
|
|
261
|
+
bufferOrEmit(eventName: PaywalloNotifications.notificationOpenedEvent, body: payload)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Swizzles UIApplicationDelegate methods to intercept APNS callbacks.
|
|
5
|
+
/// Runs automatically at load time via swizzleIfNeeded() called from PaywalloNotifications.init().
|
|
6
|
+
/// Same approach used by OneSignal, Firebase, and other push SDKs.
|
|
7
|
+
@objc class PaywalloNotificationsSwizzle: NSObject {
|
|
8
|
+
|
|
9
|
+
private static var hasSwizzled = false
|
|
10
|
+
|
|
11
|
+
/// Call once at module init time to swizzle AppDelegate APNS methods.
|
|
12
|
+
@objc static func swizzleIfNeeded() {
|
|
13
|
+
guard !hasSwizzled else { return }
|
|
14
|
+
|
|
15
|
+
// Defer to main queue — UIApplication.shared.delegate is set after app launch.
|
|
16
|
+
// Set hasSwizzled only after the delegate is confirmed, so a nil-delegate
|
|
17
|
+
// early call doesn't permanently suppress swizzling.
|
|
18
|
+
DispatchQueue.main.async {
|
|
19
|
+
guard let delegate = UIApplication.shared.delegate else { return }
|
|
20
|
+
guard !hasSwizzled else { return }
|
|
21
|
+
hasSwizzled = true
|
|
22
|
+
let delegateClass: AnyClass = type(of: delegate)
|
|
23
|
+
|
|
24
|
+
swizzleDidRegisterForRemoteNotifications(delegateClass)
|
|
25
|
+
swizzleDidFailToRegisterForRemoteNotifications(delegateClass)
|
|
26
|
+
swizzleDidReceiveRemoteNotification(delegateClass)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MARK: - didRegisterForRemoteNotificationsWithDeviceToken
|
|
31
|
+
|
|
32
|
+
private static func swizzleDidRegisterForRemoteNotifications(_ cls: AnyClass) {
|
|
33
|
+
let original = #selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:))
|
|
34
|
+
let swizzled = #selector(PaywalloNotificationsSwizzle.pw_didRegisterForRemoteNotifications(_:deviceToken:))
|
|
35
|
+
swizzle(cls: cls, original: original, swizzled: swizzled)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MARK: - didFailToRegisterForRemoteNotificationsWithError
|
|
39
|
+
|
|
40
|
+
private static func swizzleDidFailToRegisterForRemoteNotifications(_ cls: AnyClass) {
|
|
41
|
+
let original = #selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:))
|
|
42
|
+
let swizzled = #selector(PaywalloNotificationsSwizzle.pw_didFailToRegisterForRemoteNotifications(_:error:))
|
|
43
|
+
swizzle(cls: cls, original: original, swizzled: swizzled)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MARK: - didReceiveRemoteNotification (with completion handler)
|
|
47
|
+
|
|
48
|
+
private static func swizzleDidReceiveRemoteNotification(_ cls: AnyClass) {
|
|
49
|
+
let original = #selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
|
|
50
|
+
let swizzled = #selector(PaywalloNotificationsSwizzle.pw_didReceiveRemoteNotification(_:userInfo:completionHandler:))
|
|
51
|
+
swizzle(cls: cls, original: original, swizzled: swizzled)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// MARK: - Core swizzle helper
|
|
55
|
+
|
|
56
|
+
/// Track whether the original method existed before swizzle for each selector,
|
|
57
|
+
/// so the pw_ implementations know whether to forward (exchange case) or not (add case).
|
|
58
|
+
private static var didExchange: [String: Bool] = [:]
|
|
59
|
+
|
|
60
|
+
/// Exchanges implementations if the method exists on cls, otherwise adds it directly.
|
|
61
|
+
private static func swizzle(cls: AnyClass, original: Selector, swizzled: Selector) {
|
|
62
|
+
guard let swizzledMethod = class_getInstanceMethod(PaywalloNotificationsSwizzle.self, swizzled) else {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let key = NSStringFromSelector(original)
|
|
67
|
+
|
|
68
|
+
if let originalMethod = class_getInstanceMethod(cls, original) {
|
|
69
|
+
// Method exists on the AppDelegate — exchange so both run.
|
|
70
|
+
// After exchange: calling `original` runs our pw_ body,
|
|
71
|
+
// calling `pw_` from within pw_ runs the ORIGINAL body (not recursion).
|
|
72
|
+
method_exchangeImplementations(originalMethod, swizzledMethod)
|
|
73
|
+
didExchange[key] = true
|
|
74
|
+
} else {
|
|
75
|
+
// Method not implemented in the host AppDelegate — add our impl directly.
|
|
76
|
+
// pw_ methods must NOT call themselves here (no original to forward to).
|
|
77
|
+
let types = method_getTypeEncoding(swizzledMethod)
|
|
78
|
+
class_addMethod(cls, original, method_getImplementation(swizzledMethod), types)
|
|
79
|
+
didExchange[key] = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - Swizzled implementations
|
|
84
|
+
// After method_exchangeImplementations:
|
|
85
|
+
// - The original selector on AppDelegate points to our pw_ body
|
|
86
|
+
// - Calling pw_ from within itself calls the ORIGINAL AppDelegate implementation
|
|
87
|
+
// (because the IMP under pw_ was exchanged to the original). Not infinite recursion.
|
|
88
|
+
//
|
|
89
|
+
// When class_addMethod was used instead (no original method):
|
|
90
|
+
// - There is no original to forward to, so we skip the self-call to avoid
|
|
91
|
+
// actual infinite recursion / stack overflow.
|
|
92
|
+
|
|
93
|
+
// All pw_ methods are called on the main thread (UIApplicationDelegate callbacks
|
|
94
|
+
// are main-thread-only). didExchange is written on main by swizzleIfNeeded, so
|
|
95
|
+
// reading it here is safe without additional locking.
|
|
96
|
+
|
|
97
|
+
@objc func pw_didRegisterForRemoteNotifications(_ application: UIApplication, deviceToken: Data) {
|
|
98
|
+
PaywalloNotifications.didRegisterForRemoteNotifications(deviceToken: deviceToken)
|
|
99
|
+
let key = NSStringFromSelector(#selector(UIApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)))
|
|
100
|
+
if PaywalloNotificationsSwizzle.didExchange[key] == true {
|
|
101
|
+
pw_didRegisterForRemoteNotifications(application, deviceToken: deviceToken)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@objc func pw_didFailToRegisterForRemoteNotifications(_ application: UIApplication, error: Error) {
|
|
106
|
+
let key = NSStringFromSelector(#selector(UIApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:)))
|
|
107
|
+
if PaywalloNotificationsSwizzle.didExchange[key] == true {
|
|
108
|
+
pw_didFailToRegisterForRemoteNotifications(application, error: error)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@objc func pw_didReceiveRemoteNotification(
|
|
113
|
+
_ application: UIApplication,
|
|
114
|
+
userInfo: [AnyHashable: Any],
|
|
115
|
+
completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
|
116
|
+
) {
|
|
117
|
+
PaywalloNotifications.didReceiveRemoteNotification(userInfo: userInfo)
|
|
118
|
+
let key = NSStringFromSelector(#selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:)))
|
|
119
|
+
if PaywalloNotificationsSwizzle.didExchange[key] == true {
|
|
120
|
+
pw_didReceiveRemoteNotification(application, userInfo: userInfo, completionHandler: completionHandler)
|
|
121
|
+
} else {
|
|
122
|
+
// No original handler — we must call the completion handler ourselves
|
|
123
|
+
completionHandler(.noData)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/ios/PaywalloStoreKit.m
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#import <React/RCTBridgeModule.h>
|
|
2
|
+
#import <React/RCTEventEmitter.h>
|
|
2
3
|
|
|
3
|
-
@interface RCT_EXTERN_MODULE(PaywalloStoreKit,
|
|
4
|
+
@interface RCT_EXTERN_MODULE(PaywalloStoreKit, RCTEventEmitter)
|
|
5
|
+
|
|
6
|
+
RCT_EXTERN_METHOD(supportedEvents)
|
|
4
7
|
|
|
5
8
|
RCT_EXTERN_METHOD(getProducts:(NSArray *)productIds
|
|
6
9
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import StoreKit
|
|
3
|
+
import React
|
|
3
4
|
|
|
4
5
|
@available(iOS 15.0, *)
|
|
5
6
|
@objc(PaywalloStoreKit)
|
|
6
|
-
class PaywalloStoreKit:
|
|
7
|
+
class PaywalloStoreKit: RCTEventEmitter {
|
|
7
8
|
|
|
8
9
|
private var updateListenerTask: Task<Void, Never>?
|
|
10
|
+
private var hasListeners = false
|
|
11
|
+
|
|
12
|
+
// Buffer for transaction events that arrive before JS subscribes via
|
|
13
|
+
// NativeEventEmitter. StoreKit 2 starts delivering Transaction.updates
|
|
14
|
+
// as soon as the module is constructed (cold start), but JS may not
|
|
15
|
+
// have called addListener yet. Without buffering, those payloads are
|
|
16
|
+
// silently dropped and sales go untracked.
|
|
17
|
+
// In-memory only; capped at maxBufferedEvents to avoid unbounded growth.
|
|
18
|
+
private var preSubscribeBuffer: [[String: Any]] = []
|
|
19
|
+
private let bufferQueue = DispatchQueue(label: "com.paywallo.storekit.buffer")
|
|
20
|
+
private static let maxBufferedEvents = 100
|
|
21
|
+
|
|
22
|
+
// Event name emitted for every StoreKit 2 Transaction.updates signal
|
|
23
|
+
// (renewals, refunds, cancellations). JS side subscribes via
|
|
24
|
+
// NativeEventEmitter and fans the payload out to the EventBatcher.
|
|
25
|
+
private static let transactionUpdateEvent = "PaywalloTransactionUpdate"
|
|
9
26
|
|
|
10
27
|
override init() {
|
|
11
28
|
super.init()
|
|
@@ -16,22 +33,154 @@ class PaywalloStoreKit: NSObject {
|
|
|
16
33
|
updateListenerTask?.cancel()
|
|
17
34
|
}
|
|
18
35
|
|
|
36
|
+
// MARK: - RCTEventEmitter overrides
|
|
37
|
+
|
|
38
|
+
override func supportedEvents() -> [String]! {
|
|
39
|
+
return [PaywalloStoreKit.transactionUpdateEvent]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override func startObserving() {
|
|
43
|
+
hasListeners = true
|
|
44
|
+
drainPreSubscribeBuffer()
|
|
45
|
+
NotificationCenter.default.addObserver(
|
|
46
|
+
self,
|
|
47
|
+
selector: #selector(handleAppForeground),
|
|
48
|
+
name: UIApplication.didBecomeActiveNotification,
|
|
49
|
+
object: nil
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override func stopObserving() {
|
|
54
|
+
hasListeners = false
|
|
55
|
+
NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@objc private func handleAppForeground() {
|
|
59
|
+
guard updateListenerTask == nil || updateListenerTask!.isCancelled else { return }
|
|
60
|
+
#if DEBUG
|
|
61
|
+
print("[Paywallo:StoreKit] Restarting transaction listener after foreground")
|
|
62
|
+
#endif
|
|
63
|
+
startTransactionListener()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Drains any transaction events that were buffered before JS subscribed,
|
|
67
|
+
/// emitting them in FIFO order. Called from `startObserving()` once the
|
|
68
|
+
/// JS-side NativeEventEmitter has registered its listener.
|
|
69
|
+
private func drainPreSubscribeBuffer() {
|
|
70
|
+
let buffered: [[String: Any]] = bufferQueue.sync {
|
|
71
|
+
let copy = preSubscribeBuffer
|
|
72
|
+
preSubscribeBuffer.removeAll()
|
|
73
|
+
return copy
|
|
74
|
+
}
|
|
75
|
+
guard !buffered.isEmpty else { return }
|
|
76
|
+
#if DEBUG
|
|
77
|
+
print("[Paywallo:StoreKit] Draining \(buffered.count) pre-subscribe transaction event(s)")
|
|
78
|
+
#endif
|
|
79
|
+
for payload in buffered {
|
|
80
|
+
sendEvent(withName: PaywalloStoreKit.transactionUpdateEvent, body: payload)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override static func requiresMainQueueSetup() -> Bool {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
19
88
|
// MARK: - Transaction Listener
|
|
20
89
|
|
|
21
90
|
private func startTransactionListener() {
|
|
22
91
|
updateListenerTask = Task { [weak self] in
|
|
23
92
|
for await result in Transaction.updates {
|
|
24
|
-
guard let
|
|
93
|
+
guard let self = self else { return }
|
|
25
94
|
if case .verified(let transaction) = result {
|
|
26
95
|
#if DEBUG
|
|
27
|
-
print("[Paywallo:StoreKit] Transaction update: \(transaction.productID)
|
|
96
|
+
print("[Paywallo:StoreKit] Transaction update: \(transaction.productID) revoked=\(transaction.revocationDate != nil) id=\(transaction.id)")
|
|
28
97
|
#endif
|
|
98
|
+
await self.emitTransactionUpdate(transaction)
|
|
29
99
|
await transaction.finish()
|
|
30
100
|
}
|
|
31
101
|
}
|
|
32
102
|
}
|
|
33
103
|
}
|
|
34
104
|
|
|
105
|
+
/// Classifies a StoreKit 2 `Transaction` coming from `Transaction.updates`
|
|
106
|
+
/// into one of three domain events — `renewed`, `refunded`, `canceled` —
|
|
107
|
+
/// and forwards a minimal payload to JS via RCTEventEmitter.
|
|
108
|
+
///
|
|
109
|
+
/// Classification rules:
|
|
110
|
+
/// - `revocationDate != nil` + `revocationReason == .developerIssue` → `canceled`
|
|
111
|
+
/// - `revocationDate != nil` (any other reason, incl. `.other`) → `refunded`
|
|
112
|
+
/// - otherwise → `renewed` (auto-renewable subscription renewal or
|
|
113
|
+
/// restored purchase)
|
|
114
|
+
private func emitTransactionUpdate(_ transaction: Transaction) async {
|
|
115
|
+
let type: String
|
|
116
|
+
if transaction.revocationDate != nil {
|
|
117
|
+
if let reason = transaction.revocationReason, reason == .developerIssue {
|
|
118
|
+
type = "canceled"
|
|
119
|
+
} else {
|
|
120
|
+
type = "refunded"
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
type = "renewed"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var payload: [String: Any] = [
|
|
127
|
+
"type": type,
|
|
128
|
+
"transactionId": String(transaction.id),
|
|
129
|
+
"productId": transaction.productID,
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
// Best-effort amount + currency lookup. Products are cached on the JS
|
|
133
|
+
// side but StoreKit 2 also lets us fetch by id synchronously-ish; if
|
|
134
|
+
// the fetch fails we simply omit the fields (server-side webhook will
|
|
135
|
+
// still carry the truth).
|
|
136
|
+
if let priceInfo = await resolveProductPrice(productId: transaction.productID) {
|
|
137
|
+
payload["amount"] = priceInfo.amount
|
|
138
|
+
payload["currency"] = priceInfo.currency
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if hasListeners {
|
|
142
|
+
sendEvent(withName: PaywalloStoreKit.transactionUpdateEvent, body: payload)
|
|
143
|
+
} else {
|
|
144
|
+
// JS not yet subscribed (cold-start race). Buffer up to
|
|
145
|
+
// maxBufferedEvents; drop oldest on overflow to bound memory.
|
|
146
|
+
bufferQueue.sync {
|
|
147
|
+
if preSubscribeBuffer.count >= PaywalloStoreKit.maxBufferedEvents {
|
|
148
|
+
preSubscribeBuffer.removeFirst()
|
|
149
|
+
#if DEBUG
|
|
150
|
+
print("[Paywallo:StoreKit] Pre-subscribe buffer overflow — dropping oldest event")
|
|
151
|
+
#endif
|
|
152
|
+
}
|
|
153
|
+
preSubscribeBuffer.append(payload)
|
|
154
|
+
}
|
|
155
|
+
#if DEBUG
|
|
156
|
+
print("[Paywallo:StoreKit] Buffered transaction event (no JS listener yet): \(transaction.productID)")
|
|
157
|
+
#endif
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private func resolveProductPrice(productId: String) async -> (amount: Double, currency: String)? {
|
|
162
|
+
do {
|
|
163
|
+
let products = try await Product.products(for: [productId])
|
|
164
|
+
guard let product = products.first else { return nil }
|
|
165
|
+
let amount = NSDecimalNumber(decimal: product.price).doubleValue
|
|
166
|
+
var currency: String = "USD"
|
|
167
|
+
if let json = try? JSONSerialization.jsonObject(with: product.jsonRepresentation) as? [String: Any],
|
|
168
|
+
let attrs = json["attributes"] as? [String: Any],
|
|
169
|
+
let offers = attrs["offers"] as? [[String: Any]],
|
|
170
|
+
let firstOffer = offers.first,
|
|
171
|
+
let currencyCode = firstOffer["currencyCode"] as? String {
|
|
172
|
+
currency = currencyCode
|
|
173
|
+
} else if #available(iOS 16, *) {
|
|
174
|
+
currency = Locale.current.currency?.identifier ?? "USD"
|
|
175
|
+
} else {
|
|
176
|
+
currency = Locale.current.currencyCode ?? "USD"
|
|
177
|
+
}
|
|
178
|
+
return (amount, currency)
|
|
179
|
+
} catch {
|
|
180
|
+
return nil
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
35
184
|
// MARK: - Get Products
|
|
36
185
|
|
|
37
186
|
@objc
|
|
@@ -263,9 +412,4 @@ class PaywalloStoreKit: NSObject {
|
|
|
263
412
|
return "P\(period.value)D"
|
|
264
413
|
}
|
|
265
414
|
}
|
|
266
|
-
|
|
267
|
-
@objc
|
|
268
|
-
static func requiresMainQueueSetup() -> Bool {
|
|
269
|
-
return false
|
|
270
|
-
}
|
|
271
415
|
}
|
package/ios/PaywalloWebView.m
CHANGED
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
// data:, and blob: — to prevent mixed-content and injection attacks.
|
|
196
196
|
NSString *scheme = url.scheme.lowercaseString;
|
|
197
197
|
NSSet<NSString *> *allowedSchemes = [NSSet setWithObjects:
|
|
198
|
-
@"https", @"about", @"mailto", @"itms-apps",
|
|
198
|
+
@"https", @"about", @"mailto", @"itms-apps", nil];
|
|
199
199
|
if (scheme && ![allowedSchemes containsObject:scheme]) {
|
|
200
200
|
decisionHandler(WKNavigationActionPolicyCancel);
|
|
201
201
|
return;
|
|
@@ -203,8 +203,7 @@
|
|
|
203
203
|
|
|
204
204
|
// mailto: and App Store links must be opened by the OS, not loaded inside the WebView.
|
|
205
205
|
if ([scheme isEqualToString:@"mailto"] ||
|
|
206
|
-
[scheme isEqualToString:@"itms-apps"]
|
|
207
|
-
[scheme isEqualToString:@"itms-services"]) {
|
|
206
|
+
[scheme isEqualToString:@"itms-apps"]) {
|
|
208
207
|
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
|
|
209
208
|
decisionHandler(WKNavigationActionPolicyCancel);
|
|
210
209
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@virex-tech/paywallo-sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "SDK React Native para integração com Paywallo - Paywalls, Subscriptions e Analytics",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"react-native": "./dist/index.mjs",
|
|
12
12
|
"import": "./dist/index.mjs",
|
|
13
13
|
"require": "./dist/index.js"
|
|
14
|
-
}
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"dist",
|
|
@@ -42,7 +43,9 @@
|
|
|
42
43
|
"react-native",
|
|
43
44
|
"mobile",
|
|
44
45
|
"subscription",
|
|
45
|
-
"paywallo"
|
|
46
|
+
"paywallo",
|
|
47
|
+
"push-notifications",
|
|
48
|
+
"fcm"
|
|
46
49
|
],
|
|
47
50
|
"author": "Virex Tech",
|
|
48
51
|
"license": "MIT",
|
|
@@ -62,11 +65,15 @@
|
|
|
62
65
|
"node": ">=18.0.0"
|
|
63
66
|
},
|
|
64
67
|
"peerDependencies": {
|
|
68
|
+
"@react-native-firebase/app": "^22.0.0",
|
|
65
69
|
"expo-tracking-transparency": "*",
|
|
66
70
|
"react": "*",
|
|
67
71
|
"react-native": "*"
|
|
68
72
|
},
|
|
69
73
|
"peerDependenciesMeta": {
|
|
74
|
+
"@react-native-firebase/app": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
70
77
|
"expo-tracking-transparency": {
|
|
71
78
|
"optional": true
|
|
72
79
|
}
|
|
@@ -86,6 +93,6 @@
|
|
|
86
93
|
"vitest": "^4.1.2"
|
|
87
94
|
},
|
|
88
95
|
"dependencies": {
|
|
89
|
-
"
|
|
96
|
+
"zod": "^4.3.6"
|
|
90
97
|
}
|
|
91
98
|
}
|