@virex-tech/paywallo-sdk 1.5.0 → 1.5.2

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.
@@ -11,10 +11,9 @@ Pod::Spec.new do |s|
11
11
  s.authors = { "Paywallo" => "support@paywallo.io" }
12
12
  s.platforms = { :ios => "15.0" }
13
13
  s.source = { :git => "https://github.com/paywallo/sdk-react-native.git", :tag => "v#{s.version}" }
14
- s.source_files = "ios/**/*.{h,m,swift}"
15
- s.frameworks = "WebKit", "StoreKit"
14
+ s.source_files = "ios/**/*.{h,m,mm,swift}"
15
+ s.frameworks = "Security", "StoreKit", "WebKit", "CoreTelephony", "AVFoundation"
16
16
  s.swift_version = "5.5"
17
17
 
18
18
  s.dependency "React-Core"
19
- s.dependency "FBSDKCoreKit", "~> 17.0"
20
19
  end
package/README.md CHANGED
@@ -8,25 +8,23 @@ Paywalls, subscriptions, analytics and feature flags for React Native.
8
8
  npm install @virex-tech/paywallo-sdk
9
9
  ```
10
10
 
11
- Peer dependencies:
12
-
11
+ iOS:
13
12
  ```bash
14
- npm install @react-native-async-storage/async-storage @react-native-community/netinfo react-native-iap react-native-device-info
13
+ cd ios && pod install
15
14
  ```
16
15
 
17
- Optional but recommended persists device ID across reinstalls via Keychain (iOS) and Keystore (Android):
16
+ That's it. The SDK includes its own native modules for storage, device info and StoreKit — no extra dependencies needed.
18
17
 
19
- ```bash
20
- npm install react-native-keychain
21
- ```
18
+ ### Optional: Ad tracking (IDFA)
22
19
 
23
- Without it, the SDK falls back to AsyncStorage (works, but device ID resets on reinstall).
20
+ If your app uses Facebook Ads, AppsFlyer or any attribution that needs the Apple IDFA, install the tracking transparency module:
24
21
 
25
- iOS only:
26
22
  ```bash
27
- cd ios && pod install
23
+ npm install expo-tracking-transparency
28
24
  ```
29
25
 
26
+ This enables the ATT prompt ("Allow this app to track your activity?"). Without it, the SDK skips IDFA collection — everything else works normally.
27
+
30
28
  ## Quick start
31
29
 
32
30
  ### 1. Wrap your app
@@ -48,28 +46,21 @@ export default function App() {
48
46
  }
49
47
  ```
50
48
 
51
- ### 2. Show a paywall
49
+ ### 2. Gate content with a paywall
52
50
 
53
51
  ```tsx
54
- import { usePaywallo, useSubscription } from '@virex-tech/paywallo-sdk';
52
+ import { PaywalloClient } from '@virex-tech/paywallo-sdk';
55
53
 
56
- function MyComponent() {
57
- const { presentPaywall, hasActiveSubscription } = usePaywallo();
58
- const { subscription } = useSubscription();
54
+ const hasAccess = await PaywalloClient.requireSubscriptionWithCampaign('my-campaign');
55
+ if (hasAccess) {
56
+ // User has an active subscription or just purchased
57
+ }
58
+ ```
59
59
 
60
- const handlePurchase = async () => {
61
- const hasAccess = await hasActiveSubscription();
62
- if (!hasAccess) {
63
- await presentPaywall('default');
64
- }
65
- };
60
+ ### 3. Check subscription status
66
61
 
67
- return (
68
- <Button onPress={handlePurchase}>
69
- {subscription ? 'Premium' : 'Upgrade'}
70
- </Button>
71
- );
72
- }
62
+ ```tsx
63
+ const isActive = await PaywalloClient.hasActiveSubscription();
73
64
  ```
74
65
 
75
66
  ## Hooks
