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.
@@ -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
- // Each module class must implement the definition function. The definition consists of components
9
- // that describes the module's functionality and behavior.
10
- // See https://docs.expo.dev/modules/module-api for more details about available components.
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
- // Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary.
18
- Constants(
19
- "PI" to Math.PI
20
- )
139
+ OnCreate {
140
+ NativeModuleManager.currentModule = this@HeliumPaywallSdkModule
141
+ }
21
142
 
22
- // Defines event names that the module can send to JavaScript.
23
- Events("onChange")
143
+ // Defines event names that the module can send to JavaScript
144
+ Events("onHeliumPaywallEvent", "onDelegateActionEvent", "paywallEventHandlers")
24
145
 
25
- // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
26
- Function("hello") {
27
- "Hello world! 👋"
146
+ // Lifecycle event to cache Activity reference for hot reload resilience
147
+ OnActivityEntersForeground {
148
+ activityRef = WeakReference(appContext.currentActivity)
28
149
  }
29
150
 
30
- // Defines a JavaScript function that always returns a Promise and whose native code
31
- // is by default dispatched on the different thread than the JavaScript runtime runs on.
32
- AsyncFunction("setValueAsync") { value: String ->
33
- // Send an event to JavaScript.
34
- sendEvent("onChange", mapOf(
35
- "value" to value
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
- // Enables the module to be used as a native view. Definition components that are accepted as part of
40
- // the view definition: Prop, Events.
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
  }