@purchasely/cordova-plugin-purchasely 5.7.3 → 6.0.0-rc.3

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.
Files changed (33) hide show
  1. package/README.md +12 -8
  2. package/__tests__/Purchasely.test.js +307 -92
  3. package/example/config.xml +1 -11
  4. package/example/e2e/README.md +44 -0
  5. package/example/e2e/helpers/driver.js +70 -0
  6. package/example/e2e/package.json +19 -0
  7. package/example/e2e/specs/bridge.e2e.js +51 -0
  8. package/example/e2e/specs/dismiss.e2e.js +37 -0
  9. package/example/e2e/tools/ci_run_e2e.sh +49 -0
  10. package/example/e2e/tools/ci_run_e2e_ios.sh +46 -0
  11. package/example/e2e/wdio.android.conf.js +21 -0
  12. package/example/e2e/wdio.ios.conf.js +19 -0
  13. package/example/e2e/wdio.shared.conf.js +28 -0
  14. package/example/package-lock.json +59 -205
  15. package/example/package.json +7 -6
  16. package/example/www/index.html +1 -7
  17. package/example/www/js/index.js +113 -92
  18. package/package.json +1 -1
  19. package/plugin.xml +4 -22
  20. package/src/android/PurchaselyPlugin.kt +529 -440
  21. package/src/ios/CDVPurchasely+Events.h +1 -1
  22. package/src/ios/CDVPurchasely+UserAttributes.h +1 -1
  23. package/src/ios/CDVPurchasely+UserAttributes.m +2 -0
  24. package/src/ios/CDVPurchasely.h +21 -16
  25. package/src/ios/CDVPurchasely.m +497 -261
  26. package/www/Purchasely.js +208 -40
  27. package/src/android/PLYProductActivity.kt +0 -168
  28. package/src/android/PLYSubscriptionsActivity.java +0 -37
  29. package/src/android/activity_ply_product_activity.xml +0 -6
  30. package/src/android/activity_ply_subscriptions_activity.xml +0 -6
  31. package/src/android/theme_purchasely_fullscreen.xml +0 -6
  32. package/src/android/theme_purchasely_fullscreen_v23.xml +0 -6
  33. package/src/android/theme_purchasely_fullscreen_v23_light_status_bar.xml +0 -7
@@ -1,7 +1,5 @@
1
1
  package cordova.plugin.purchasely
2
2
 
3
- import android.app.Activity
4
- import android.content.Intent
5
3
  import android.net.Uri
6
4
  import android.os.Build
7
5
  import android.util.Log
@@ -10,28 +8,24 @@ import io.purchasely.ext.Attribute
10
8
  import io.purchasely.ext.DistributionType
11
9
  import io.purchasely.ext.EventListener
12
10
  import io.purchasely.ext.LogLevel
11
+ import io.purchasely.ext.PLYActionInterceptorCallback
13
12
  import io.purchasely.ext.PLYAppTechnology
14
- import io.purchasely.ext.PLYCompletionHandler
15
13
  import io.purchasely.ext.PLYDataProcessingLegalBasis
16
14
  import io.purchasely.ext.PLYDataProcessingPurpose
17
15
  import io.purchasely.ext.PLYEvent
18
- import io.purchasely.ext.PLYPresentation
19
- import io.purchasely.ext.PLYPresentationAction
20
- import io.purchasely.ext.PLYPresentationType
21
- import io.purchasely.ext.PLYPresentationProperties
22
- import io.purchasely.ext.PLYProductViewResult
16
+ import io.purchasely.ext.PLYInterceptResult
17
+ import io.purchasely.ext.PLYInterceptorInfo
23
18
  import io.purchasely.ext.PLYRunningMode
24
- import io.purchasely.ext.PLYRunningMode.Full
25
- import io.purchasely.ext.PLYRunningMode.PaywallObserver
26
- import io.purchasely.ext.PlanListener
27
- import io.purchasely.ext.ProductListener
28
- import io.purchasely.ext.ProductsListener
29
19
  import io.purchasely.ext.PurchaseListener
30
20
  import io.purchasely.ext.Purchasely
31
21
  import io.purchasely.ext.State
32
22
  import io.purchasely.ext.StoreType
33
- import io.purchasely.ext.SubscriptionsListener
34
23
  import io.purchasely.ext.UserAttributeListener
24
+ import io.purchasely.ext.presentation.PLYPresentationAction
25
+ import io.purchasely.ext.presentation.PLYPresentationBase
26
+ import io.purchasely.ext.presentation.PLYPresentationOutcome
27
+ import io.purchasely.ext.presentation.display
28
+ import io.purchasely.ext.presentation.preload
35
29
  import io.purchasely.storage.userData.PLYUserAttributeSource
36
30
  import io.purchasely.storage.userData.PLYUserAttributeType
37
31
  import io.purchasely.models.PLYError
@@ -40,26 +34,52 @@ import io.purchasely.models.PLYPresentationPlan
40
34
  import io.purchasely.models.PLYProduct
41
35
  import io.purchasely.models.PLYSubscriptionData
42
36
  import io.purchasely.views.presentation.PLYThemeMode
37
+ import io.purchasely.views.presentation.models.PLYDimensionType
38
+ import io.purchasely.views.presentation.models.PLYTransition
39
+ import io.purchasely.views.presentation.models.PLYTransitionDimension
40
+ import io.purchasely.views.presentation.models.PLYTransitionType
41
+ import kotlinx.coroutines.CoroutineScope
42
+ import kotlinx.coroutines.Dispatchers
43
+ import kotlinx.coroutines.SupervisorJob
44
+ import kotlinx.coroutines.launch
43
45
  import org.apache.cordova.CallbackContext
44
- import org.apache.cordova.CordovaInterface
45
46
  import org.apache.cordova.CordovaPlugin
46
47
  import org.apache.cordova.PluginResult
47
48
  import org.json.JSONArray
48
49
  import org.json.JSONException
49
50
  import org.json.JSONObject
50
- import java.lang.ref.WeakReference
51
51
  import java.text.SimpleDateFormat
52
52
  import java.util.Calendar
53
53
  import java.util.Date
54
54
  import java.util.Locale
55
55
  import java.util.TimeZone
56
+ import java.util.concurrent.ConcurrentHashMap
56
57
 
57
58
  /**
58
59
  * This class echoes a string called from JavaScript.
59
60
  */