@@ -79,20 +70,32 @@ function MyComponent() {
79
70
  | Property | Type | Description |
80
71
  |---|---|---|
81
72
  | `isInitialized` | `boolean` | SDK ready |
73
+ | `isReady` | `boolean` | SDK ready and products loaded |
82
74
  | `distinctId` | `string` | Current user ID |
83
- | `presentPaywall(placement)` | `Promise<PaywallResult>` | Show a paywall |
75
+ | `presentCampaign(placement)` | `Promise<CampaignResult>` | Show a campaign paywall |
84
76
  | `hasActiveSubscription()` | `Promise<boolean>` | Check subscription status |
85
77
  | `getSubscription()` | `Promise<Subscription>` | Get subscription data |
86
- | `restorePurchases()` | `Promise<void>` | Restore previous purchases |
78
+ | `restorePurchases()` | `Promise<RestoreResult>` | Restore previous purchases |
87
79
 
88
80
  ### useSubscription()
89
81
 
90
82
  | Property | Type | Description |
91
83
  |---|---|---|
92
84
  | `subscription` | `Subscription \| null` | Current subscription |
85
+ | `hasActiveSubscription` | `boolean` | Whether subscription is active |
93
86
  | `isLoading` | `boolean` | Loading state |
94
87
  | `refresh()` | `() => void` | Refetch subscription data |
95
88
 
89
+ ### usePurchase()
90
+
91
+ | Property | Type | Description |
92
+ |---|---|---|
93
+ | `purchase(productId)` | `Promise<PurchaseOutcome>` | Purchase a product |
94
+ | `restore()` | `Promise<IAPPurchase[]>` | Restore purchases |
95
+ | `isPurchasing` | `boolean` | Purchase in progress |
96
+ | `isRestoring` | `boolean` | Restore in progress |
97
+ | `error` | `PurchaseError \| null` | Last error |
98
+
96
99
  ## Imperative API
97
100
 
98
101
  For use outside React components:
@@ -100,23 +103,34 @@ For use outside React components:
100
103
  ```tsx
101
104
  import { PaywalloClient } from '@virex-tech/paywallo-sdk';
102
105
 
103
- await PaywalloClient.identify('user_123', { email: 'user@example.com' });
106
+ // Identify user
107
+ await PaywalloClient.identify({ email: 'user@example.com' });
104
108
 
105
- await PaywalloClient.track('purchase_completed', { product: 'premium' });
109
+ // Track events
110
+ await PaywalloClient.track('purchase_completed', { properties: { product: 'premium' } });
106
111
 
112
+ // Feature flags
107
113
  const variant = await PaywalloClient.getVariant('new_feature');
114
+
115
+ // Subscription gate
116
+ const hasAccess = await PaywalloClient.requireSubscriptionWithCampaign('premium');
108
117
  ```
109
118
 
110
- ## Types
119
+ ## Config options
111
120
 
112
121
  ```tsx
113
122
  interface PaywalloConfig {
114
- appKey: string;
115
- baseUrl?: string;
116
- debug?: boolean;
117
- environment?: 'Production' | 'Sandbox';
123
+ appKey: string; // Required — your app key from the dashboard
124
+ apiUrl?: string; // API URL (default: https://paywallo.com.br)
125
+ debug?: boolean; // Enable debug logs (default: false)
126
+ autoStartSession?: boolean; // Auto-start session on init (default: true)
127
+ sessionFlags?: string[]; // Flags to resolve during session start
118
128
  }
129
+ ```
130
+
131
+ ## Types
119
132
 
133
+ ```tsx
120
134
  interface Subscription {
121
135
  productId: string;
122
136
  status: 'active' | 'expired' | 'cancelled' | 'in_grace_period';
@@ -125,12 +139,19 @@ interface Subscription {
125
139
  autoRenewEnabled: boolean;
126
140
  }
127
141
 
128
- interface PaywallResult {
142
+ interface CampaignResult {
129
143
  presented: boolean;
130
144
  purchased: boolean;
131
145
  restored: boolean;
132
146
  cancelled: boolean;
133
147
  error?: Error;
148
+ skippedReason?: 'subscriber';
149
+ }
150
+
151
+ interface PurchaseOutcome {
152
+ status: 'success' | 'cancelled' | 'failed';
153
+ purchase?: IAPPurchase;
154
+ error?: PurchaseError;
134
155
  }
135
156
  ```
136
157
 
@@ -22,6 +22,7 @@ android {
22
22
  defaultConfig {
23
23
  minSdkVersion safeExtGet('minSdkVersion', 24)
24
24
  targetSdkVersion safeExtGet('targetSdkVersion', 34)
25
+ consumerProguardFiles "consumer-rules.pro"
25
26
  }
26
27
 
27
28
  compileOptions {
@@ -51,5 +52,7 @@ dependencies {
51
52
  implementation "com.android.billingclient:billing-ktx:7.0.0"
52
53
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
53
54
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
54
- implementation "com.facebook.android:facebook-android-sdk:17.+"
55
+ compileOnly "com.facebook.android:facebook-android-sdk:17.+"
56
+ implementation "androidx.security:security-crypto:1.0.0"
57
+ implementation "com.android.installreferrer:installreferrer:2.2"
55
58
  }
@@ -0,0 +1,24 @@
1
+ # InstallReferrer — keep callback interface used via reflection
2
+ -keep interface com.android.installreferrer.api.InstallReferrerStateListener { *; }
3
+ -keep class com.android.installreferrer.** { *; }
4
+
5
+ # EncryptedSharedPreferences — uses reflection internally
6
+ -keep class androidx.security.crypto.** { *; }
7
+
8
+ # Paywallo SDK — keep ReactMethod-annotated methods and module classes
9
+ -keepclassmembers class com.paywallo.sdk.** {
10
+ @com.facebook.react.bridge.ReactMethod <methods>;
11
+ }
12
+ -keep class com.paywallo.sdk.**Module { *; }
13
+ -keep class com.paywallo.sdk.**Package { *; }
14
+
15
+ # WebView classes — keep for React Native view manager and JavascriptInterface
16
+ -keep class com.paywallo.sdk.PaywalloWebView { *; }
17
+ -keep class com.paywallo.sdk.PaywalloWebView$WebAppInterface { *; }
18
+ -keep class com.paywallo.sdk.PaywalloWebViewManager { *; }
19
+ -keep class com.paywallo.sdk.PaywalloWebViewEvent { *; }
20
+
21
+ # JavascriptInterface — keep annotated methods to prevent stripping
22
+ -keepclassmembers class com.paywallo.sdk.** {
23
+ @android.webkit.JavascriptInterface <methods>;
24
+ }
@@ -0,0 +1,148 @@
1
+ package com.paywallo.sdk
2
+
3
+ import com.facebook.react.bridge.*
4
+ import android.os.Build
5
+ import android.os.Environment
6
+ import android.os.StatFs
7
+ import android.app.ActivityManager
8
+ import android.content.Context
9
+ import android.provider.Settings
10
+ import android.telephony.TelephonyManager
11
+ import androidx.core.content.pm.PackageInfoCompat
12
+ import com.android.installreferrer.api.InstallReferrerClient
13
+ import com.android.installreferrer.api.InstallReferrerStateListener
14
+
15
+ class PaywalloDeviceModule(private val reactContext: ReactApplicationContext) :
16
+ ReactContextBaseJavaModule(reactContext) {
17
+
18
+ override fun getName() = "PaywalloDevice"
19
+
20
+ @ReactMethod
21
+ fun getDeviceInfo(promise: Promise) {
22
+ try {
23
+ val map = Arguments.createMap()
24
+
25
+ // deviceId
26
+ try {
27
+ val deviceId = Settings.Secure.getString(
28
+ reactContext.contentResolver,
29
+ Settings.Secure.ANDROID_ID
30
+ )
31
+ map.putString("deviceId", if (deviceId.isNullOrEmpty()) "unknown" else deviceId)
32
+ } catch (e: Exception) {
33
+ map.putString("deviceId", "unknown")
34
+ }
35
+
36
+ map.putString("model", Build.MODEL ?: "unknown")
37
+ map.putString("modelId", Build.DEVICE ?: "unknown")
38
+ map.putString("systemVersion", Build.VERSION.RELEASE ?: "unknown")
39
+ map.putString("systemName", "Android")
40
+ map.putString("brand", Build.BRAND ?: "unknown")
41
+
42
+ // appVersion, buildNumber, bundleId
43
+ try {
44
+ val packageName = reactContext.packageName
45
+ val pm = reactContext.packageManager
46
+ val pkgInfo = pm.getPackageInfo(packageName, 0)
47
+ map.putString("appVersion", pkgInfo.versionName ?: "unknown")
48
+ val buildNumber = PackageInfoCompat.getLongVersionCode(pkgInfo).toString()
49
+ map.putString("buildNumber", buildNumber)
50
+ map.putString("bundleId", packageName)
51
+ } catch (e: Exception) {
52
+ map.putString("appVersion", "unknown")
53
+ map.putString("buildNumber", "unknown")
54
+ map.putString("bundleId", reactContext.packageName ?: "unknown")
55
+ }
56
+
57
+ // totalDisk, freeDisk
58
+ try {
59
+ val stat = StatFs(Environment.getDataDirectory().path)
60
+ map.putDouble("totalDisk", stat.totalBytes.toDouble())
61
+ map.putDouble("freeDisk", stat.availableBytes.toDouble())
62
+ } catch (e: Exception) {
63
+ map.putDouble("totalDisk", 0.0)
64
+ map.putDouble("freeDisk", 0.0)
65
+ }
66
+
67
+ // totalRam
68
+ try {
69
+ val activityManager = reactContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
70
+ val memInfo = ActivityManager.MemoryInfo()
71
+ activityManager.getMemoryInfo(memInfo)
72
+ map.putDouble("totalRam", memInfo.totalMem.toDouble())
73
+ } catch (e: Exception) {
74
+ map.putDouble("totalRam", 0.0)
75
+ }
76
+
77
+ // carrier
78
+ try {
79
+ val tm = reactContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
80
+ val carrier = tm.networkOperatorName
81
+ map.putString("carrier", if (carrier.isNullOrEmpty()) "unknown" else carrier)
82
+ } catch (e: Exception) {
83
+ map.putString("carrier", "unknown")
84
+ }
85
+
86
+ // installerPackage
87
+ try {
88
+ val packageName = reactContext.packageName
89
+ val pm = reactContext.packageManager
90
+ val installer = pm.getInstallerPackageName(packageName)
91
+ if (installer != null) {
92
+ map.putString("installerPackage", installer)
93
+ } else {
94
+ map.putNull("installerPackage")
95
+ }
96
+ } catch (e: Exception) {
97
+ map.putNull("installerPackage")
98
+ }
99
+
100
+ // idfv — iOS-only concept
101
+ map.putNull("idfv")
102
+
103
+ promise.resolve(map)
104
+ } catch (e: Exception) {
105
+ promise.resolve(Arguments.createMap())
106
+ }
107
+ }
108
+
109
+ @ReactMethod
110
+ fun getInstallReferrer(promise: Promise) {
111
+ try {
112
+ val client = InstallReferrerClient.newBuilder(reactContext).build()
113
+ // resolved tracks whether the promise was already settled to prevent
114
+ // double-resolve when endConnection() triggers onInstallReferrerServiceDisconnected
115
+ var resolved = false
116
+
117
+ client.startConnection(object : InstallReferrerStateListener {
118
+ override fun onInstallReferrerSetupFinished(responseCode: Int) {
119
+ if (resolved) return
120
+ if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
121
+ try {
122
+ val referrer = client.installReferrer.installReferrer
123
+ resolved = true
124
+ promise.resolve(referrer)
125
+ } catch (e: Exception) {
126
+ resolved = true
127
+ promise.resolve(null)
128
+ } finally {
129
+ client.endConnection()
130
+ }
131
+ } else {
132
+ resolved = true
133
+ client.endConnection()
134
+ promise.resolve(null)
135
+ }
136
+ }
137
+
138
+ override fun onInstallReferrerServiceDisconnected() {
139
+ if (resolved) return
140
+ resolved = true
141
+ promise.resolve(null)
142
+ }
143
+ })
144
+ } catch (e: Exception) {
145
+ promise.resolve(null)
146
+ }
147
+ }
148
+ }
@@ -20,7 +20,8 @@ class PaywalloFBBridgeModule(private val reactContext: ReactApplicationContext)
20
20
  } else {
21
21
  promise.resolve(anonId)
22
22
  }
23
- } catch (e: Exception) {
23
+ } catch (t: Throwable) {
24
+ // catches NoClassDefFoundError when Facebook SDK is not present at runtime
24
25
  promise.resolve(null)
25
26
  }
