expo-helium 3.0.18 → 3.1.1
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/android/build.gradle +7 -0
- package/android/src/main/java/expo/modules/paywallsdk/HeliumPaywallSdkModule.kt +652 -26
- package/build/HeliumExperimentInfo.types.d.ts +10 -2
- package/build/HeliumExperimentInfo.types.d.ts.map +1 -1
- package/build/HeliumExperimentInfo.types.js.map +1 -1
- package/build/HeliumPaywallSdk.types.d.ts +24 -2
- package/build/HeliumPaywallSdk.types.d.ts.map +1 -1
- package/build/HeliumPaywallSdk.types.js +2 -0
- package/build/HeliumPaywallSdk.types.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +38 -6
- package/build/index.js.map +1 -1
- package/build/revenuecat/revenuecat.d.ts +10 -2
- package/build/revenuecat/revenuecat.d.ts.map +1 -1
- package/build/revenuecat/revenuecat.js +120 -28
- package/build/revenuecat/revenuecat.js.map +1 -1
- package/ios/HeliumPaywallSdk.podspec +1 -1
- package/ios/HeliumPaywallSdkModule.swift +0 -3
- package/package.json +1 -1
- package/src/HeliumExperimentInfo.types.ts +12 -2
- package/src/HeliumPaywallSdk.types.ts +30 -3
- package/src/index.ts +41 -6
- package/src/revenuecat/revenuecat.ts +246 -127
|
@@ -1,50 +1,676 @@
|
|
|
1
1
|
package expo.modules.paywallsdk
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
5
|
+
import expo.modules.kotlin.functions.Coroutine
|
|
3
6
|
import expo.modules.kotlin.modules.Module
|
|
7
|
+
import expo.modules.kotlin.Promise
|
|
4
8
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
9
|
+
import expo.modules.kotlin.records.Field
|
|
10
|
+
import expo.modules.kotlin.records.Record
|
|
11
|
+
import com.google.gson.Gson
|
|
12
|
+
import com.google.gson.reflect.TypeToken
|
|
13
|
+
import com.tryhelium.paywall.core.Helium
|
|
14
|
+
import com.tryhelium.paywall.core.HeliumEnvironment
|
|
15
|
+
import com.tryhelium.paywall.core.event.HeliumEvent
|
|
16
|
+
import com.tryhelium.paywall.core.event.PaywallEventHandlers
|
|
17
|
+
import com.tryhelium.paywall.core.event.*
|
|
18
|
+
import com.tryhelium.paywall.core.HeliumFallbackConfig
|
|
19
|
+
import com.tryhelium.paywall.core.HeliumIdentityManager
|
|
20
|
+
import com.tryhelium.paywall.core.HeliumUserTraits
|
|
21
|
+
import com.tryhelium.paywall.core.HeliumUserTraitsArgument
|
|
22
|
+
import com.tryhelium.paywall.core.HeliumPaywallTransactionStatus
|
|
23
|
+
import com.tryhelium.paywall.core.HeliumLightDarkMode
|
|
24
|
+
import com.tryhelium.paywall.delegate.HeliumPaywallDelegate
|
|
25
|
+
import com.tryhelium.paywall.delegate.PlayStorePaywallDelegate
|
|
26
|
+
import com.android.billingclient.api.ProductDetails
|
|
27
|
+
import kotlinx.coroutines.CoroutineScope
|
|
28
|
+
import kotlinx.coroutines.Dispatchers
|
|
29
|
+
import kotlinx.coroutines.launch
|
|
30
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
31
|
+
import java.lang.ref.WeakReference
|
|
5
32
|
import java.net.URL
|
|
33
|
+
import kotlin.coroutines.resume
|
|
34
|
+
import kotlin.reflect.full.memberProperties
|
|
35
|
+
import kotlin.reflect.jvm.isAccessible
|
|
36
|
+
|
|
37
|
+
// Record data classes for type-safe return values
|
|
38
|
+
class PaywallInfoResult : Record {
|
|
39
|
+
@Field
|
|
40
|
+
var errorMsg: String? = null
|
|
41
|
+
|
|
42
|
+
@Field
|
|
43
|
+
var templateName: String? = null
|
|
44
|
+
|
|
45
|
+
@Field
|
|
46
|
+
var shouldShow: Boolean? = null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class HasEntitlementResult : Record {
|
|
50
|
+
@Field
|
|
51
|
+
var hasEntitlement: Boolean? = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extension function to convert any object (especially HeliumEvent data classes) to a Map.
|
|
56
|
+
* Uses Kotlin reflection to extract all member properties from data classes.
|
|
57
|
+
*/
|
|
58
|
+
@Suppress("UNCHECKED_CAST")
|
|
59
|
+
private fun Any.toMap(): Map<String, Any?> {
|
|
60
|
+
return try {
|
|
61
|
+
val kClass = this::class
|
|
62
|
+
kClass.memberProperties.associate { prop ->
|
|
63
|
+
prop.isAccessible = true
|
|
64
|
+
val value = (prop as kotlin.reflect.KProperty1<Any, *>).get(this)
|
|
65
|
+
prop.name to when (value) {
|
|
66
|
+
is Enum<*> -> value.name
|
|
67
|
+
is List<*> -> value
|
|
68
|
+
is Map<*, *> -> value
|
|
69
|
+
else -> value
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (e: Exception) {
|
|
73
|
+
android.util.Log.e("HeliumPaywallSdk", "Failed to convert to map: ${e.message}", e)
|
|
74
|
+
emptyMap()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extracts the event type string from a HeliumEvent for JavaScript consumption.
|
|
80
|
+
* Maps Android SDK event class names to camelCase type strings expected by TypeScript.
|
|
81
|
+
*/
|
|
82
|
+
private fun getEventType(event: Any): String? {
|
|
83
|
+
return when (event) {
|
|
84
|
+
is PaywallOpen -> "paywallOpen"
|
|
85
|
+
is PaywallClose -> "paywallClose"
|
|
86
|
+
is PaywallDismissed -> "paywallDismissed"
|
|
87
|
+
is PaywallOpenFailed -> "paywallOpenFailed"
|
|
88
|
+
is PaywallSkipped -> "paywallSkipped"
|
|
89
|
+
is PaywallButtonPressed -> "paywallButtonPressed"
|
|
90
|
+
is ProductSelected -> "productSelected"
|
|
91
|
+
is PurchasedPressed -> "purchasePressed"
|
|
92
|
+
is PurchaseSucceeded -> "purchaseSucceeded"
|
|
93
|
+
is PurchaseCancelled -> "purchaseCancelled"
|
|
94
|
+
is PurchaseFailed -> "purchaseFailed"
|
|
95
|
+
is PurchaseRestored -> "purchaseRestored"
|
|
96
|
+
is PurchaseRestoreFailed -> "purchaseRestoreFailed"
|
|
97
|
+
is PurchasePending -> "purchasePending"
|
|
98
|
+
is PaywallsDownloadSuccess -> "paywallsDownloadSuccess"
|
|
99
|
+
is PaywallsDownloadError -> "paywallsDownloadError"
|
|
100
|
+
is PaywallWebViewRendered -> "paywallWebViewRendered"
|
|
101
|
+
is CustomPaywallAction -> "customPaywallAction"
|
|
102
|
+
//is UserAllocatedEvent -> "userAllocated"
|
|
103
|
+
else -> "unknown"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Singleton to manage purchase state that survives module recreation in dev mode
|
|
108
|
+
private object NativeModuleManager {
|
|
109
|
+
// Always keep reference to the current module
|
|
110
|
+
var currentModule: HeliumPaywallSdkModule? = null
|
|
111
|
+
|
|
112
|
+
// Store active operations
|
|
113
|
+
var purchaseContinuation: ((HeliumPaywallTransactionStatus) -> Unit)? = null
|
|
114
|
+
var restoreContinuation: ((Boolean) -> Unit)? = null
|
|
115
|
+
|
|
116
|
+
fun clearPurchase() {
|
|
117
|
+
purchaseContinuation = null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fun clearRestore() {
|
|
121
|
+
restoreContinuation = null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
6
124
|
|
|
7
125
|
class HeliumPaywallSdkModule : Module() {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
126
|
+
companion object {
|
|
127
|
+
private const val DEFAULT_LOADING_BUDGET_MS = 7000L
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private val gson = Gson()
|
|
131
|
+
private var activityRef: WeakReference<Activity>? = null
|
|
132
|
+
|
|
133
|
+
private val activity: Activity?
|
|
134
|
+
get() = appContext.currentActivity ?: activityRef?.get()
|
|
135
|
+
|
|
11
136
|
override fun definition() = ModuleDefinition {
|
|
12
|
-
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
|
13
|
-
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
|
14
|
-
// The module will be accessible from `requireNativeModule('HeliumPaywallSdk')` in JavaScript.
|
|
15
137
|
Name("HeliumPaywallSdk")
|
|
16
138
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
)
|
|
139
|
+
OnCreate {
|
|
140
|
+
NativeModuleManager.currentModule = this@HeliumPaywallSdkModule
|
|
141
|
+
}
|
|
21
142
|
|
|
22
|
-
// Defines event names that the module can send to JavaScript
|
|
23
|
-
Events("
|
|
143
|
+
// Defines event names that the module can send to JavaScript
|
|
144
|
+
Events("onHeliumPaywallEvent", "onDelegateActionEvent", "paywallEventHandlers")
|
|
24
145
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
146
|
+
// Lifecycle event to cache Activity reference for hot reload resilience
|
|
147
|
+
OnActivityEntersForeground {
|
|
148
|
+
activityRef = WeakReference(appContext.currentActivity)
|
|
28
149
|
}
|
|
29
150
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
151
|
+
// Initialize the Helium SDK with configuration
|
|
152
|
+
Function("initialize") { config: Map<String, Any?> ->
|
|
153
|
+
NativeModuleManager.currentModule = this@HeliumPaywallSdkModule // extra redundancy to update to latest live module
|
|
154
|
+
|
|
155
|
+
val apiKey = config["apiKey"] as? String ?: ""
|
|
156
|
+
val customUserId = config["customUserId"] as? String
|
|
157
|
+
val customAPIEndpoint = config["customAPIEndpoint"] as? String
|
|
158
|
+
val useDefaultDelegate = config["useDefaultDelegate"] as? Boolean ?: false
|
|
159
|
+
|
|
160
|
+
@Suppress("UNCHECKED_CAST")
|
|
161
|
+
val customUserTraitsMap = config["customUserTraits"] as? Map<String, Any?>
|
|
162
|
+
val customUserTraits = convertToHeliumUserTraits(customUserTraitsMap)
|
|
163
|
+
|
|
164
|
+
// Extract fallback bundle fields from top-level config
|
|
165
|
+
val fallbackBundleUrlString = config["fallbackBundleUrlString"] as? String
|
|
166
|
+
val fallbackBundleString = config["fallbackBundleString"] as? String
|
|
167
|
+
|
|
168
|
+
@Suppress("UNCHECKED_CAST")
|
|
169
|
+
val paywallLoadingConfigMap = convertMarkersToBooleans(config["paywallLoadingConfig"] as? Map<String, Any?>)
|
|
170
|
+
val fallbackConfig = convertToHeliumFallbackConfig(
|
|
171
|
+
paywallLoadingConfigMap,
|
|
172
|
+
fallbackBundleUrlString,
|
|
173
|
+
fallbackBundleString,
|
|
174
|
+
appContext.reactContext
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// Parse environment parameter, defaulting to PRODUCTION
|
|
178
|
+
val environmentString = config["environment"] as? String
|
|
179
|
+
val environment = when (environmentString?.lowercase()) {
|
|
180
|
+
"sandbox" -> HeliumEnvironment.SANDBOX
|
|
181
|
+
"production" -> HeliumEnvironment.PRODUCTION
|
|
182
|
+
else -> HeliumEnvironment.PRODUCTION
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Event handler that converts events and adds backwards compatibility fields
|
|
186
|
+
val delegateEventHandler: (Any) -> Unit = { event ->
|
|
187
|
+
val eventMap = event.toMap().toMutableMap()
|
|
188
|
+
// Add deprecated fields for backwards compatibility
|
|
189
|
+
eventMap["paywallName"]?.let { eventMap["paywallTemplateName"] = it }
|
|
190
|
+
eventMap["error"]?.let { eventMap["errorDescription"] = it }
|
|
191
|
+
eventMap["productId"]?.let { eventMap["productKey"] = it }
|
|
192
|
+
eventMap["buttonName"]?.let { eventMap["ctaName"] = it }
|
|
193
|
+
|
|
194
|
+
// Add event type for JavaScript consumption
|
|
195
|
+
getEventType(event)?.let { eventMap["type"] = it }
|
|
196
|
+
|
|
197
|
+
NativeModuleManager.currentModule?.sendEvent("onHeliumPaywallEvent", eventMap)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Initialize on a coroutine scope
|
|
201
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
202
|
+
try {
|
|
203
|
+
val context = appContext.reactContext
|
|
204
|
+
?: throw Exceptions.ReactContextLost()
|
|
205
|
+
|
|
206
|
+
// Create delegate
|
|
207
|
+
val delegate = if (useDefaultDelegate) {
|
|
208
|
+
val currentActivity = activity
|
|
209
|
+
?: throw Exceptions.MissingActivity()
|
|
210
|
+
DefaultPaywallDelegate(currentActivity, delegateEventHandler)
|
|
211
|
+
} else {
|
|
212
|
+
CustomPaywallDelegate(this@HeliumPaywallSdkModule, delegateEventHandler)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
Helium.initialize(
|
|
216
|
+
context = context,
|
|
217
|
+
apiKey = apiKey,
|
|
218
|
+
heliumPaywallDelegate = delegate,
|
|
219
|
+
customUserId = customUserId,
|
|
220
|
+
customApiEndpoint = customAPIEndpoint,
|
|
221
|
+
customUserTraits = customUserTraits,
|
|
222
|
+
fallbackConfig = fallbackConfig,
|
|
223
|
+
environment = environment
|
|
224
|
+
)
|
|
225
|
+
} catch (e: Exception) {
|
|
226
|
+
// Log error but don't throw - initialization errors will be handled by SDK
|
|
227
|
+
android.util.Log.e("HeliumPaywallSdk", "Failed to initialize: ${e.message}", e)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Function for JavaScript to provide purchase result
|
|
233
|
+
Function("handlePurchaseResult") { statusString: String, errorMsg: String? ->
|
|
234
|
+
val continuation = NativeModuleManager.purchaseContinuation ?: return@Function
|
|
235
|
+
|
|
236
|
+
// Parse status string
|
|
237
|
+
val lowercasedStatus = statusString.lowercase()
|
|
238
|
+
val status: HeliumPaywallTransactionStatus = when (lowercasedStatus) {
|
|
239
|
+
"purchased" -> HeliumPaywallTransactionStatus.Purchased
|
|
240
|
+
"cancelled" -> HeliumPaywallTransactionStatus.Cancelled
|
|
241
|
+
"restored" -> HeliumPaywallTransactionStatus.Purchased // Android SDK has no Restored, map to Purchased
|
|
242
|
+
"pending" -> HeliumPaywallTransactionStatus.Pending
|
|
243
|
+
"failed" -> HeliumPaywallTransactionStatus.Failed(
|
|
244
|
+
Exception(errorMsg ?: "Unexpected error.")
|
|
245
|
+
)
|
|
246
|
+
else -> HeliumPaywallTransactionStatus.Failed(
|
|
247
|
+
Exception("Unknown status: $lowercasedStatus")
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Clear the singleton state
|
|
252
|
+
NativeModuleManager.clearPurchase()
|
|
253
|
+
|
|
254
|
+
// Resume the continuation with the status
|
|
255
|
+
continuation(status)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Function for JavaScript to provide restore result
|
|
259
|
+
Function("handleRestoreResult") { success: Boolean ->
|
|
260
|
+
val continuation = NativeModuleManager.restoreContinuation ?: return@Function
|
|
261
|
+
|
|
262
|
+
// Clear the singleton state
|
|
263
|
+
NativeModuleManager.clearRestore()
|
|
264
|
+
continuation(success)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Present a paywall with the given trigger
|
|
268
|
+
Function("presentUpsell") { trigger: String, customPaywallTraits: Map<String, Any?>?, dontShowIfAlreadyEntitled: Boolean? ->
|
|
269
|
+
NativeModuleManager.currentModule = this@HeliumPaywallSdkModule // extra redundancy to update to latest live module
|
|
270
|
+
|
|
271
|
+
// Convert custom paywall traits
|
|
272
|
+
val convertedTraits = convertToHeliumUserTraits(customPaywallTraits)
|
|
273
|
+
|
|
274
|
+
// Helper to send event to JavaScript
|
|
275
|
+
val sendPaywallEvent: (Any) -> Unit = { event ->
|
|
276
|
+
val eventMap = event.toMap().toMutableMap()
|
|
277
|
+
// Add event type for JavaScript consumption
|
|
278
|
+
getEventType(event)?.let { eventMap["type"] = it }
|
|
279
|
+
NativeModuleManager.currentModule?.sendEvent("paywallEventHandlers", eventMap)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
val eventHandlers = PaywallEventHandlers(
|
|
283
|
+
onOpen = { event -> sendPaywallEvent(event) },
|
|
284
|
+
onClose = { event -> sendPaywallEvent(event) },
|
|
285
|
+
onDismissed = { event -> sendPaywallEvent(event) },
|
|
286
|
+
onPurchaseSucceeded = { event -> sendPaywallEvent(event) },
|
|
287
|
+
onOpenFailed = { event -> sendPaywallEvent(event) },
|
|
288
|
+
onCustomPaywallAction = { event -> sendPaywallEvent(event) }
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
Helium.presentUpsell(
|
|
292
|
+
trigger = trigger,
|
|
293
|
+
dontShowIfAlreadyEntitled = dontShowIfAlreadyEntitled,
|
|
294
|
+
eventListener = eventHandlers
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Hide the current upsell
|
|
299
|
+
Function("hideUpsell") {
|
|
300
|
+
Helium.hideUpsell()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Hide all upsells
|
|
304
|
+
Function("hideAllUpsells") {
|
|
305
|
+
Helium.hideAllUpsells()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Get download status of paywall assets
|
|
309
|
+
Function("getDownloadStatus") {
|
|
310
|
+
val status = (Helium.shared.downloadStatus as? kotlinx.coroutines.flow.StateFlow<*>)?.value
|
|
311
|
+
val statusString = when (status?.javaClass?.simpleName) {
|
|
312
|
+
"NotYetDownloaded" -> "notDownloadedYet"
|
|
313
|
+
"Downloading" -> "inProgress"
|
|
314
|
+
"DownloadFailure" -> "downloadFailure"
|
|
315
|
+
"DownloadSuccess" -> "downloadSuccess"
|
|
316
|
+
else -> "notDownloadedYet"
|
|
317
|
+
}
|
|
318
|
+
return@Function statusString
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Handle fallback open/close events
|
|
322
|
+
Function("fallbackOpenOrCloseEvent") { trigger: String?, isOpen: Boolean, viewType: String? ->
|
|
323
|
+
// TODO: Call Helium SDK fallback event handler
|
|
324
|
+
// TODO: Pass trigger, isOpen, viewType, and fallbackReason
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Get paywall info for a specific trigger
|
|
328
|
+
Function("getPaywallInfo") { trigger: String ->
|
|
329
|
+
val paywallInfo = Helium.shared.getPaywallInfo(trigger)
|
|
330
|
+
|
|
331
|
+
return@Function if (paywallInfo == null) {
|
|
332
|
+
PaywallInfoResult().apply {
|
|
333
|
+
errorMsg = "Invalid trigger or paywalls not ready."
|
|
334
|
+
templateName = null
|
|
335
|
+
shouldShow = null
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
PaywallInfoResult().apply {
|
|
339
|
+
errorMsg = null
|
|
340
|
+
templateName = paywallInfo.paywallTemplateName
|
|
341
|
+
shouldShow = paywallInfo.shouldShow
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Set RevenueCat app user ID
|
|
347
|
+
Function("setRevenueCatAppUserId") { rcAppUserId: String ->
|
|
348
|
+
HeliumIdentityManager.shared.setRevenueCatAppUserId(rcAppUserId)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Set custom user ID
|
|
352
|
+
Function("setCustomUserId") { newUserId: String ->
|
|
353
|
+
HeliumIdentityManager.shared.setCustomUserId(newUserId)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check if user has entitlement for a specific paywall
|
|
357
|
+
AsyncFunction("hasEntitlementForPaywall") Coroutine { trigger: String ->
|
|
358
|
+
val result = Helium.shared.hasEntitlementForPaywall(trigger)
|
|
359
|
+
return@Coroutine HasEntitlementResult().apply {
|
|
360
|
+
hasEntitlement = result
|
|
361
|
+
}
|
|
37
362
|
}
|
|
38
363
|
|
|
39
|
-
//
|
|
40
|
-
|
|
364
|
+
// Check if user has any active subscription
|
|
365
|
+
AsyncFunction("hasAnyActiveSubscription") { promise: Promise ->
|
|
366
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
367
|
+
try {
|
|
368
|
+
val result = Helium.shared.hasAnyActiveSubscription()
|
|
369
|
+
promise.resolve(result)
|
|
370
|
+
} catch (e: Exception) {
|
|
371
|
+
promise.reject("ERR_HAS_ANY_ACTIVE_SUBSCRIPTION", e.message, e)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check if user has any entitlement
|
|
377
|
+
AsyncFunction("hasAnyEntitlement") { promise: Promise ->
|
|
378
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
379
|
+
try {
|
|
380
|
+
val result = Helium.shared.hasAnyEntitlement()
|
|
381
|
+
promise.resolve(result)
|
|
382
|
+
} catch (e: Exception) {
|
|
383
|
+
promise.reject("ERR_HAS_ANY_ENTITLEMENT", e.message, e)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Handle deep link
|
|
389
|
+
Function("handleDeepLink") { urlString: String ->
|
|
390
|
+
val handled = Helium.shared.handleDeepLink(uri = urlString)
|
|
391
|
+
return@Function handled
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Get experiment info for a trigger
|
|
395
|
+
Function("getExperimentInfoForTrigger") { trigger: String ->
|
|
396
|
+
val experimentInfo = Helium.shared.getExperimentInfoForTrigger(trigger)
|
|
397
|
+
|
|
398
|
+
return@Function if (experimentInfo == null) {
|
|
399
|
+
mapOf<String, Any?>(
|
|
400
|
+
"getExperimentInfoErrorMsg" to "No experiment info found for trigger: $trigger"
|
|
401
|
+
)
|
|
402
|
+
} else {
|
|
403
|
+
try {
|
|
404
|
+
val json = gson.toJson(experimentInfo)
|
|
405
|
+
val type = object : TypeToken<Map<String, Any?>>() {}.type
|
|
406
|
+
val map: Map<String, Any?> = gson.fromJson(json, type)
|
|
407
|
+
map
|
|
408
|
+
} catch (e: Exception) {
|
|
409
|
+
mapOf<String, Any?>(
|
|
410
|
+
"getExperimentInfoErrorMsg" to "Failed to serialize experiment info"
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Disable restore failed dialog
|
|
417
|
+
Function("disableRestoreFailedDialog") {
|
|
418
|
+
Helium.shared.disableRestoreFailedDialog()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Set custom restore failed strings
|
|
422
|
+
Function("setCustomRestoreFailedStrings") { customTitle: String?, customMessage: String?, customCloseButtonText: String? ->
|
|
423
|
+
Helium.shared.setCustomRestoreFailedStrings(
|
|
424
|
+
customTitle = customTitle,
|
|
425
|
+
customMessage = customMessage,
|
|
426
|
+
customCloseButtonText = customCloseButtonText
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Reset Helium SDK
|
|
431
|
+
Function("resetHelium") {
|
|
432
|
+
Helium.resetHelium()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Set light/dark mode override
|
|
436
|
+
Function("setLightDarkModeOverride") { mode: String ->
|
|
437
|
+
val heliumMode: HeliumLightDarkMode = when (mode.lowercase()) {
|
|
438
|
+
"light" -> HeliumLightDarkMode.LIGHT
|
|
439
|
+
"dark" -> HeliumLightDarkMode.DARK
|
|
440
|
+
"system" -> HeliumLightDarkMode.SYSTEM
|
|
441
|
+
else -> {
|
|
442
|
+
android.util.Log.w("HeliumPaywallSdk", "Invalid mode: $mode, defaulting to system")
|
|
443
|
+
HeliumLightDarkMode.SYSTEM
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
Helium.shared.setLightDarkModeOverride(heliumMode)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Enables the module to be used as a native view
|
|
41
450
|
View(HeliumPaywallSdkView::class) {
|
|
42
|
-
// Defines a setter for the `url` prop
|
|
451
|
+
// Defines a setter for the `url` prop
|
|
43
452
|
Prop("url") { view: HeliumPaywallSdkView, url: URL ->
|
|
44
453
|
view.webView.loadUrl(url.toString())
|
|
45
454
|
}
|
|
46
|
-
// Defines an event that the view can send to JavaScript
|
|
455
|
+
// Defines an event that the view can send to JavaScript
|
|
47
456
|
Events("onLoad")
|
|
48
457
|
}
|
|
49
458
|
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Recursively converts special marker strings back to boolean values to restore
|
|
462
|
+
* type information that was preserved when passing through native bridge.
|
|
463
|
+
*
|
|
464
|
+
* Native bridge converts booleans to numbers, so we use special marker strings
|
|
465
|
+
* to preserve the original intent. This helper converts:
|
|
466
|
+
* - "__helium_rn_bool_true__" -> true
|
|
467
|
+
* - "__helium_rn_bool_false__" -> false
|
|
468
|
+
* - All other values remain unchanged
|
|
469
|
+
*/
|
|
470
|
+
private fun convertMarkersToBooleans(input: Map<String, Any?>?): Map<String, Any?>? {
|
|
471
|
+
if (input == null) return null
|
|
472
|
+
return input.mapValues { (_, value) ->
|
|
473
|
+
convertValueMarkersToBooleans(value)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private fun convertValueMarkersToBooleans(value: Any?): Any? {
|
|
478
|
+
return when (value) {
|
|
479
|
+
"__helium_rn_bool_true__" -> true
|
|
480
|
+
"__helium_rn_bool_false__" -> false
|
|
481
|
+
is String -> value
|
|
482
|
+
is Map<*, *> -> {
|
|
483
|
+
@Suppress("UNCHECKED_CAST")
|
|
484
|
+
convertMarkersToBooleans(value as? Map<String, Any?>)
|
|
485
|
+
}
|
|
486
|
+
is List<*> -> value.map { convertValueMarkersToBooleans(it) }
|
|
487
|
+
else -> value
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private fun convertToHeliumUserTraits(input: Map<String, Any?>?): HeliumUserTraits? {
|
|
492
|
+
if (input == null) return null
|
|
493
|
+
val convertedInput = convertMarkersToBooleans(input) ?: return null
|
|
494
|
+
val traits = convertedInput.mapValues { (_, value) ->
|
|
495
|
+
convertToHeliumUserTraitsArgument(value)
|
|
496
|
+
}.filterValues { it != null }.mapValues { it.value!! }
|
|
497
|
+
return HeliumUserTraits(traits)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private fun convertToHeliumUserTraitsArgument(value: Any?): HeliumUserTraitsArgument? {
|
|
501
|
+
return when (value) {
|
|
502
|
+
is String -> HeliumUserTraitsArgument.StringParam(value)
|
|
503
|
+
is Int -> HeliumUserTraitsArgument.IntParam(value)
|
|
504
|
+
is Long -> HeliumUserTraitsArgument.LongParam(value)
|
|
505
|
+
is Double -> HeliumUserTraitsArgument.DoubleParam(value.toString())
|
|
506
|
+
is Boolean -> HeliumUserTraitsArgument.BooleanParam(value)
|
|
507
|
+
is List<*> -> {
|
|
508
|
+
val items = value.mapNotNull { convertToHeliumUserTraitsArgument(it) }
|
|
509
|
+
HeliumUserTraitsArgument.Array(items)
|
|
510
|
+
}
|
|
511
|
+
is Map<*, *> -> {
|
|
512
|
+
@Suppress("UNCHECKED_CAST")
|
|
513
|
+
val properties = (value as? Map<String, Any?>)?.mapValues { (_, v) ->
|
|
514
|
+
convertToHeliumUserTraitsArgument(v)
|
|
515
|
+
}?.filterValues { it != null }?.mapValues { it.value!! } ?: emptyMap()
|
|
516
|
+
HeliumUserTraitsArgument.Complex(properties)
|
|
517
|
+
}
|
|
518
|
+
else -> null
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private fun convertToHeliumFallbackConfig(
|
|
523
|
+
paywallLoadingConfig: Map<String, Any?>?,
|
|
524
|
+
fallbackBundleUrlString: String?,
|
|
525
|
+
fallbackBundleString: String?,
|
|
526
|
+
context: android.content.Context?
|
|
527
|
+
): HeliumFallbackConfig? {
|
|
528
|
+
// Extract loading config settings
|
|
529
|
+
val useLoadingState = paywallLoadingConfig?.get("useLoadingState") as? Boolean ?: true
|
|
530
|
+
val loadingBudget = (paywallLoadingConfig?.get("loadingBudget") as? Number)?.toLong() ?: DEFAULT_LOADING_BUDGET_MS
|
|
531
|
+
|
|
532
|
+
// Parse perTriggerLoadingConfig if present
|
|
533
|
+
var perTriggerLoadingConfig: Map<String, HeliumFallbackConfig>? = null
|
|
534
|
+
val perTriggerDict = paywallLoadingConfig?.get("perTriggerLoadingConfig") as? Map<*, *>
|
|
535
|
+
if (perTriggerDict != null) {
|
|
536
|
+
@Suppress("UNCHECKED_CAST")
|
|
537
|
+
perTriggerLoadingConfig = perTriggerDict.mapNotNull { (key, value) ->
|
|
538
|
+
if (key is String && value is Map<*, *>) {
|
|
539
|
+
val config = value as? Map<String, Any?>
|
|
540
|
+
val triggerUseLoadingState = config?.get("useLoadingState") as? Boolean
|
|
541
|
+
val triggerLoadingBudget = (config?.get("loadingBudget") as? Number)?.toLong()
|
|
542
|
+
key to HeliumFallbackConfig(
|
|
543
|
+
useLoadingState = triggerUseLoadingState ?: true,
|
|
544
|
+
loadingBudgetInMs = triggerLoadingBudget ?: DEFAULT_LOADING_BUDGET_MS
|
|
545
|
+
)
|
|
546
|
+
} else {
|
|
547
|
+
null
|
|
548
|
+
}
|
|
549
|
+
}.toMap() as Map<String, HeliumFallbackConfig>
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Handle fallback bundle - write to helium_local directory where SDK expects it
|
|
553
|
+
var fallbackBundleName: String? = null
|
|
554
|
+
if (context != null && (fallbackBundleUrlString != null || fallbackBundleString != null)) {
|
|
555
|
+
try {
|
|
556
|
+
val heliumLocalDir = context.getDir("helium_local", android.content.Context.MODE_PRIVATE)
|
|
557
|
+
val destinationFile = java.io.File(heliumLocalDir, "helium-fallback.json")
|
|
558
|
+
|
|
559
|
+
if (fallbackBundleUrlString != null) {
|
|
560
|
+
// Copy file from Expo's document directory to helium_local
|
|
561
|
+
val sourceFile = java.io.File(java.net.URI.create(fallbackBundleUrlString))
|
|
562
|
+
if (sourceFile.exists()) {
|
|
563
|
+
sourceFile.copyTo(destinationFile, overwrite = true)
|
|
564
|
+
fallbackBundleName = "helium-fallback.json"
|
|
565
|
+
}
|
|
566
|
+
} else if (fallbackBundleString != null) {
|
|
567
|
+
// Write fallback bundle string to file
|
|
568
|
+
destinationFile.writeText(fallbackBundleString)
|
|
569
|
+
fallbackBundleName = "helium-fallback.json"
|
|
570
|
+
}
|
|
571
|
+
} catch (e: Exception) {
|
|
572
|
+
// Silently fail for now
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return HeliumFallbackConfig(
|
|
577
|
+
useLoadingState = useLoadingState,
|
|
578
|
+
loadingBudgetInMs = loadingBudget,
|
|
579
|
+
perTriggerLoadingConfig = perTriggerLoadingConfig,
|
|
580
|
+
fallbackBundleName = fallbackBundleName
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Custom Helium Paywall Delegate that bridges purchase calls to React Native.
|
|
587
|
+
* Similar to the InternalDelegate in iOS implementation.
|
|
588
|
+
*/
|
|
589
|
+
class CustomPaywallDelegate(
|
|
590
|
+
private val module: HeliumPaywallSdkModule,
|
|
591
|
+
private val eventHandler: (Any) -> Unit
|
|
592
|
+
) : HeliumPaywallDelegate {
|
|
593
|
+
|
|
594
|
+
override fun onHeliumEvent(event: HeliumEvent) {
|
|
595
|
+
eventHandler(event)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
override suspend fun makePurchase(
|
|
599
|
+
productDetails: ProductDetails,
|
|
600
|
+
basePlanId: String?,
|
|
601
|
+
offerId: String?
|
|
602
|
+
): HeliumPaywallTransactionStatus {
|
|
603
|
+
return suspendCancellableCoroutine { continuation ->
|
|
604
|
+
// First check singleton for orphaned continuation and clean it up
|
|
605
|
+
NativeModuleManager.purchaseContinuation?.let { existingContinuation ->
|
|
606
|
+
existingContinuation(HeliumPaywallTransactionStatus.Cancelled)
|
|
607
|
+
NativeModuleManager.clearPurchase()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
val currentModule = NativeModuleManager.currentModule ?: module
|
|
611
|
+
|
|
612
|
+
NativeModuleManager.purchaseContinuation = { status ->
|
|
613
|
+
continuation.resume(status)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Clean up on cancellation to prevent memory leaks and crashes
|
|
617
|
+
continuation.invokeOnCancellation {
|
|
618
|
+
NativeModuleManager.clearPurchase()
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Send event to JavaScript with separate parameters
|
|
622
|
+
val eventMap = mutableMapOf<String, Any?>(
|
|
623
|
+
"type" to "purchase",
|
|
624
|
+
"productId" to productDetails.productId
|
|
625
|
+
)
|
|
626
|
+
if (basePlanId != null) {
|
|
627
|
+
eventMap["basePlanId"] = basePlanId
|
|
628
|
+
}
|
|
629
|
+
if (offerId != null) {
|
|
630
|
+
eventMap["offerId"] = offerId
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
currentModule.sendEvent("onDelegateActionEvent", eventMap)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
override suspend fun restorePurchases(): Boolean {
|
|
638
|
+
return suspendCancellableCoroutine { continuation ->
|
|
639
|
+
// Check singleton for orphaned continuation and clean it up
|
|
640
|
+
NativeModuleManager.restoreContinuation?.let { existingContinuation ->
|
|
641
|
+
existingContinuation(false)
|
|
642
|
+
NativeModuleManager.clearRestore()
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
val currentModule = NativeModuleManager.currentModule ?: module
|
|
646
|
+
|
|
647
|
+
NativeModuleManager.restoreContinuation = { success ->
|
|
648
|
+
continuation.resume(success)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Clean up on cancellation to prevent memory leaks and crashes
|
|
652
|
+
continuation.invokeOnCancellation {
|
|
653
|
+
NativeModuleManager.clearRestore()
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Send event to JavaScript
|
|
657
|
+
currentModule.sendEvent("onDelegateActionEvent", mapOf(
|
|
658
|
+
"type" to "restore"
|
|
659
|
+
))
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Default Paywall Delegate that extends PlayStorePaywallDelegate with event dispatching.
|
|
666
|
+
* Similar to the DefaultPurchaseDelegate in iOS implementation.
|
|
667
|
+
*/
|
|
668
|
+
class DefaultPaywallDelegate(
|
|
669
|
+
activity: Activity,
|
|
670
|
+
private val eventHandler: (Any) -> Unit
|
|
671
|
+
) : PlayStorePaywallDelegate(activity) {
|
|
672
|
+
|
|
673
|
+
override fun onHeliumEvent(event: HeliumEvent) {
|
|
674
|
+
eventHandler(event)
|
|
675
|
+
}
|
|
50
676
|
}
|