60
- class PurchaselyPlugin : CordovaPlugin() {
61
- private var paywallActionHandler: PLYCompletionHandler? = null
62
- private var paywallAction: PLYPresentationAction? = null
61
+ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
62
+
63
+ private val job = SupervisorJob()
64
+ override val coroutineContext = job + Dispatchers.Main
65
+
66
+ // v6 per-action interceptor state. Each registered action kind keeps its own
67
+ // Cordova callback (to emit intercept events); each intercepted invocation stashes
68
+ // its completion under a unique id so concurrent intercepts resolve independently.
69
+ private val actionInterceptorCallbacks = ConcurrentHashMap<String, CallbackContext>()
70
+ private val pendingInterceptCompletions = ConcurrentHashMap<String, (PLYInterceptResult) -> Unit>()
71
+ private var interceptorInvocationCounter = 0
72
+
73
+ // Presentation currently displayed (used for back()/close()).
74
+ private var displayedPresentation: PLYPresentationBase.Loaded? = null
75
+ // Presentations preloaded by fetchPresentation, keyed by a synthetic handle
76
+ // (the "id" we send back to JS) so presentPresentation can re-display them.
77
+ private val loadedPresentations = ConcurrentHashMap<String, PLYPresentationBase.Loaded>()
78
+
79
+ override fun onDestroy() {
80
+ job.cancel()
81
+ super.onDestroy()
82
+ }
63
83
 
64
84
  override fun execute(
65
85
  action: String,
@@ -68,16 +88,7 @@ class PurchaselyPlugin : CordovaPlugin() {
68
88
  ): Boolean {
69
89
  try {
70
90
  when (action) {
71
- "start" -> start(
72
- getStringFromJson(args.getString(0)),
73
- args.getJSONArray(1),
74
- args.getBoolean(2),
75
- getStringFromJson(args.getString(3)),
76
- args.getInt(4),
77
- args.getInt(5),
78
- getStringFromJson(args.getString(6)),
79
- callbackContext
80
- )
91
+ "start" -> start(args.getJSONObject(0), callbackContext)
81
92
 
82
93
  "close" -> close()
83
94
  "addEventsListener" -> addEventsListener(callbackContext)
@@ -91,42 +102,35 @@ class PurchaselyPlugin : CordovaPlugin() {
91
102
  "setLogLevel" -> setLogLevel(args.getInt(0))
92
103
  "setThemeMode" -> setThemeMode(args.getInt(0))
93
104
  "setAttribute" -> setAttribute(args.getInt(0), getStringFromJson(args.getString(1)))
94
- "setDefaultPresentationResultHandler" -> setDefaultPresentationResultHandler(
105
+ "setDefaultPresentationDismissHandler" -> setDefaultPresentationDismissHandler(
95
106
  callbackContext
96
107
  )
108
+ "removeDefaultPresentationDismissHandler" -> removeDefaultPresentationDismissHandler(callbackContext)
97
109
 
98
110
  "purchasedSubscription" -> purchasedSubscription(callbackContext)
99
- "readyToOpenDeeplink" -> readyToOpenDeeplink(args.getBoolean(0))
100
- "synchronize" -> synchronize()
111
+ "allowDeeplink" -> allowDeeplink(args.getBoolean(0))
112
+ "allowCampaigns" -> allowCampaigns(args.getBoolean(0))
113
+ "synchronize" -> synchronize(callbackContext)
101
114
  "presentPresentationWithIdentifier" -> presentPresentationWithIdentifier(
102
115
  getStringFromJson(args.getString(0)),
103
116
  getStringFromJson(args.getString(1)),
104
- args.getBoolean(2),
117
+ args.optJSONObject(2),
105
118
  callbackContext
106
119
  )
107
120
 
108
121
  "presentPresentationForPlacement" -> presentPresentationForPlacement(
109
122
  getStringFromJson(args.getString(0)),
110
123
  getStringFromJson(args.getString(1)),
111
- args.getBoolean(2),
124
+ args.optJSONObject(2),
112
125
  callbackContext
113
126
  )
114
127
 
115
- "presentProductWithIdentifier" -> presentProductWithIdentifier(
128
+ "presentPresentationForDefault" -> presentPresentationForDefault(
116
129
  getStringFromJson(args.getString(0)),
117
- getStringFromJson(args.getString(1)),
118
- getStringFromJson(args.getString(2)),
119
- args.getBoolean(3),
130
+ args.optJSONObject(1),
120
131
  callbackContext
121
132
  )
122
133
 
123
- "presentPlanWithIdentifier" -> presentPlanWithIdentifier(
124
- getStringFromJson(args.getString(0)),
125
- getStringFromJson(args.getString(1)),
126
- getStringFromJson(args.getString(2)),
127
- args.getBoolean(3),
128
- callbackContext
129
- )
130
134
  "fetchPresentation" -> fetchPresentation(
131
135
  getStringFromJson(args.getString(0)),
132
136
  getStringFromJson(args.getString(1)),
@@ -135,16 +139,15 @@ class PurchaselyPlugin : CordovaPlugin() {
135
139
  )
136
140
  "presentPresentation" -> presentPresentation(
137
141
  args.getJSONObject(0),
138
- args.getBoolean(1),
142
+ args.optJSONObject(1),
139
143
  getStringFromJson(args.getString(2)),
140
144
  callbackContext
141
145
  )
142
- "presentSubscriptions" -> presentSubscriptions()
143
146
  "restoreAllProducts" -> restoreAllProducts(callbackContext)
144
147
  "silentRestoreAllProducts" -> restoreAllProducts(callbackContext)
145
148
  "userSubscriptions" -> userSubscriptions(callbackContext)
146
149
  "userSubscriptionsHistory" -> userSubscriptionsHistory(callbackContext)
147
- "isDeeplinkHandled" -> isDeeplinkHandled(
150
+ "handleDeeplink" -> handleDeeplink(
148
151
  getStringFromJson(args.getString(0)),
149
152
  callbackContext
150
153
  )
@@ -166,11 +169,11 @@ class PurchaselyPlugin : CordovaPlugin() {
166
169
  getStringFromJson(args.getString(2)),
167
170
  callbackContext
168
171
  )
169
- "setPaywallActionInterceptor" -> setPaywallActionInterceptor(callbackContext)
170
- "onProcessAction" -> onProcessAction(args.getBoolean(0))
172
+ "registerActionInterceptor" -> registerActionInterceptor(getStringFromJson(args.getString(0)), callbackContext)
173
+ "unregisterActionInterceptor" -> unregisterActionInterceptor(getStringFromJson(args.getString(0)))
174
+ "completeActionInterceptor" -> completeActionInterceptor(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)))
171
175
  "closePresentation" -> closePresentation(callbackContext)
172
- "hidePresentation" -> hidePresentation()
173
- "showPresentation" -> showPresentation()
176
+ "backPresentation" -> backPresentation(callbackContext)
174
177
  "userDidConsumeSubscriptionContent" -> userDidConsumeSubscriptionContent()
175
178
  "setUserAttributeWithString" -> setUserAttributeWithString(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), getStringFromJson(args.optString(2)))
176
179
  "setUserAttributeWithBoolean" -> setUserAttributeWithBoolean(getStringFromJson(args.getString(0)), args.getBoolean(1), getStringFromJson(args.optString(2)))
@@ -203,52 +206,83 @@ class PurchaselyPlugin : CordovaPlugin() {
203
206
  } else value
204
207
  }
205
208
 
206
- private fun start(
207
- apiKey: String?,
208
- stores: JSONArray,
209
- storeKit1: Boolean,
210
- userId: String?,
211
- logLevel: Int,
212
- runningMode: Int,
213
- cordovaSdkVersion: String?,
214
- callbackContext: CallbackContext
215
- ) {
209
+ //region start
210
+ private fun logLevelFrom(raw: Any?): LogLevel {
211
+ return when (raw) {
212
+ is Number -> LogLevel.values().getOrElse(raw.toInt()) { LogLevel.ERROR }
213
+ is String -> LogLevel.values().firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: LogLevel.ERROR
214
+ else -> LogLevel.ERROR
215
+ }
216
+ }
216
217
 
217
- if(apiKey == null) {
218
+ // v6: running mode is passed by NAME ("observer"/"full"). The old int mapping is
219
+ // gone; PLYRunningMode has only Observer (default) and Full.
220
+ private fun runningModeFrom(raw: Any?): PLYRunningMode {
221
+ return when (raw) {
222
+ is Number -> when (raw.toInt()) {
223
+ 1 -> PLYRunningMode.Full
224
+ else -> PLYRunningMode.Observer
225
+ }
226
+ is String -> when (raw.lowercase(Locale.US)) {
227
+ "full" -> PLYRunningMode.Full
228
+ else -> PLYRunningMode.Observer
229
+ }
230
+ else -> PLYRunningMode.Observer
231
+ }
232
+ }
233
+
234
+ private fun start(options: JSONObject, callbackContext: CallbackContext) {
235
+ val apiKey = getStringFromJson(options.optString("apiKey"))
236
+ if (apiKey == null) {
218
237
  callbackContext.error("API Key is null")
219
238
  return
220
239
  }
221
240
 
222
- val list = ArrayList<String>()
223
- for (i in 0 until stores.length()) {
224
- try {
225
- list.add(stores.getString(i))
226
- } catch (e: JSONException) {
227
- Log.e("Purchasely", "Error in store array" + e.message, e)
241
+ val userId = getStringFromJson(options.optString("appUserId"))
242
+ val logLevel = logLevelFrom(options.opt("logLevel"))
243
+ val runningMode = runningModeFrom(options.opt("runningMode"))
244
+
245
+ val storesList = ArrayList<String>()
246
+ options.optJSONArray("stores")?.let { arr ->
247
+ for (i in 0 until arr.length()) {
248
+ try {
249
+ storesList.add(arr.getString(i))
250
+ } catch (e: JSONException) {
251
+ Log.e("Purchasely", "Error in store array" + e.message, e)
252
+ }
228
253
  }
229
254
  }
230
- val storesInstances = getStoresInstances(list)
231
- var plyRunningMode: PLYRunningMode = Full
232
- if (runningMode == runningModePaywallObserver) plyRunningMode = PaywallObserver
255
+
256
+ val allowDeeplink = if (options.has("allowDeeplink")) options.optBoolean("allowDeeplink") else null
257
+ val allowCampaigns = if (options.has("allowCampaigns")) options.optBoolean("allowCampaigns") else null
258
+ val deeplink = getStringFromJson(options.optString("deeplink"))
259
+ val sdkVersion = getStringFromJson(options.optString("sdkVersion"))
260
+
233
261
  Purchasely.Builder(cordova.context)
234
262
  .apiKey(apiKey)
235
- .stores(storesInstances)
263
+ .stores(getStoresInstances(storesList))
236
264
  .userId(userId)
237
- .runningMode(plyRunningMode)
238
- .logLevel(LogLevel.values()[logLevel])
265
+ .runningMode(runningMode)
266
+ .logLevel(logLevel)
267
+ .apply {
268
+ allowDeeplink?.let { this.allowDeeplink(it) }
269
+ allowCampaigns?.let { this.allowCampaigns(it) }
270
+ // Cold-start deeplink: replayed automatically once started.
271
+ deeplink?.let { this.handleDeeplink(Uri.parse(it)) }
272
+ }
239
273
  .build()
240
- Purchasely.sdkBridgeVersion = cordovaSdkVersion
274
+
275
+ Purchasely.sdkBridgeVersion = sdkVersion
241
276
  Purchasely.appTechnology = PLYAppTechnology.CORDOVA
242
- Purchasely.start { isConfigured: Boolean, error: PLYError? ->
243
- if (isConfigured) {
244
- callbackContext.success()
277
+ Purchasely.start { error: PLYError? ->
278
+ if (error == null) {
279
+ callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, true))
245
280
  } else {
246
- callbackContext.error(
247
- if (error != null) error.message else "Purchasely SDK not configured"
248
- )
281
+ callbackContext.error(error.message ?: "Purchasely SDK not configured")
249
282
  }
250
283
  }
251
284
  }
285
+ //endregion
252
286
 
253
287
  private fun getStoresInstances(stores: List<String>): ArrayList<Store> {
254
288
  val result = ArrayList<Store>()
@@ -279,9 +313,12 @@ class PurchaselyPlugin : CordovaPlugin() {
279
313
  private fun close() {
280
314
  defaultCallback = null
281
315
  purchaseCallback = null
282
- paywallActionHandler = null
283
- productActivity = null
284
- Purchasely.close()
316
+ actionInterceptorCallbacks.clear()
317
+ pendingInterceptCompletions.clear()
318
+ displayedPresentation = null
319
+ // TODO(v6-verify): the global Purchasely.close() teardown was removed/lumped with
320
+ // presentation close in v6 (Flutter never calls it). Not invoked here to avoid a
321
+ // reference to a symbol that may no longer exist.
285
322
  }
286
323
 
287
324
  private fun addUserAttributesListener(callbackContext: CallbackContext) {
@@ -358,7 +395,7 @@ class PurchaselyPlugin : CordovaPlugin() {
358
395
  }
359
396
 
360
397
  private fun userLogout() {
361
- Purchasely.userLogout()
398
+ Purchasely.userLogout(true)
362
399
  }
363
400
 
364
401
  private fun setLogLevel(logLevel: Int) {
@@ -373,8 +410,12 @@ class PurchaselyPlugin : CordovaPlugin() {
373
410
  }
374
411
  }
375
412
 
376
- private fun readyToOpenDeeplink(isReadyToPurchase: Boolean) {
377
- Purchasely.readyToOpenDeeplink = isReadyToPurchase
413
+ private fun allowDeeplink(allow: Boolean) {
414
+ Purchasely.allowDeeplink = allow
415
+ }
416
+
417
+ private fun allowCampaigns(allow: Boolean) {
418
+ Purchasely.allowCampaigns = allow
378
419
  }
379
420
 
380
421
  private fun setThemeMode(mode: Int) {
@@ -414,15 +455,16 @@ class PurchaselyPlugin : CordovaPlugin() {
414
455
  }
415
456
  }
416
457
 
417
- private fun setDefaultPresentationResultHandler(callbackContext: CallbackContext) {
458
+ //region Default presentation dismiss handler
459
+ private fun setDefaultPresentationDismissHandler(callbackContext: CallbackContext) {
418
460
  defaultCallback = callbackContext
419
- Purchasely.setDefaultPresentationResultHandler { result: PLYProductViewResult, plan: PLYPlan? ->
420
- sendPurchaseResult(
421
- result,
422
- plan
423
- )
461
+ Purchasely.setDefaultPresentationDismissHandler { outcome: PLYPresentationOutcome ->
462
+ val pluginResult = PluginResult(PluginResult.Status.OK, JSONObject(outcomeToMap(outcome)))
463
+ pluginResult.keepCallback = true
464
+ defaultCallback?.sendPluginResult(pluginResult)
424
465
  }
425
466
  }
467
+ //endregion
426
468
 
427
469
  private fun purchasedSubscription(callbackContext: CallbackContext) {
428
470
  Purchasely.purchaseListener = object : PurchaseListener {
@@ -437,72 +479,115 @@ class PurchaselyPlugin : CordovaPlugin() {
437
479
  }
438
480
  }
439
481
 
440
- private fun synchronize() {
441
- Purchasely.synchronize()
482
+ private fun synchronize(callbackContext: CallbackContext) {
483
+ Purchasely.synchronize(
484
+ onSuccess = {
485
+ callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, true))
486
+ },
487
+ onError = { error ->
488
+ callbackContext.error(error?.message ?: "Synchronization failed")
489
+ }
490
+ )
442
491
  }
443
492
 
444
493
  private fun userDidConsumeSubscriptionContent() {
445
494
  Purchasely.userDidConsumeSubscriptionContent()
446
495
  }
447
496
 
497
+ //region Presentation lifecycle
498
+ // v6: the presentation activities are gone. A presentation is built via the
499
+ // builder DSL, preloaded, then displayed. `displayMode` maps to a PLYTransition.
448
500
  private fun presentPresentationWithIdentifier(
449
501
  presentationVendorId: String?,
450
502
  contentId: String?,
451
- isFullScreen: Boolean,
503
+ transition: JSONObject?,
452
504
  callbackContext: CallbackContext
453
505
  ) {
454
506
  purchaseCallback = callbackContext
455
- val intent = PLYProductActivity.newIntent(cordova.activity)
456
- intent.putExtra("presentationId", presentationVendorId)
457
- intent.putExtra("contentId", contentId)
458
- intent.putExtra("isFullScreen", isFullScreen)
459
- cordova.activity.startActivity(intent)
507
+ displayPresentation(
508
+ screenId = presentationVendorId,
509
+ placementId = null,
510
+ contentId = contentId,
511
+ transition = transition,
512
+ callbackContext = callbackContext
513
+ )
460
514
  }
461
515
 
462
516
  private fun presentPresentationForPlacement(
463
517
  placementVendorId: String?,
464
518
  contentId: String?,
465
- isFullScreen: Boolean,
519
+ transition: JSONObject?,
466
520
  callbackContext: CallbackContext
467
521
  ) {
468
522
  purchaseCallback = callbackContext
469
- val intent = PLYProductActivity.newIntent(cordova.activity)
470
- intent.putExtra("placementId", placementVendorId)
471
- intent.putExtra("contentId", contentId)
472
- intent.putExtra("isFullScreen", isFullScreen)
473
- cordova.activity.startActivity(intent)
523
+ displayPresentation(
524
+ screenId = null,
525
+ placementId = placementVendorId,
526
+ contentId = contentId,
527
+ transition = transition,
528
+ callbackContext = callbackContext
529
+ )
474
530
  }
475
531
 
476
- private fun presentProductWithIdentifier(
477
- productVendorId: String?,
478
- presentationVendorId: String?,
532
+ // v6: present the default (audience-targeted) presentation — no placement/screen id.
533
+ private fun presentPresentationForDefault(
479
534
  contentId: String?,
480
- isFullScreen: Boolean,
535
+ transition: JSONObject?,
481
536
  callbackContext: CallbackContext
482
537
  ) {
483
538
  purchaseCallback = callbackContext
484
- val intent = PLYProductActivity.newIntent(cordova.activity)
485
- intent.putExtra("presentationId", presentationVendorId)
486
- intent.putExtra("productId", productVendorId)
487
- intent.putExtra("contentId", contentId)
488
- intent.putExtra("isFullScreen", isFullScreen)
489
- cordova.activity.startActivity(intent)
539
+ displayPresentation(
540
+ screenId = null,
541
+ placementId = null,
542
+ contentId = contentId,
543
+ transition = transition,
544
+ callbackContext = callbackContext
545
+ )
490
546
  }
491
547
 
492
- private fun presentPlanWithIdentifier(
493
- planVendorId: String?,
494
- presentationVendorId: String?,
548
+ private fun displayPresentation(
549
+ screenId: String?,
550
+ placementId: String?,
495
551
  contentId: String?,
496
- isFullScreen: Boolean,
552
+ transition: JSONObject?,
497
553
  callbackContext: CallbackContext
498
554
  ) {
499
- purchaseCallback = callbackContext
500
- val intent = PLYProductActivity.newIntent(cordova.activity)
501
- intent.putExtra("presentationId", presentationVendorId)
502
- intent.putExtra("planId", planVendorId)
503
- intent.putExtra("contentId", contentId)
504
- intent.putExtra("isFullScreen", isFullScreen)
505
- cordova.activity.startActivity(intent)
555
+ launch {
556
+ try {
557
+ val builder = PLYPresentationBase.builder().apply {
558
+ when {
559
+ placementId != null -> this.placementId(placementId)
560
+ screenId != null -> this.screenId(screenId)
561
+ // else: default (audience-targeted) source — no id
562
+ }
563
+ contentId(contentId)
564
+ // v6: stream the presentation lifecycle to the JS callback via keep-alive
565
+ // envelopes; the dismiss outcome is delivered as the final (non-kept)
566
+ // result by sendPurchaseResult.
567
+ onPresented { presentation, error ->
568
+ val env = mutableMapOf<String, Any?>("event" to "presented")
569
+ presentation?.let { env["presentation"] = presentationToMap(it) }
570
+ error?.let { env["error"] = it.message }
571
+ val r = PluginResult(PluginResult.Status.OK, JSONObject(env))
572
+ r.keepCallback = true
573
+ callbackContext.sendPluginResult(r)
574
+ }
575
+ onCloseRequested {
576
+ val r = PluginResult(PluginResult.Status.OK, JSONObject(mapOf("event" to "closeRequested")))
577
+ r.keepCallback = true
578
+ callbackContext.sendPluginResult(r)
579
+ }
580
+ }
581
+ val loaded = builder.build().preload()
582
+ displayedPresentation = loaded
583
+ loaded.display(cordova.activity, transitionFromMap(transition)) { outcome ->
584
+ sendPurchaseResult(outcome)
585
+ }
586
+ } catch (t: Throwable) {
587
+ if (purchaseCallback === callbackContext) purchaseCallback = null
588
+ callbackContext.error(t.message ?: "Unable to present presentation")
589
+ }
590
+ }
506
591
  }
507
592
 
508
593
  private fun fetchPresentation(
@@ -510,43 +595,31 @@ class PurchaselyPlugin : CordovaPlugin() {
510
595
  presentationId: String?,
511
596
  contentId: String?,
512
597
  callbackContext: CallbackContext) {
513
- val properties = PLYPresentationProperties(
514
- placementId = placementId,
515
- presentationId = presentationId,
516
- contentId = contentId)
517
-
518
- Purchasely.fetchPresentation(properties = properties) { presentation: PLYPresentation?, error: PLYError? ->
519
- if(presentation != null) {
520
- presentationsLoaded.removeAll { it.id == presentation.id && it.placementId == presentation.placementId }
521
- presentationsLoaded.add(presentation)
522
- val map = presentation.toMap().mapValues {
523
- val value = it.value
524
- if(value is PLYPresentationType) value.ordinal
525
- else value
526
- }
527
-
528
- val mutableMap = map.toMutableMap().apply {
529
- //this["metadata"] = presentation.metadata?.toMap()
530
- this["plans"] = (this["plans"] as List<PLYPresentationPlan>).map { it.toMap() }
598
+ launch {
599
+ try {
600
+ val builder = PLYPresentationBase.builder().apply {
601
+ when {
602
+ placementId != null -> this.placementId(placementId)
603
+ presentationId != null -> this.screenId(presentationId)
604
+ // else: neither id → the default (audience-targeted) presentation
605
+ }
606
+ contentId(contentId)
531
607
  }
608
+ val loaded = builder.build().preload()
609
+ // Synthetic handle so presentPresentation can re-display this preloaded presentation.
610
+ val handle = "ply_fetch_${System.nanoTime()}"
611
+ loadedPresentations[handle] = loaded
612
+ val map = presentationToMap(loaded).toMutableMap()
613
+ map["id"] = handle
532
614
  callbackContext.success(JSONObject(map))
615
+ } catch (t: Throwable) {
616
+ callbackContext.error(t.message ?: "Unable to fetch presentation")
533
617
  }
534
- if(error != null) callbackContext.error(error.message ?: "Unable to fetch presentation")
535
618
  }
536
619
  }
537
620
 
538
- // Delete when available in Android SDK
539
- fun PLYPresentationPlan.toMap() : Map<String, String?> {
540
- return mapOf(
541
- Pair("planVendorId", planVendorId),
542
- Pair("storeProductId", storeProductId),
543
- Pair("basePlanId", basePlanId),
544
- //Pair("offerId", offerId)
545
- )
546
- }
547
-
548
621
  private fun presentPresentation(presentationMap: JSONObject?,
549
- isFullScreen: Boolean,
622
+ transition: JSONObject?,
550
623
  loadingBackgroundColor: String?,
551
624
  callbackContext: CallbackContext) {
552
625
  if (presentationMap == null) {
@@ -554,112 +627,130 @@ class PurchaselyPlugin : CordovaPlugin() {
554
627
  return
555
628
  }
556
629
 
557
- val presentation = presentationsLoaded.lastOrNull {
558
- it.id == presentationMap.getString("id")
559
- && it.placementId == presentationMap.getString("placementId")
560
- }
561
- if(presentation == null) {
630
+ val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
631
+ val loaded = handle?.let { loadedPresentations[it] }
632
+ if (loaded == null) {
562
633
  callbackContext.error("presentation cannot be found")
563
634
  return
564
635
  }
565
636
 
566
637
  purchaseCallback = callbackContext
567
-
568
- cordova.activity.let { activity ->
569
- if (presentation.flowId != null) {
570
- presentation.display(activity) { result, plan ->
571
- sendPurchaseResult(result, plan)
572
- }
573
- } else {
574
- // Open legacy Activity for now if not a flow
575
- val intent = PLYProductActivity.newIntent(activity, PLYPresentationProperties(), isFullScreen, loadingBackgroundColor).apply {
576
- putExtra("presentation", presentation)
577
- }
578
- activity.startActivity(intent)
638
+ displayedPresentation = loaded
639
+
640
+ // TODO(v6-verify): loadingBackgroundColor cannot be applied to an already-preloaded
641
+ // presentation in v6 (colors are builder options set before preload). The re-display
642
+ // path delivers the dismiss outcome only; onPresented/onCloseRequested are wired on the
643
+ // direct present* methods (builder-seeded before preload).
644
+ cordova.activity?.runOnUiThread {
645
+ loaded.display(cordova.activity, transitionFromMap(transition)) { outcome ->
646
+ sendPurchaseResult(outcome)
579
647
  }
580
648
  }
581
-
582
649
  }
583
650
 
584
- private fun presentSubscriptions() {
585
- val intent =
586
- Intent(cordova.context, PLYSubscriptionsActivity::class.java)
587
- cordova.activity.startActivity(intent)
651
+ // v6: stop receiving default (campaign/deeplink) presentation dismiss outcomes.
652
+ private fun removeDefaultPresentationDismissHandler(callbackContext: CallbackContext) {
653
+ // Android's setter is non-null; install a no-op handler and drop the JS callback
654
+ // so default (campaign/deeplink) dismiss outcomes are no longer forwarded.
655
+ Purchasely.setDefaultPresentationDismissHandler { }
656
+ defaultCallback = null
657
+ callbackContext.success()
588
658
  }
589
659
 
590
- private fun restoreAllProducts(callbackContext: CallbackContext) {
591
- Purchasely.restoreAllProducts({ plyPlan: PLYPlan? ->
592
- callbackContext.success(JSONObject(transformPlanToMap(plyPlan)))
593
- }) { plyError: PLYError? ->
594
- callbackContext.error(plyError?.message)
660
+ // Maps the JS transition object to a v6 PLYTransition. Shape:
661
+ // { type, dismissible?, width?: {type,value}, height?: {type,value} }
662
+ // width is popin-only; height drives drawer+popin. Null → surface default.
663
+ private fun transitionFromMap(map: JSONObject?): PLYTransition? {
664
+ if (map == null) return null
665
+ val type = when (map.optString("type")) {
666
+ "fullScreen" -> PLYTransitionType.FULLSCREEN
667
+ "push" -> PLYTransitionType.PUSH
668
+ "modal" -> PLYTransitionType.MODAL
669
+ "drawer" -> PLYTransitionType.DRAWER
670
+ "popin" -> PLYTransitionType.POPIN
671
+ "inlinePaywall" -> PLYTransitionType.INLINE_PAYWALL
672
+ else -> return null
595
673
  }
674
+ return PLYTransition(
675
+ type = type,
676
+ width = dimensionFromMap(map.optJSONObject("width")),
677
+ height = dimensionFromMap(map.optJSONObject("height")),
678
+ dismissible = map.optBoolean("dismissible", true)
679
+ )
596
680
  }
597
681
 
598
- private fun userSubscriptions(callbackContext: CallbackContext) {
599
- Purchasely.userSubscriptions(
600
- false,
601
- object : SubscriptionsListener {
602
- override fun onSuccess(list: List<PLYSubscriptionData>) {
603
- val result = JSONArray()
604
- for (i in list.indices) {
605
- val data = list[i]
606
- val map = HashMap(data.toMap())
607
- map["plan"] = transformPlanToMap(data.plan)
608
- map["product"] = data.product.toMap()
609
- if (data.data.storeType == StoreType.GOOGLE_PLAY_STORE) {
610
- map["subscriptionSource"] = StoreType.GOOGLE_PLAY_STORE.ordinal
611
- } else if (data.data.storeType == StoreType.AMAZON_APP_STORE) {
612
- map["subscriptionSource"] = StoreType.AMAZON_APP_STORE.ordinal
613
- } else if (data.data.storeType == StoreType.HUAWEI_APP_GALLERY) {
614
- map["subscriptionSource"] = StoreType.HUAWEI_APP_GALLERY.ordinal
615
- } else if (data.data.storeType == StoreType.APPLE_APP_STORE) {
616
- map["subscriptionSource"] = StoreType.APPLE_APP_STORE.ordinal
617
- }
618
- result.put(JSONObject(map))
619
- }
620
- callbackContext.success(result)
621
- }
682
+ // { type: 'pixel'|'percentage', value: Number } → PLYTransitionDimension. Null when absent.
683
+ private fun dimensionFromMap(map: JSONObject?): PLYTransitionDimension? {
684
+ if (map == null || !map.has("value")) return null
685
+ val value = map.optDouble("value").toFloat()
686
+ val type = if (map.optString("type") == "pixel") PLYDimensionType.PIXEL else PLYDimensionType.PERCENTAGE
687
+ return PLYTransitionDimension(type, value)
688
+ }
622
689
 
623
- override fun onFailure(throwable: Throwable) {
624
- callbackContext.error(throwable.message)
625
- }
690
+ private fun closePresentation(callbackContext: CallbackContext) {
691
+ Purchasely.closeAllScreens()
692
+ displayedPresentation = null
693
+ callbackContext.success()
694
+ }
695
+
696
+ private fun backPresentation(callbackContext: CallbackContext) {
697
+ cordova.activity?.runOnUiThread { displayedPresentation?.back() }
698
+ callbackContext.success()
699
+ }
700
+ //endregion
701
+
702
+ private fun restoreAllProducts(callbackContext: CallbackContext) {
703
+ Purchasely.restoreAllProducts(
704
+ onSuccess = {
705
+ callbackContext.success()
706
+ },
707
+ onError = { plyError: PLYError? ->
708
+ callbackContext.error(plyError?.message)
626
709
  }
627
710
  )
628
711
  }
629
712
 
713
+ private fun userSubscriptions(callbackContext: CallbackContext) {
714
+ launch {
715
+ try {
716
+ val list = Purchasely.userSubscriptions(true)
717
+ callbackContext.success(transformSubscriptionsToJson(list))
718
+ } catch (e: Exception) {
719
+ callbackContext.error(e.message)
720
+ }
721
+ }
722
+ }
723
+
630
724
  private fun userSubscriptionsHistory(callbackContext: CallbackContext) {
631
- Purchasely.userSubscriptionsHistory(
632
- false,
633
- object : SubscriptionsListener {
634
- override fun onSuccess(list: List<PLYSubscriptionData>) {
635
- val result = JSONArray()
636
- for (i in list.indices) {
637
- val data = list[i]
638
- val map = HashMap(data.toMap())
639
- map["plan"] = transformPlanToMap(data.plan)
640
- map["product"] = data.product.toMap()
641
- if (data.data.storeType == StoreType.GOOGLE_PLAY_STORE) {
642
- map["subscriptionSource"] = StoreType.GOOGLE_PLAY_STORE.ordinal
643
- } else if (data.data.storeType == StoreType.AMAZON_APP_STORE) {
644
- map["subscriptionSource"] = StoreType.AMAZON_APP_STORE.ordinal
645
- } else if (data.data.storeType == StoreType.HUAWEI_APP_GALLERY) {
646
- map["subscriptionSource"] = StoreType.HUAWEI_APP_GALLERY.ordinal
647
- } else if (data.data.storeType == StoreType.APPLE_APP_STORE) {
648
- map["subscriptionSource"] = StoreType.APPLE_APP_STORE.ordinal
649
- }
650
- result.put(JSONObject(map))
651
- }
652
- callbackContext.success(result)
653
- }
725
+ launch {
726
+ try {
727
+ val list = Purchasely.userSubscriptionsHistory(true)
728
+ callbackContext.success(transformSubscriptionsToJson(list))
729
+ } catch (e: Exception) {
730
+ callbackContext.error(e.message)
731
+ }
732
+ }
733
+ }
654
734
 
655
- override fun onFailure(throwable: Throwable) {
656
- callbackContext.error(throwable.message)
657
- }
735
+ private fun transformSubscriptionsToJson(list: List<PLYSubscriptionData>): JSONArray {
736
+ val result = JSONArray()
737
+ for (data in list) {
738
+ val map = HashMap(data.data.toMap())
739
+ map["plan"] = transformPlanToMap(data.plan)
740
+ map["product"] = data.product.toMap()
741
+ map["subscriptionSource"] = when (data.data.storeType) {
742
+ StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal
743
+ StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal
744
+ StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal
745
+ StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal
746
+ else -> null
658
747
  }
659
- )
748
+ result.put(JSONObject(map))
749
+ }
750
+ return result
660
751
  }
661
752
 
662
- private fun isDeeplinkHandled(deeplink: String?, callbackContext: CallbackContext) {
753
+ private fun handleDeeplink(deeplink: String?, callbackContext: CallbackContext) {
663
754
  if (deeplink == null) {
664
755
  callbackContext.error("Deeplink must not be null")
665
756
  return
@@ -668,25 +759,24 @@ class PurchaselyPlugin : CordovaPlugin() {
668
759
  callbackContext.sendPluginResult(
669
760
  PluginResult(
670
761
  PluginResult.Status.OK,
671
- Purchasely.isDeeplinkHandled(uri)
762
+ Purchasely.handleDeeplink(uri, cordova.activity)
672
763
  )
673
764
  )
674
765
  }
675
766
 
676
767
  private fun allProducts(callbackContext: CallbackContext) {
677
- Purchasely.allProducts(object : ProductsListener {
678
- override fun onSuccess(list: List<PLYProduct>) {
768
+ launch {
769
+ try {
770
+ val list = Purchasely.allProducts()
679
771
  val result = JSONArray()
680
- for (i in list.indices) {
681
- result.put(JSONObject(list[i].toMap()))
772
+ for (product in list) {
773
+ result.put(JSONObject(product.toMap()))
682
774
  }
683
775
  callbackContext.success(result)
776
+ } catch (e: Exception) {
777
+ callbackContext.error(e.message)
684
778
  }
685
-
686
- override fun onFailure(throwable: Throwable) {
687
- callbackContext.error(throwable.message)
688
- }
689
- })
779
+ }
690
780
  }
691
781
 
692
782
  private fun productWithIdentifier(vendorId: String?, callbackContext: CallbackContext) {
@@ -694,19 +784,18 @@ class PurchaselyPlugin : CordovaPlugin() {
694
784
  callbackContext.error("No product found with $vendorId")
695
785
  return
696
786
  }
697
- Purchasely.product(vendorId, object : ProductListener {
698
- override fun onSuccess(product: PLYProduct?) {
787
+ launch {
788
+ try {
789
+ val product: PLYProduct? = Purchasely.product(vendorId)
699
790
  if (product != null) {
700
791
  callbackContext.success(JSONObject(product.toMap()))
701
792
  } else {
702
793
  callbackContext.error("No product found with $vendorId")
703
794
  }
795
+ } catch (e: Exception) {
796
+ callbackContext.error(e.message)
704
797
  }
705
-
706
- override fun onFailure(throwable: Throwable) {
707
- callbackContext.error(throwable.message)
708
- }
709
- })
798
+ }
710
799
  }
711
800
 
712
801
  private fun planWithIdentifier(vendorId: String?, callbackContext: CallbackContext) {
@@ -714,19 +803,18 @@ class PurchaselyPlugin : CordovaPlugin() {
714
803
  callbackContext.error("No plan found with $vendorId")
715
804
  return
716
805
  }
717
- Purchasely.plan(vendorId, object : PlanListener {
718
- override fun onSuccess(plan: PLYPlan?) {
806
+ launch {
807
+ try {
808
+ val plan: PLYPlan? = Purchasely.plan(vendorId)
719
809
  if (plan != null) {
720
810
  callbackContext.success(JSONObject(transformPlanToMap(plan)))
721
811
  } else {
722
812
  callbackContext.error("No plan found with $vendorId")
723
813
  }
814
+ } catch (e: Exception) {
815
+ callbackContext.error(e.message)
724
816
  }
725
-
726
- override fun onFailure(throwable: Throwable) {
727
- callbackContext.error(throwable.message)
728
- }
729
- })
817
+ }
730
818
  }
731
819
 
732
820
  private fun purchaseWithPlanVendorId(
@@ -740,114 +828,129 @@ class PurchaselyPlugin : CordovaPlugin() {
740
828
  return
741
829
  }
742
830
 
743
- Purchasely.plan(planVendorId, object : PlanListener {
744
- override fun onSuccess(plyPlan: PLYPlan?) {
831
+ launch {
832
+ try {
833
+ val plyPlan: PLYPlan? = Purchasely.plan(planVendorId)
745
834
  if (plyPlan != null) {
746
835
  val offer = plyPlan.promoOffers.firstOrNull { it.vendorId == offerId }
747
836
  Purchasely.purchase(cordova.activity, plyPlan, offer, contentId,
748
- { plyPlan1: PLYPlan? ->
837
+ onSuccess = { plyPlan1: PLYPlan? ->
749
838
  callbackContext.success(JSONObject(transformPlanToMap(plyPlan1)))
750
- }) { plyError: PLYError? ->
751
- callbackContext.error(plyError?.message)
752
- }
839
+ },
840
+ onError = { plyError: PLYError? ->
841
+ callbackContext.error(plyError?.message)
842
+ })
753
843
  } else {
754
844
  callbackContext.error("No plan found with $planVendorId")
755
845
  }
846
+ } catch (e: Exception) {
847
+ callbackContext.error(e.message)
756
848
  }
849
+ }
850
+ }
757
851
 
758
- override fun onFailure(throwable: Throwable) {
759
- callbackContext.error(throwable.message)
852
+ //region Action interceptor
853
+ // v6: register a JS handler for a single action kind. Each intercept emits an
854
+ // event carrying a unique callbackId; JS replies via completeActionInterceptor.
855
+ private fun registerActionInterceptor(kind: String?, callbackContext: CallbackContext) {
856
+ if (kind == null) return
857
+ val clazz = PLYPresentationAction.fromValue(kind)?.java
858
+ if (clazz == null) {
859
+ Log.w("Purchasely", "Unknown interceptor kind: $kind")
860
+ return
861
+ }
862
+ actionInterceptorCallbacks[kind] = callbackContext
863
+ Purchasely.interceptAction(clazz, object : PLYActionInterceptorCallback {
864
+ override fun onIntercept(
865
+ info: PLYInterceptorInfo,
866
+ action: PLYPresentationAction,
867
+ completion: (PLYInterceptResult) -> Unit
868
+ ) {
869
+ val callback = actionInterceptorCallbacks[kind]
870
+ if (callback == null) {
871
+ completion(PLYInterceptResult.NOT_HANDLED)
872
+ return
873
+ }
874
+ // Unique id per intercepted invocation so concurrent intercepts resolve independently.
875
+ val invocationId = "$kind#${++interceptorInvocationCounter}"
876
+ pendingInterceptCompletions[invocationId] = completion
877
+
878
+ val map = hashMapOf<String, Any?>(
879
+ "action" to kind,
880
+ "callbackId" to invocationId,
881
+ "info" to mapOf(
882
+ // TODO(v6-verify): presentationId derived from info.presentation?.screenId;
883
+ // v6 PLYInterceptorInfo exposes {contentId, presentation}, not a raw id.
884
+ "contentId" to info.contentId,
885
+ "presentationId" to info.presentation?.screenId
886
+ ),
887
+ "parameters" to (actionPayloadToMap(action) ?: emptyMap<String, Any?>())
888
+ )
889
+ val result = PluginResult(PluginResult.Status.OK, JSONObject(map))
890
+ result.keepCallback = true
891
+ callback.sendPluginResult(result)
760
892
  }
761
893
  })
762
894
  }
763
895
 
764
- private fun setPaywallActionInterceptor(callbackContext: CallbackContext) {
765
- Purchasely.setPaywallActionsInterceptor { info, action, parameters, processAction ->
766
- paywallActionHandler = processAction
767
- paywallAction = action
768
-
769
- interceptorActivity = WeakReference(info?.activity)
896
+ // v6: stop intercepting a single action kind.
897
+ private fun unregisterActionInterceptor(kind: String?) {
898
+ if (kind == null) return
899
+ val clazz = PLYPresentationAction.fromValue(kind)?.java ?: return
900
+ actionInterceptorCallbacks.remove(kind)
901
+ runCatching { Purchasely.removeActionInterceptor(clazz) }.onFailure {
902
+ Log.w("Purchasely", "removeActionInterceptor($kind) failed: ${it.message}")
903
+ }
904
+ }
770
905
 
771
- val parametersForCordova = hashMapOf<String, Any?>();
772
- parametersForCordova["title"] = parameters.title
773
- parametersForCordova["url"] = parameters.url?.toString()
774
- parametersForCordova["plan"] = transformPlanToMap(parameters.plan)
775
- parametersForCordova["offer"] = mapOf(
776
- "vendorId" to parameters.offer?.vendorId,
777
- "storeOfferId" to parameters.offer?.storeOfferId
906
+ private fun actionPayloadToMap(action: PLYPresentationAction): Map<String, Any?>? {
907
+ return when (action) {
908
+ is PLYPresentationAction.Navigate -> mapOf(
909
+ "url" to action.url?.toString(),
910
+ "title" to action.title
778
911
  )
779
- parametersForCordova["subscriptionOffer"] = parameters.subscriptionOffer?.toMap()
780
- parametersForCordova["presentation"] = parameters.presentation
781
- parametersForCordova["placement"] = parameters.placement
782
- parametersForCordova["closeReason"] = parameters.closeReason?.name
783
- parametersForCordova["clientReferenceId"] = parameters?.clientReferenceId
784
- parametersForCordova["queryParameterKey"] = parameters?.queryParameterKey
785
- parametersForCordova["webCheckoutProvider"] = parameters?.webCheckoutProvider?.name
786
-
787
- val result = PluginResult(
788
- PluginResult.Status.OK,
789
- JSONObject(
790
- hashMapOf<String, Any?>(
791
- Pair("info", mapOf(
792
- Pair("contentId", info?.contentId),
793
- Pair("presentationId", info?.presentationId),
794
- Pair("placementId", info?.placementId),
795
- Pair("abTestId", info?.abTestId),
796
- Pair("abTestVariantId", info?.abTestVariantId)
797
- )),
798
- Pair("action", action.value),
799
- Pair("parameters", parametersForCordova.filterNot { it.value == null })
912
+ is PLYPresentationAction.Purchase -> mapOf(
913
+ "plan" to transformPlanToMap(action.plan),
914
+ "subscriptionOffer" to action.subscriptionOffer?.toMap(),
915
+ "offer" to action.offer?.let { offer ->
916
+ mapOf(
917
+ "vendorId" to offer.vendorId,
918
+ "storeOfferId" to offer.storeOfferId
800
919
  )
801
- )
920
+ }
802
921
  )
803
- result.keepCallback = true
804
- callbackContext.sendPluginResult(result)
805
- }
806
- }
807
-
808
- private fun closePresentation(callbackContext: CallbackContext) {
809
- val openedPaywall = productActivity?.activity?.get()
810
- openedPaywall?.finish()
811
- productActivity = null
812
- }
813
-
814
- private fun onProcessAction(processAction: Boolean) {
815
- val activityHandler = interceptorActivity?.get() ?: productActivity?.activity?.get() ?: cordova.activity
816
- activityHandler?.runOnUiThread {
817
- paywallActionHandler?.invoke(processAction)
818
-
819
- interceptorActivity?.clear()
820
- interceptorActivity = null
922
+ is PLYPresentationAction.Close -> mapOf("closeReason" to action.closeReason.value)
923
+ is PLYPresentationAction.CloseAll -> mapOf("closeReason" to action.closeReason.value)
924
+ is PLYPresentationAction.OpenPresentation -> mapOf("presentationId" to action.presentationId)
925
+ is PLYPresentationAction.OpenPlacement -> mapOf("placementId" to action.placementId)
926
+ is PLYPresentationAction.WebCheckout -> mapOf(
927
+ "url" to action.url?.toString(),
928
+ "clientReferenceId" to action.clientReferenceId,
929
+ "queryParameterKey" to action.queryParameterKey,
930
+ "webCheckoutProvider" to action.webCheckoutProvider?.name
931
+ )
932
+ else -> null
821
933
  }
822
934
  }
823
935
 
824
- private fun showPresentation() {
825
- val currentActivity = interceptorActivity?.get()
826
-
827
- if (currentActivity != null && !currentActivity.isFinishing && !currentActivity.isDestroyed) {
828
- cordova.activity?.let {
829
- it.startActivity(
830
- Intent(it, currentActivity::class.java).apply {
831
- //flags = Intent.FLAG_ACTIVITY_NEW_TASK
832
- flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
833
- }
834
- )
835
- }
936
+ // v6: JS reports how an intercepted action was handled, keyed by the invocation id
937
+ // carried on the intercept event. Result is "success"/"failed"/"notHandled".
938
+ private fun completeActionInterceptor(callbackId: String?, result: String?) {
939
+ if (callbackId == null) return
940
+ val completion = pendingInterceptCompletions.remove(callbackId) ?: return
941
+ val plyResult = when (result) {
942
+ "success" -> PLYInterceptResult.SUCCESS
943
+ "failed" -> PLYInterceptResult.FAILED
944
+ else -> PLYInterceptResult.NOT_HANDLED
836
945
  }
837
- else {
838
- productActivity?.relaunch(cordova)
946
+ val activity = cordova.activity
947
+ if (activity != null) {
948
+ activity.runOnUiThread { completion.invoke(plyResult) }
949
+ } else {
950
+ completion.invoke(plyResult)
839
951
  }
840
952
  }
841
-
842
- private fun hidePresentation() {
843
- val cordovaActivity = cordova.activity
844
- val activity = productActivity?.activity?.get() ?: cordovaActivity
845
- cordovaActivity?.startActivity(
846
- Intent(activity, cordovaActivity::class.java).apply {
847
- flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
848
- }
849
- )
850
- }
953
+ //endregion
851
954
 
852
955
  fun setUserAttributeWithStringArray(key: String?, value: JSONArray?, legalBasisString: String?) {
853
956
  if(key == null || value == null) return
@@ -1033,14 +1136,15 @@ class PurchaselyPlugin : CordovaPlugin() {
1033
1136
  }
1034
1137
 
1035
1138
  private fun isEligibleForIntroOffer(planId: String?, callbackContext: CallbackContext) {
1036
- Purchasely.plan(planId,
1037
- onSuccess = { plan ->
1038
- callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, plan?.isEligibleToIntroOffer(null) ?: false))
1039
- },
1040
- onError = { error ->
1041
- callbackContext.error(error.message ?: "Unable to fetch plan")
1139
+ launch {
1140
+ try {
1141
+ val plan = Purchasely.plan(planId ?: "")
1142
+ val eligible = plan?.isEligibleToOffer(null) ?: false
1143
+ callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, eligible))
1144
+ } catch (e: Exception) {
1145
+ callbackContext.error(e.message ?: "Unable to fetch plan")
1042
1146
  }
1043
- )
1147
+ }
1044
1148
  }
1045
1149
 
1046
1150
  private fun signPromotionalOffer(storeProductId: String?, storeOfferId: String?, callbackContext: CallbackContext) {
@@ -1074,103 +1178,88 @@ class PurchaselyPlugin : CordovaPlugin() {
1074
1178
  Purchasely.debugMode = enabled
1075
1179
  }
1076
1180
 
1077
- class ProductActivity {
1078
- var presentationId: String? = null
1079
- var placementId: String? = null
1080
- var productId: String? = null
1081
- var planId: String? = null
1082
- var contentId: String? = null
1083
- var presentation: PLYPresentation? = null
1084
- var isFullScreen = false
1085
- var loadingBackgroundColor: String? = null
1086
- var activity: WeakReference<PLYProductActivity>? = null
1087
- fun relaunch(cordova: CordovaInterface): Boolean {
1088
- var backgroundActivity: PLYProductActivity? = null
1089
- if (activity != null) {
1090
- backgroundActivity = activity?.get()
1091
- }
1092
- return if (backgroundActivity != null && !backgroundActivity.isFinishing
1093
- && !backgroundActivity.isDestroyed
1094
- ) {
1095
- val intent = Intent(cordova.activity, PLYProductActivity::class.java)
1096
- intent.putExtra("presentation", presentation)
1097
- intent.putExtra("presentationId", presentationId)
1098
- intent.putExtra("placementId", placementId)
1099
- intent.putExtra("productId", productId)
1100
- intent.putExtra("planId", planId)
1101
- intent.putExtra("contentId", contentId)
1102
- intent.putExtra("isFullScreen", isFullScreen)
1103
- intent.putExtra("background_color", loadingBackgroundColor)
1104
- intent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
1105
- cordova.activity.startActivity(intent)
1106
- true
1107
- } else {
1108
- val intent = PLYProductActivity.newIntent(cordova.activity)
1109
- intent.putExtra("presentation", presentation)
1110
- intent.putExtra("presentationId", presentationId)
1111
- intent.putExtra("placementId", placementId)
1112
- intent.putExtra("productId", productId)
1113
- intent.putExtra("planId", planId)
1114
- intent.putExtra("contentId", contentId)
1115
- intent.putExtra("isFullScreen", isFullScreen)
1116
- intent.putExtra("background_color", loadingBackgroundColor)
1117
- cordova.activity.startActivity(intent)
1118
- false
1119
- }
1120
- }
1121
- }
1122
-
1123
1181
  companion object {
1124
1182
  var defaultCallback: CallbackContext? = null
1125
1183
  var purchaseCallback: CallbackContext? = null
1126
1184
  var eventsCallback: CallbackContext? = null
1127
1185
  var attributesCallback: CallbackContext? = null
1128
- var productActivity: ProductActivity? = null
1129
-
1130
- var interceptorActivity: WeakReference<Activity>? = null
1131
1186
 
1132
- val presentationsLoaded = mutableListOf<PLYPresentation>()
1133
-
1134
- private const val runningModePaywallObserver = 2
1135
- private const val runningModeFull = 3
1136
-
1137
- fun sendPurchaseResult(result: PLYProductViewResult, plan: PLYPlan?) {
1138
- var productViewResult = 0
1139
- if (result == PLYProductViewResult.PURCHASED) {
1140
- productViewResult = PLYProductViewResult.PURCHASED.ordinal
1141
- } else if (result == PLYProductViewResult.CANCELLED) {
1142
- productViewResult = PLYProductViewResult.CANCELLED.ordinal
1143
- } else if (result == PLYProductViewResult.RESTORED) {
1144
- productViewResult = PLYProductViewResult.RESTORED.ordinal
1145
- }
1146
- val map = HashMap<String?, Any?>()
1147
- map["result"] = productViewResult
1148
- map["plan"] = transformPlanToMap(plan)
1149
- if (purchaseCallback != null) {
1150
- purchaseCallback?.success(JSONObject(map))
1187
+ // Resolves a present*/presentPresentation call at dismissal. One-shot for the
1188
+ // purchase callback; falls back to the (repeatable) default dismiss handler.
1189
+ fun sendPurchaseResult(outcome: PLYPresentationOutcome) {
1190
+ val json = JSONObject(outcomeToMap(outcome))
1191
+ val oneShot = purchaseCallback
1192
+ if (oneShot != null) {
1193
+ oneShot.success(json)
1151
1194
  purchaseCallback = null
1152
- } else if (defaultCallback != null) {
1153
- val pluginResult = PluginResult(PluginResult.Status.OK, JSONObject(map))
1195
+ return
1196
+ }
1197
+ defaultCallback?.let {
1198
+ val pluginResult = PluginResult(PluginResult.Status.OK, json)
1154
1199
  pluginResult.keepCallback = true
1155
- defaultCallback?.sendPluginResult(pluginResult)
1200
+ it.sendPluginResult(pluginResult)
1156
1201
  }
1157
1202
  }
1158
1203
 
1204
+ // Serializes a v6 PLYPresentationOutcome to the wire contract. `result` is kept as an
1205
+ // int (PurchaseResult 0/1/2) for back-compat with the pre-6.0 JS layer.
1206
+ fun outcomeToMap(outcome: PLYPresentationOutcome): Map<String?, Any?> {
1207
+ val result = when (outcome.purchaseResult?.name?.lowercase(Locale.US)) {
1208
+ "purchased" -> 0
1209
+ "cancelled" -> 1
1210
+ "restored" -> 2
1211
+ else -> 1 // no purchase (plain dismiss) → cancelled
1212
+ }
1213
+ val map = HashMap<String?, Any?>()
1214
+ map["result"] = result
1215
+ // v6: also expose the string purchaseResult ('purchased'|'cancelled'|'restored'|null)
1216
+ // alongside the legacy int `result`, matching the Flutter outcome contract.
1217
+ map["purchaseResult"] = outcome.purchaseResult?.name?.lowercase(Locale.US)
1218
+ map["plan"] = transformPlanToMap(outcome.plan)
1219
+ map["closeReason"] = outcome.closeReason?.value
1220
+ map["error"] = outcome.error?.message
1221
+ map["presentation"] = outcome.presentation?.let { presentationToMap(it) }
1222
+ return map
1223
+ }
1224
+
1225
+ fun presentationToMap(p: PLYPresentationBase.Loaded): Map<String, Any?> {
1226
+ return mapOf(
1227
+ "screenId" to p.screenId,
1228
+ "placementId" to p.placementId,
1229
+ "contentId" to p.contentId,
1230
+ "audienceId" to p.audienceId,
1231
+ "abTestId" to p.abTestId,
1232
+ "abTestVariantId" to p.abTestVariantId,
1233
+ "campaignId" to p.campaignId,
1234
+ "flowId" to p.flowId,
1235
+ "language" to p.language,
1236
+ "type" to p.type.ordinal,
1237
+ "height" to p.height,
1238
+ "plans" to p.plans.map { presentationPlanToMap(it) }
1239
+ )
1240
+ }
1241
+
1242
+ private fun presentationPlanToMap(plan: PLYPresentationPlan): Map<String, Any?> {
1243
+ return mapOf(
1244
+ "planVendorId" to plan.planVendorId,
1245
+ "storeProductId" to plan.storeProductId,
1246
+ "basePlanId" to plan.basePlanId,
1247
+ "offerId" to plan.storeOfferId
1248
+ )
1249
+ }
1250
+
1159
1251
  private fun transformPlanToMap(plan: PLYPlan?): Map<String?, Any?> {
1160
1252
  if (plan == null) return HashMap()
1161
1253
  val map = HashMap(plan.toMap())
1162
- if (plan.type == DistributionType.CONSUMABLE) {
1163
- map["type"] = DistributionType.CONSUMABLE.ordinal
1164
- } else if (plan.type == DistributionType.CONSUMABLE) {
1165
- map["type"] = DistributionType.NON_CONSUMABLE.ordinal
1166
- } else if (plan.type == DistributionType.NON_CONSUMABLE) {
1167
- map["type"] = DistributionType.RENEWING_SUBSCRIPTION.ordinal
1168
- } else if (plan.type == DistributionType.NON_RENEWING_SUBSCRIPTION) {
1169
- map["type"] = DistributionType.NON_RENEWING_SUBSCRIPTION.ordinal
1170
- } else if (plan.type == DistributionType.UNKNOWN) {
1171
- map["type"] = DistributionType.UNKNOWN.ordinal
1254
+ map["type"] = when (plan.type) {
1255
+ DistributionType.RENEWING_SUBSCRIPTION -> DistributionType.RENEWING_SUBSCRIPTION.ordinal
1256
+ DistributionType.NON_RENEWING_SUBSCRIPTION -> DistributionType.NON_RENEWING_SUBSCRIPTION.ordinal
1257
+ DistributionType.CONSUMABLE -> DistributionType.CONSUMABLE.ordinal
1258
+ DistributionType.NON_CONSUMABLE -> DistributionType.NON_CONSUMABLE.ordinal
1259
+ DistributionType.UNKNOWN -> DistributionType.UNKNOWN.ordinal
1260
+ else -> null
1172
1261
  }
1173
- map["isEligibleForIntroOffer"] = plan.isEligibleToIntroOffer(null)
1262
+ map["isEligibleForIntroOffer"] = plan.isEligibleToOffer(null)
1174
1263
  return map
1175
1264
  }
1176
1265
  }
@@ -1223,4 +1312,4 @@ class PurchaselyPlugin : CordovaPlugin() {
1223
1312
  BATCH_CUSTOM_USER_ID: 20,
1224
1313
  */
1225
1314
  }
1226
- }
1315
+ }