26
27
  }
@@ -32,7 +33,8 @@ class PaywalloFBBridgeModule(private val reactContext: ReactApplicationContext)
32
33
  val logger = AppEventsLogger.newLogger(reactApplicationContext)
33
34
  logger.logEvent(name, bundle)
34
35
  promise.resolve(null)
35
- } catch (e: Exception) {
36
+ } catch (t: Throwable) {
37
+ // catches NoClassDefFoundError when Facebook SDK is not present at runtime
36
38
  promise.resolve(null)
37
39
  }
38
40
  }
@@ -44,7 +46,8 @@ class PaywalloFBBridgeModule(private val reactContext: ReactApplicationContext)
44
46
  val logger = AppEventsLogger.newLogger(reactApplicationContext)
45
47
  logger.logPurchase(BigDecimal.valueOf(amount), Currency.getInstance(currency), bundle)
46
48
  promise.resolve(null)
47
- } catch (e: Exception) {
49
+ } catch (t: Throwable) {
50
+ // catches NoClassDefFoundError when Facebook SDK is not present at runtime
48
51
  promise.resolve(null)
49
52
  }
50
53
  }
@@ -53,8 +56,8 @@ class PaywalloFBBridgeModule(private val reactContext: ReactApplicationContext)
53
56
  fun setUserID(userId: String) {
54
57
  try {
55
58
  AppEventsLogger.setUserID(userId)
56
- } catch (e: Exception) {
57
- // never crash
59
+ } catch (t: Throwable) {
60
+ // catches NoClassDefFoundError when Facebook SDK is not present at runtime
58
61
  }
59
62
  }
60
63
 
@@ -62,8 +65,8 @@ class PaywalloFBBridgeModule(private val reactContext: ReactApplicationContext)
62
65
  fun flush() {
63
66
  try {
64
67
  AppEventsLogger.newLogger(reactApplicationContext).flush()
65
- } catch (e: Exception) {
66
- // never crash
68
+ } catch (t: Throwable) {
69
+ // catches NoClassDefFoundError when Facebook SDK is not present at runtime
67
70
  }
68
71
  }
69
72
 
@@ -9,7 +9,9 @@ class PaywalloSdkPackage : ReactPackage {
9
9
  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10
10
  return listOf(
11
11
  PaywalloStoreKitModule(reactContext),
12
- PaywalloFBBridgeModule(reactContext)
12
+ PaywalloFBBridgeModule(reactContext),
13
+ PaywalloStorageModule(reactContext),
14
+ PaywalloDeviceModule(reactContext)
13
15
  )
14
16
  }
15
17
 
@@ -0,0 +1,106 @@
1
+ package com.paywallo.sdk
2
+
3
+ import android.content.SharedPreferences
4
+ import android.util.Log
5
+ import androidx.security.crypto.EncryptedSharedPreferences
6
+ import androidx.security.crypto.MasterKey
7
+ import com.facebook.react.bridge.*
8
+
9
+ class PaywalloStorageModule(private val reactContext: ReactApplicationContext) :
10
+ ReactContextBaseJavaModule(reactContext) {
11
+
12
+ override fun getName() = "PaywalloStorage"
13
+
14
+ private val TAG = "PaywalloStorage"
15
+ private val SECURE_FILE = "paywallo_secure"
16
+ private val REGULAR_FILE = "paywallo_storage"
17
+ // No prefix — TypeScript layer handles prefixing
18
+
19
+ private val encryptedPrefs: SharedPreferences by lazy {
20
+ try {
21
+ val masterKey = MasterKey.Builder(reactContext)
22
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
23
+ .build()
24
+ EncryptedSharedPreferences.create(
25
+ reactContext,
26
+ SECURE_FILE,
27
+ masterKey,
28
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
29
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
30
+ )
31
+ } catch (e: Exception) {
32
+ Log.w(TAG, "EncryptedSharedPreferences unavailable, falling back to regular prefs: ${e.message}")
33
+ reactContext.getSharedPreferences(SECURE_FILE, android.content.Context.MODE_PRIVATE)
34
+ }
35
+ }
36
+
37
+ private val regularPrefs: SharedPreferences by lazy {
38
+ reactContext.getSharedPreferences(REGULAR_FILE, android.content.Context.MODE_PRIVATE)
39
+ }
40
+
41
+ @ReactMethod
42
+ fun secureGet(key: String, promise: Promise) {
43
+ try {
44
+ val value = encryptedPrefs.getString(key, null)
45
+ promise.resolve(value)
46
+ } catch (e: Exception) {
47
+ Log.w(TAG, "secureGet failed: ${e.message}")
48
+ promise.resolve(null)
49
+ }
50
+ }
51
+
52
+ @ReactMethod
53
+ fun secureSet(key: String, value: String, promise: Promise) {
54
+ try {
55
+ encryptedPrefs.edit().putString(key, value).apply()
56
+ promise.resolve(true)
57
+ } catch (e: Exception) {
58
+ Log.w(TAG, "secureSet failed: ${e.message}")
59
+ promise.resolve(false)
60
+ }
61
+ }
62
+
63
+ @ReactMethod
64
+ fun secureRemove(key: String, promise: Promise) {
65
+ try {
66
+ encryptedPrefs.edit().remove(key).apply()
67
+ promise.resolve(true)
68
+ } catch (e: Exception) {
69
+ Log.w(TAG, "secureRemove failed: ${e.message}")
70
+ promise.resolve(false)
71
+ }
72
+ }
73
+
74
+ @ReactMethod
75
+ fun get(key: String, promise: Promise) {
76
+ try {
77
+ val value = regularPrefs.getString(key, null)
78
+ promise.resolve(value)
79
+ } catch (e: Exception) {
80
+ Log.w(TAG, "get failed: ${e.message}")
81
+ promise.resolve(null)
82
+ }
83
+ }
84
+
85
+ @ReactMethod
86
+ fun set(key: String, value: String, promise: Promise) {
87
+ try {
88
+ regularPrefs.edit().putString(key, value).apply()
89
+ promise.resolve(true)
90
+ } catch (e: Exception) {
91
+ Log.w(TAG, "set failed: ${e.message}")
92
+ promise.resolve(false)
93
+ }
94
+ }
95
+
96
+ @ReactMethod
97
+ fun remove(key: String, promise: Promise) {
98
+ try {
99
+ regularPrefs.edit().remove(key).apply()
100
+ promise.resolve(true)
101
+ } catch (e: Exception) {
102
+ Log.w(TAG, "remove failed: ${e.message}")
103
+ promise.resolve(false)
104
+ }
105
+ }
106
+ }
@@ -174,6 +174,9 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
174
174
  return@launch
175
175
  }
176
176
 
177
+ // If there's already a pending purchase, reject the previous promise
178
+ // before overwriting it to avoid orphaned promises
179
+ purchasePromise?.reject("PURCHASE_CANCELLED", "Superseded by a new purchase request")
177
180
  purchasePromise = promise
178
181
 
179
182
  val flowParamsBuilder = BillingFlowParams.newBuilder()
@@ -246,14 +249,19 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
246
249
 
247
250
  val client = billingClient!!
248
251
 
249
- // Query purchases to find the one to acknowledge
252
+ // Query both SUBS and INAPP purchases — one-time purchases also need acknowledgement
250
253
  val subsResult = client.queryPurchasesAsync(
251
254
  QueryPurchasesParams.newBuilder()
252
255
  .setProductType(BillingClient.ProductType.SUBS)
253
256
  .build()
254
257
  )
258
+ val inappResult = client.queryPurchasesAsync(
259
+ QueryPurchasesParams.newBuilder()
260
+ .setProductType(BillingClient.ProductType.INAPP)
261
+ .build()
262
+ )
255
263
 
256
- val allPurchases = subsResult.purchasesList
264
+ val allPurchases = subsResult.purchasesList + inappResult.purchasesList
257
265
 
258
266
  val purchase = allPurchases.firstOrNull {
259
267
  it.orderId == transactionId || it.purchaseToken == transactionId
@@ -317,6 +325,9 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
317
325
  }
318
326
 
319
327
  override fun onCatalystInstanceDestroy() {
328
+ // Reject any pending purchase promise so the JS side doesn't hang forever
329
+ purchasePromise?.reject("MODULE_DESTROYED", "Module was destroyed before purchase completed")
330
+ purchasePromise = null
320
331
  scope.cancel()
321
332
  billingClient?.endConnection()
322
333
  billingClient = null
@@ -13,6 +13,7 @@ import android.webkit.WebView
13
13
  import android.webkit.WebViewClient
14
14
  import com.facebook.react.bridge.Arguments
15
15
  import com.facebook.react.bridge.ReactContext
16
+ import com.facebook.react.uimanager.UIManagerHelper
16
17
  import com.facebook.react.uimanager.events.RCTEventEmitter
17
18
  import org.json.JSONObject
18
19
 
@@ -30,11 +31,36 @@ class PaywalloWebView(context: ReactContext) : WebView(context) {
30
31
  javaScriptEnabled = true
31
32
  domStorageEnabled = true
32
33
  mediaPlaybackRequiresUserGesture = false
33
- mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
34
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
34
35
  cacheMode = WebSettings.LOAD_DEFAULT
35
36
  }
36
37
 
37
38
  webViewClient = object : WebViewClient() {
39
+ // Allow HTTPS and about: (initial blank) to load inside the WebView.
40
+ // Redirect mailto:, intent:, and market: to the OS. Block everything else
41
+ // (plain HTTP, javascript:, data:, blob:) to prevent mixed-content and injection.
42
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
43
+ val uri = request?.url ?: return true
44
+ val scheme = uri.scheme?.lowercase()
45
+
46
+ val osSchemes = setOf("mailto", "intent", "market")
47
+ if (scheme != null && scheme in osSchemes) {
48
+ try {
49
+ val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, uri)
50
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
51
+ context.startActivity(intent)
52
+ } catch (_: Exception) {
53
+ // No app installed to handle this scheme — silently ignore.
54
+ }
55
+ return true // prevent WebView from loading it
56
+ }
57
+
58
+ if (scheme != null && scheme != "https" && scheme != "about") {
59
+ return true // block the navigation
60
+ }
61
+ return false
62
+ }
63
+
38
64
  override fun onPageFinished(view: WebView?, url: String?) {
39
65
  super.onPageFinished(view, url)
40
66
  dispatchEvent("onLoadEnd", null)
@@ -88,6 +114,7 @@ class PaywalloWebView(context: ReactContext) : WebView(context) {
88
114
  private var pendingHtml: String? = null
89
115
 
90
116
  fun setSourceUrl(url: String?) {
117
+ // Only load URL if no inline HTML has been set — HTML takes precedence over URL
91
118
  if (pendingHtml.isNullOrEmpty()) {
92
119
  url?.let { loadUrl(it) }
93
120
  }
@@ -106,8 +133,17 @@ class PaywalloWebView(context: ReactContext) : WebView(context) {
106
133
  val event = Arguments.createMap().apply {
107
134
  data?.let { putString("data", it) }
108
135
  }
109
- reactContext.getJSModule(RCTEventEmitter::class.java)
110
- .receiveEvent(id, eventName, event)
136
+ // UIManagerHelper is the New Architecture path; fall back to legacy RCTEventEmitter
137
+ // for compatibility with React Native < 0.71 (Old Architecture)
138
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
139
+ if (dispatcher != null) {
140
+ val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
141
+ dispatcher.dispatchEvent(PaywalloWebViewEvent(surfaceId, id, eventName, event))
142
+ } else {
143
+ @Suppress("DEPRECATION")
144
+ reactContext.getJSModule(RCTEventEmitter::class.java)
145
+ ?.receiveEvent(id, eventName, event)
146
+ }
111
147
  }
112
148
 
113
149
  inner class WebAppInterface {