@purchasely/cordova-plugin-purchasely 6.0.0-rc.3 → 6.0.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.
@@ -72,9 +72,18 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
72
72
 
73
73
  // Presentation currently displayed (used for back()/close()).
74
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.
75
+ // Presentations preloaded by fetchPresentation, keyed by a synthetic handle sent back to
76
+ // JS under the private `fetchId` key (NOT `id` -- `screenId` is the authoritative public
77
+ // presentation identifier; `fetchId` is an internal re-display lookup key, never documented
78
+ // as public API) so presentPresentation can re-display them.
77
79
  private val loadedPresentations = ConcurrentHashMap<String, PLYPresentationBase.Loaded>()
80
+ // v6: the native SDK fixes onPresented/onCloseRequested at builder.build() time (before
81
+ // preload()) -- there is no display invocation, hence no CallbackContext, yet at fetch
82
+ // time. Seeded closures in fetchPresentation() capture the handle and resolve the live
83
+ // CallbackContext from here lazily, at fire time; presentPresentation() registers the
84
+ // invocation that's actually re-displaying under the same handle just before calling
85
+ // display(). Keyed identically to loadedPresentations.
86
+ private val lifecycleCallbacks = ConcurrentHashMap<String, CallbackContext>()
78
87
 
79
88
  override fun onDestroy() {
80
89
  job.cancel()
@@ -90,14 +99,14 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
90
99
  when (action) {
91
100
  "start" -> start(args.getJSONObject(0), callbackContext)
92
101
 
93
- "close" -> close()
94
102
  "addEventsListener" -> addEventsListener(callbackContext)
95
103
  "addUserAttributeListener" -> addUserAttributesListener(callbackContext)
96
104
  "removeUserAttributeListener" -> removeUserAttributesListener()
97
105
  "removeEventsListener" -> removeEventsListener()
98
106
  "getAnonymousUserId" -> getAnonymousUserId(callbackContext)
107
+ "isAnonymous" -> isAnonymous(callbackContext)
99
108
  "userLogin" -> userLogin(getStringFromJson(args.getString(0)), callbackContext)
100
- "userLogout" -> userLogout()
109
+ "userLogout" -> userLogout(args.optBoolean(0, true))
101
110
  "setLanguage" -> setLanguage(getStringFromJson(args.getString(0)))
102
111
  "setLogLevel" -> setLogLevel(args.getInt(0))
103
112
  "setThemeMode" -> setThemeMode(args.getInt(0))
@@ -145,8 +154,8 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
145
154
  )
146
155
  "restoreAllProducts" -> restoreAllProducts(callbackContext)
147
156
  "silentRestoreAllProducts" -> restoreAllProducts(callbackContext)
148
- "userSubscriptions" -> userSubscriptions(callbackContext)
149
- "userSubscriptionsHistory" -> userSubscriptionsHistory(callbackContext)
157
+ "userSubscriptions" -> userSubscriptions(args.optBoolean(0, false), callbackContext)
158
+ "userSubscriptionsHistory" -> userSubscriptionsHistory(args.optBoolean(0, false), callbackContext)
150
159
  "handleDeeplink" -> handleDeeplink(
151
160
  getStringFromJson(args.getString(0)),
152
161
  callbackContext
@@ -173,6 +182,7 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
173
182
  "unregisterActionInterceptor" -> unregisterActionInterceptor(getStringFromJson(args.getString(0)))
174
183
  "completeActionInterceptor" -> completeActionInterceptor(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)))
175
184
  "closePresentation" -> closePresentation(callbackContext)
185
+ "closeAllScreens" -> closePresentation(callbackContext)
176
186
  "backPresentation" -> backPresentation(callbackContext)
177
187
  "userDidConsumeSubscriptionContent" -> userDidConsumeSubscriptionContent()
178
188
  "setUserAttributeWithString" -> setUserAttributeWithString(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), getStringFromJson(args.optString(2)))
@@ -185,13 +195,27 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
185
195
  "setUserAttributeWithDoubleArray" -> setUserAttributeWithDoubleArray(getStringFromJson(args.getString(0)), args.getJSONArray(1), getStringFromJson(args.optString(2)))
186
196
  "setUserAttributeWithBooleanArray" -> setUserAttributeWithBooleanArray(getStringFromJson(args.getString(0)), args.getJSONArray(1), getStringFromJson(args.optString(2)))
187
197
  "userAttribute" -> userAttribute(getStringFromJson(args.getString(0)), callbackContext)
198
+ "userAttributes" -> userAttributes(callbackContext)
199
+ "incrementUserAttribute" -> incrementUserAttribute(getStringFromJson(args.getString(0)), args.optInt(1, 1))
200
+ "decrementUserAttribute" -> decrementUserAttribute(getStringFromJson(args.getString(0)), args.optInt(1, 1))
188
201
  "clearUserAttribute" -> clearUserAttribute(getStringFromJson(args.getString(0)))
189
202
  "clearUserAttributes" -> clearUserAttributes()
190
203
  "clearBuiltInAttributes" -> clearBuiltInAttributes()
204
+ "getBuiltInAttributes" -> getBuiltInAttributes(callbackContext)
205
+ "getBuiltInAttribute" -> getBuiltInAttribute(getStringFromJson(args.getString(0)), callbackContext)
191
206
  "isEligibleForIntroOffer" -> isEligibleForIntroOffer(getStringFromJson(args.getString(0)), callbackContext)
192
207
  "signPromotionalOffer" -> signPromotionalOffer(getStringFromJson(args.getString(0)), getStringFromJson(args.getString(1)), callbackContext)
193
208
  "revokeDataProcessingConsent" -> revokeDataProcessingConsent(args.getJSONArray(0))
194
209
  "setDebugMode" -> setDebugMode(args.getBoolean(0))
210
+ "setDynamicOffering" -> setDynamicOffering(
211
+ getStringFromJson(args.getString(0)),
212
+ getStringFromJson(args.getString(1)),
213
+ getStringFromJson(args.optString(2)),
214
+ callbackContext
215
+ )
216
+ "getDynamicOfferings" -> getDynamicOfferings(callbackContext)
217
+ "removeDynamicOffering" -> removeDynamicOffering(getStringFromJson(args.getString(0)))
218
+ "clearDynamicOfferings" -> clearDynamicOfferings()
195
219
  else -> return false
196
220
  }
197
221
  } catch (e: JSONException) {
@@ -310,17 +334,6 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
310
334
  return result
311
335
  }
312
336
 
313
- private fun close() {
314
- defaultCallback = null
315
- purchaseCallback = null
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.
322
- }
323
-
324
337
  private fun addUserAttributesListener(callbackContext: CallbackContext) {
325
338
  attributesCallback = callbackContext
326
339
  Purchasely.userAttributeListener = object: UserAttributeListener {
@@ -383,6 +396,11 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
383
396
  callbackContext.success(Purchasely.anonymousUserId)
384
397
  }
385
398
 
399
+ // REC-12 / PAR-04
400
+ private fun isAnonymous(callbackContext: CallbackContext) {
401
+ callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, Purchasely.isAnonymous()))
402
+ }
403
+
386
404
  private fun userLogin(userId: String?, callbackContext: CallbackContext) {
387
405
  if(userId == null) {
388
406
  callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, false))
@@ -394,12 +412,16 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
394
412
  }
395
413
  }
396
414
 
397
- private fun userLogout() {
398
- Purchasely.userLogout(true)
415
+ // PAR-30: clearUserAttributes defaults to true (matches the JS-side default).
416
+ private fun userLogout(clearUserAttributes: Boolean) {
417
+ Purchasely.userLogout(clearUserAttributes)
399
418
  }
400
419
 
401
420
  private fun setLogLevel(logLevel: Int) {
402
- Purchasely.logLevel = LogLevel.values()[logLevel]
421
+ // CDV-W-13: values()[logLevel] throws ArrayIndexOutOfBoundsException for an
422
+ // out-of-range value, uncaught by execute()'s JSONException-only catch. Reuse the
423
+ // already-bounded helper start() relies on.
424
+ Purchasely.logLevel = logLevelFrom(logLevel)
403
425
  }
404
426
 
405
427
  private fun setLanguage(language: String?) {
@@ -447,6 +469,7 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
447
469
  CordovaPLYAttribute.moengageUniqueId.ordinal -> Attribute.MOENGAGE_UNIQUE_ID
448
470
  CordovaPLYAttribute.oneSignalExternalId.ordinal -> Attribute.ONESIGNAL_EXTERNAL_ID
449
471
  CordovaPLYAttribute.batchCustomUserId.ordinal -> Attribute.BATCH_CUSTOM_USER_ID
472
+ CordovaPLYAttribute.oneSignalUserId.ordinal -> Attribute.ONESIGNAL_USER_ID
450
473
  else -> null
451
474
  }
452
475
 
@@ -503,7 +526,6 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
503
526
  transition: JSONObject?,
504
527
  callbackContext: CallbackContext
505
528
  ) {
506
- purchaseCallback = callbackContext
507
529
  displayPresentation(
508
530
  screenId = presentationVendorId,
509
531
  placementId = null,
@@ -519,7 +541,6 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
519
541
  transition: JSONObject?,
520
542
  callbackContext: CallbackContext
521
543
  ) {
522
- purchaseCallback = callbackContext
523
544
  displayPresentation(
524
545
  screenId = null,
525
546
  placementId = placementVendorId,
@@ -535,7 +556,6 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
535
556
  transition: JSONObject?,
536
557
  callbackContext: CallbackContext
537
558
  ) {
538
- purchaseCallback = callbackContext
539
559
  displayPresentation(
540
560
  screenId = null,
541
561
  placementId = null,
@@ -563,7 +583,9 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
563
583
  contentId(contentId)
564
584
  // v6: stream the presentation lifecycle to the JS callback via keep-alive
565
585
  // envelopes; the dismiss outcome is delivered as the final (non-kept)
566
- // result by sendPurchaseResult.
586
+ // result, resolved directly against this invocation's own callbackContext
587
+ // (CDV-W-07: no shared/global callback slot, so overlapping present* calls
588
+ // can never cross-wire, matching iOS's per-invocation closure capture).
567
589
  onPresented { presentation, error ->
568
590
  val env = mutableMapOf<String, Any?>("event" to "presented")
569
591
  presentation?.let { env["presentation"] = presentationToMap(it) }
@@ -581,10 +603,9 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
581
603
  val loaded = builder.build().preload()
582
604
  displayedPresentation = loaded
583
605
  loaded.display(cordova.activity, transitionFromMap(transition)) { outcome ->
584
- sendPurchaseResult(outcome)
606
+ callbackContext.success(JSONObject(outcomeToMap(outcome)))
585
607
  }
586
608
  } catch (t: Throwable) {
587
- if (purchaseCallback === callbackContext) purchaseCallback = null
588
609
  callbackContext.error(t.message ?: "Unable to present presentation")
589
610
  }
590
611
  }
@@ -597,6 +618,13 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
597
618
  callbackContext: CallbackContext) {
598
619
  launch {
599
620
  try {
621
+ // Synthetic handle so presentPresentation can re-display this preloaded
622
+ // presentation. Private re-display key: `fetchId`, NOT `id` -- `screenId`
623
+ // (already set by presentationToMap) is the sole authoritative public
624
+ // identifier. Generated up front so the onPresented/onCloseRequested closures
625
+ // below can capture it -- the native SDK fixes those on the builder, before
626
+ // preload(), so they must be seeded here rather than at re-display time.
627
+ val handle = "ply_fetch_${System.nanoTime()}"
600
628
  val builder = PLYPresentationBase.builder().apply {
601
629
  when {
602
630
  placementId != null -> this.placementId(placementId)
@@ -604,13 +632,32 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
604
632
  // else: neither id → the default (audience-targeted) presentation
605
633
  }
606
634
  contentId(contentId)
635
+ // v6: these fire on whichever invocation ends up re-displaying this
636
+ // presentation (via presentPresentation), not this fetch call -- resolve the
637
+ // live CallbackContext from lifecycleCallbacks lazily, at fire time, instead
638
+ // of capturing this fetch's own (long-finished) callbackContext. If nothing
639
+ // is registered yet (fired before any display), it's a silent no-op: there is
640
+ // no invocation to observe it.
641
+ onPresented { presentation, error ->
642
+ val cb = lifecycleCallbacks[handle] ?: return@onPresented
643
+ val env = mutableMapOf<String, Any?>("event" to "presented")
644
+ presentation?.let { env["presentation"] = presentationToMap(it) }
645
+ error?.let { env["error"] = it.message }
646
+ val r = PluginResult(PluginResult.Status.OK, JSONObject(env))
647
+ r.keepCallback = true
648
+ cb.sendPluginResult(r)
649
+ }
650
+ onCloseRequested {
651
+ val cb = lifecycleCallbacks[handle] ?: return@onCloseRequested
652
+ val r = PluginResult(PluginResult.Status.OK, JSONObject(mapOf("event" to "closeRequested")))
653
+ r.keepCallback = true
654
+ cb.sendPluginResult(r)
655
+ }
607
656
  }
608
657
  val loaded = builder.build().preload()
609
- // Synthetic handle so presentPresentation can re-display this preloaded presentation.
610
- val handle = "ply_fetch_${System.nanoTime()}"
611
658
  loadedPresentations[handle] = loaded
612
659
  val map = presentationToMap(loaded).toMutableMap()
613
- map["id"] = handle
660
+ map["fetchId"] = handle
614
661
  callbackContext.success(JSONObject(map))
615
662
  } catch (t: Throwable) {
616
663
  callbackContext.error(t.message ?: "Unable to fetch presentation")
@@ -627,23 +674,51 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
627
674
  return
628
675
  }
629
676
 
630
- val handle = if (presentationMap.has("id")) presentationMap.optString("id") else null
677
+ // Private re-display key: `fetchId` (see fetchPresentation above). `id` is tolerated
678
+ // as an internal belt-and-braces fallback only -- never a documented public key.
679
+ val handle = when {
680
+ presentationMap.has("fetchId") -> presentationMap.optString("fetchId")
681
+ presentationMap.has("id") -> presentationMap.optString("id")
682
+ else -> null
683
+ }
631
684
  val loaded = handle?.let { loadedPresentations[it] }
632
- if (loaded == null) {
685
+ if (loaded == null || handle == null) {
633
686
  callbackContext.error("presentation cannot be found")
634
687
  return
635
688
  }
636
689
 
637
- purchaseCallback = callbackContext
638
690
  displayedPresentation = loaded
639
691
 
640
692
  // 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)
693
+ // presentation in v6 (colors are builder options set before preload).
694
+ //
695
+ // v6: onPresented/onCloseRequested were seeded on the builder back in
696
+ // fetchPresentation() (fixed at build time, before preload -- the native SDK doesn't
697
+ // allow setting them later) and resolve their live CallbackContext from
698
+ // lifecycleCallbacks lazily, at fire time. Register THIS invocation's callbackContext
699
+ // under `handle` before triggering display() so those events reach it, matching the
700
+ // direct present* path's envelopes exactly (same event/presentation/error keys,
701
+ // keepCallback=true). Confirmed against the native SDK sources: display(callback) only
702
+ // replaces onDismissed, never onPresented/onCloseRequested (PLYPresentationBase.
703
+ // dispatchDisplay).
704
+ //
705
+ // CDV-W-07: resolve directly against this invocation's own callbackContext (no shared
706
+ // slot). Remove the registration as soon as this invocation's dismiss outcome is
707
+ // delivered, or immediately on any early-return error below -- otherwise a failed
708
+ // display leaves a stale entry routing future lifecycle events to a callback that will
709
+ // never fire again.
710
+ lifecycleCallbacks[handle] = callbackContext
711
+
712
+ val activity = cordova.activity
713
+ if (activity == null) {
714
+ lifecycleCallbacks.remove(handle)
715
+ callbackContext.error("No activity available to display presentation")
716
+ return
717
+ }
718
+ activity.runOnUiThread {
719
+ loaded.display(activity, transitionFromMap(transition)) { outcome ->
720
+ lifecycleCallbacks.remove(handle)
721
+ callbackContext.success(JSONObject(outcomeToMap(outcome)))
647
722
  }
648
723
  }
649
724
  }
@@ -710,10 +785,11 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
710
785
  )
711
786
  }
712
787
 
713
- private fun userSubscriptions(callbackContext: CallbackContext) {
788
+ // PAR-29: invalidateCache defaults to false (exposed from JS; was hardcoded true).
789
+ private fun userSubscriptions(invalidateCache: Boolean, callbackContext: CallbackContext) {
714
790
  launch {
715
791
  try {
716
- val list = Purchasely.userSubscriptions(true)
792
+ val list = Purchasely.userSubscriptions(invalidateCache)
717
793
  callbackContext.success(transformSubscriptionsToJson(list))
718
794
  } catch (e: Exception) {
719
795
  callbackContext.error(e.message)
@@ -721,10 +797,10 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
721
797
  }
722
798
  }
723
799
 
724
- private fun userSubscriptionsHistory(callbackContext: CallbackContext) {
800
+ private fun userSubscriptionsHistory(invalidateCache: Boolean, callbackContext: CallbackContext) {
725
801
  launch {
726
802
  try {
727
- val list = Purchasely.userSubscriptionsHistory(true)
803
+ val list = Purchasely.userSubscriptionsHistory(invalidateCache)
728
804
  callbackContext.success(transformSubscriptionsToJson(list))
729
805
  } catch (e: Exception) {
730
806
  callbackContext.error(e.message)
@@ -732,17 +808,42 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
732
808
  }
733
809
  }
734
810
 
811
+ // Hardening for the upcoming rc.4 native release: PLYPlan.toMap()'s raw "type" entry is
812
+ // moving from an ordinal (Int) to the DistributionType name (String). transformPlanToMap
813
+ // already overwrites "type" explicitly wherever it's used, but allProducts/
814
+ // productWithIdentifier/the subscription's nested "product" field pass product.toMap()
815
+ // straight through -- normalize those raw plan entries so the JS PlanType contract
816
+ // (an ordinal) stays stable across both native formats. Both formats resolve to the same
817
+ // ordinal since DistributionType's declared order already matches Purchasely.PlanType.
818
+ private fun normalizePlanTypeOrdinal(raw: Any?): Int? = when (raw) {
819
+ is Number -> raw.toInt()
820
+ is String -> runCatching { DistributionType.valueOf(raw).ordinal }.getOrNull()
821
+ else -> null
822
+ }
823
+
824
+ private fun normalizeProductPlans(map: Map<String, Any?>): Map<String, Any?> {
825
+ val plans = map["plans"] as? List<*> ?: return map
826
+ val normalized = plans.map { plan ->
827
+ val planMap = plan as? Map<*, *> ?: return@map plan
828
+ HashMap(planMap).apply { this["type"] = normalizePlanTypeOrdinal(this["type"]) }
829
+ }
830
+ return HashMap(map).apply { this["plans"] = normalized }
831
+ }
832
+
735
833
  private fun transformSubscriptionsToJson(list: List<PLYSubscriptionData>): JSONArray {
736
834
  val result = JSONArray()
737
835
  for (data in list) {
738
836
  val map = HashMap(data.data.toMap())
739
837
  map["plan"] = transformPlanToMap(data.plan)
740
- map["product"] = data.product.toMap()
838
+ map["product"] = normalizeProductPlans(data.product.toMap())
741
839
  map["subscriptionSource"] = when (data.data.storeType) {
742
840
  StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal
743
841
  StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal
744
842
  StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal
745
843
  StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal
844
+ // CDV-W-15: NONE/WEB_CHECKOUT_STRIPE have no JS SubscriptionSource case of
845
+ // their own; both map to `none` (4), matching iOS's PLYSubscriptionSource.None.
846
+ StoreType.NONE, StoreType.WEB_CHECKOUT_STRIPE -> 4
746
847
  else -> null
747
848
  }
748
849
  result.put(JSONObject(map))
@@ -770,7 +871,7 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
770
871
  val list = Purchasely.allProducts()
771
872
  val result = JSONArray()
772
873
  for (product in list) {
773
- result.put(JSONObject(product.toMap()))
874
+ result.put(JSONObject(normalizeProductPlans(product.toMap())))
774
875
  }
775
876
  callbackContext.success(result)
776
877
  } catch (e: Exception) {
@@ -788,7 +889,7 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
788
889
  try {
789
890
  val product: PLYProduct? = Purchasely.product(vendorId)
790
891
  if (product != null) {
791
- callbackContext.success(JSONObject(product.toMap()))
892
+ callbackContext.success(JSONObject(normalizeProductPlans(product.toMap())))
792
893
  } else {
793
894
  callbackContext.error("No product found with $vendorId")
794
895
  }
@@ -1086,11 +1187,36 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1086
1187
  is JSONArray -> callbackContext.success(result)
1087
1188
  is String -> callbackContext.success(result)
1088
1189
  is Int -> callbackContext.success(result)
1089
- is Boolean -> callbackContext.success(if (result) 1 else 0)
1190
+ // CDV-W-06: getUserAttributeValueForCordova converts a Float attribute to a Double
1191
+ // (to preserve precision); this branch was missing, so reading back any
1192
+ // setUserAttributeWithDouble value always fell through to the error case below.
1193
+ is Double -> callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, result.toFloat()))
1194
+ // CDV-W-09: send a real JSON boolean (matches iOS) instead of 0/1.
1195
+ is Boolean -> callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, result))
1090
1196
  else -> callbackContext.error("No user attribute found with $key")
1091
1197
  }
1092
1198
  }
1093
1199
 
1200
+ // REC-12 / PAR-03: bulk read, same per-value conversion as the single-key read above.
1201
+ private fun userAttributes(callbackContext: CallbackContext) {
1202
+ val result = JSONObject()
1203
+ Purchasely.userAttributes().forEach { (key, value) ->
1204
+ result.put(key, getUserAttributeValueForCordova(value))
1205
+ }
1206
+ callbackContext.success(result)
1207
+ }
1208
+
1209
+ // REC-12 / PAR-02
1210
+ private fun incrementUserAttribute(key: String?, value: Int) {
1211
+ if (key == null) return
1212
+ Purchasely.incrementUserAttribute(key, value)
1213
+ }
1214
+
1215
+ private fun decrementUserAttribute(key: String?, value: Int) {
1216
+ if (key == null) return
1217
+ Purchasely.decrementUserAttribute(key, value)
1218
+ }
1219
+
1094
1220
  private fun getUserAttributeValueForCordova(value: Any?): Any? {
1095
1221
  return when (value) {
1096
1222
  is Date -> {
@@ -1135,6 +1261,33 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1135
1261
  Purchasely.clearBuiltInAttributes()
1136
1262
  }
1137
1263
 
1264
+ // PAR-07
1265
+ private fun getBuiltInAttributes(callbackContext: CallbackContext) {
1266
+ val result = JSONObject()
1267
+ Purchasely.getBuiltInAttributes().forEach { (key, value) ->
1268
+ result.put(key, getUserAttributeValueForCordova(value))
1269
+ }
1270
+ callbackContext.success(result)
1271
+ }
1272
+
1273
+ private fun getBuiltInAttribute(key: String?, callbackContext: CallbackContext) {
1274
+ if (key == null) {
1275
+ callbackContext.success()
1276
+ return
1277
+ }
1278
+ val value = getUserAttributeValueForCordova(Purchasely.getBuiltInAttribute(key))
1279
+ when (value) {
1280
+ is JSONArray -> callbackContext.success(value)
1281
+ is String -> callbackContext.success(value)
1282
+ is Int -> callbackContext.success(value)
1283
+ is Double -> callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, value.toFloat()))
1284
+ is Boolean -> callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, value))
1285
+ // No attribute for this key: resolve success with no value (undefined in JS),
1286
+ // matching iOS's nullable id? return.
1287
+ else -> callbackContext.success()
1288
+ }
1289
+ }
1290
+
1138
1291
  private fun isEligibleForIntroOffer(planId: String?, callbackContext: CallbackContext) {
1139
1292
  launch {
1140
1293
  try {
@@ -1147,8 +1300,47 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1147
1300
  }
1148
1301
  }
1149
1302
 
1303
+ // REC-04: iOS-only feature (StoreKit promotional offer signing). No error on Android by
1304
+ // design (Kevin's call) -- signPromotionalOffer resolves as a no-op success so shared JS
1305
+ // calling it unconditionally on both platforms doesn't have to special-case Android.
1150
1306
  private fun signPromotionalOffer(storeProductId: String?, storeOfferId: String?, callbackContext: CallbackContext) {
1151
- callbackContext.error("No signing required on Android")
1307
+ callbackContext.success()
1308
+ }
1309
+
1310
+ // PAR-05: Dynamic Offerings. Payload keys (reference/planVendorId/offerVendorId) match
1311
+ // the Cordova JS↔native contract (not the native SDK's own reference/planId/offerId
1312
+ // property names on PLYDynamicOffering).
1313
+ private fun setDynamicOffering(reference: String?, planVendorId: String?, offerVendorId: String?, callbackContext: CallbackContext) {
1314
+ if (reference == null || planVendorId == null) {
1315
+ callbackContext.error("reference and planVendorId are required")
1316
+ return
1317
+ }
1318
+ Purchasely.setDynamicOffering(reference, planVendorId, offerVendorId) { success ->
1319
+ callbackContext.sendPluginResult(PluginResult(PluginResult.Status.OK, success))
1320
+ }
1321
+ }
1322
+
1323
+ private fun getDynamicOfferings(callbackContext: CallbackContext) {
1324
+ Purchasely.getDynamicOfferings { offerings ->
1325
+ val result = JSONArray()
1326
+ offerings.forEach { offering ->
1327
+ result.put(JSONObject(mapOf(
1328
+ "reference" to offering.reference,
1329
+ "planVendorId" to offering.planId,
1330
+ "offerVendorId" to (offering.offerId ?: JSONObject.NULL)
1331
+ )))
1332
+ }
1333
+ callbackContext.success(result)
1334
+ }
1335
+ }
1336
+
1337
+ private fun removeDynamicOffering(reference: String?) {
1338
+ if (reference == null) return
1339
+ Purchasely.removeDynamicOffering(reference)
1340
+ }
1341
+
1342
+ private fun clearDynamicOfferings() {
1343
+ Purchasely.clearDynamicOfferings()
1152
1344
  }
1153
1345
 
1154
1346
  private fun revokeDataProcessingConsent(purposes: JSONArray?) {
@@ -1180,27 +1372,9 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1180
1372
 
1181
1373
  companion object {
1182
1374
  var defaultCallback: CallbackContext? = null
1183
- var purchaseCallback: CallbackContext? = null
1184
1375
  var eventsCallback: CallbackContext? = null
1185
1376
  var attributesCallback: CallbackContext? = null
1186
1377
 
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)
1194
- purchaseCallback = null
1195
- return
1196
- }
1197
- defaultCallback?.let {
1198
- val pluginResult = PluginResult(PluginResult.Status.OK, json)
1199
- pluginResult.keepCallback = true
1200
- it.sendPluginResult(pluginResult)
1201
- }
1202
- }
1203
-
1204
1378
  // Serializes a v6 PLYPresentationOutcome to the wire contract. `result` is kept as an
1205
1379
  // int (PurchaseResult 0/1/2) for back-compat with the pre-6.0 JS layer.
1206
1380
  fun outcomeToMap(outcome: PLYPresentationOutcome): Map<String?, Any?> {
@@ -1215,7 +1389,11 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1215
1389
  // v6: also expose the string purchaseResult ('purchased'|'cancelled'|'restored'|null)
1216
1390
  // alongside the legacy int `result`, matching the Flutter outcome contract.
1217
1391
  map["purchaseResult"] = outcome.purchaseResult?.name?.lowercase(Locale.US)
1218
- map["plan"] = transformPlanToMap(outcome.plan)
1392
+ // CDV-W-05: transformPlanToMap(null) returns {} (not null), so setting the key
1393
+ // unconditionally made a naive `if (outcome.plan)` truthy check on Android but
1394
+ // falsy (key omitted) on iOS for the same no-purchase dismissal. Omit the key
1395
+ // entirely when there is no plan, matching iOS.
1396
+ outcome.plan?.let { map["plan"] = transformPlanToMap(it) }
1219
1397
  map["closeReason"] = outcome.closeReason?.value
1220
1398
  map["error"] = outcome.error?.message
1221
1399
  map["presentation"] = outcome.presentation?.let { presentationToMap(it) }
@@ -1264,7 +1442,8 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1264
1442
  }
1265
1443
  }
1266
1444
 
1267
- // WARNING: This enum must be strictly identical to the one in the JS side (Purchasely.js).
1445
+ // WARNING: This enum must be strictly identical (same declaration order) to the one in
1446
+ // the JS side (Purchasely.js) and iOS's CordovaPLYAttribute typedef.
1268
1447
  enum class CordovaPLYAttribute {
1269
1448
  firebase_app_instance_id,
1270
1449
  airship_channel_id,
@@ -1287,6 +1466,7 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1287
1466
  moengageUniqueId,
1288
1467
  oneSignalExternalId,
1289
1468
  batchCustomUserId,
1469
+ oneSignalUserId,
1290
1470
 
1291
1471
  /*
1292
1472
  FIREBASE_APP_INSTANCE_ID: 0,
@@ -13,7 +13,13 @@
13
13
 
14
14
  - (void)eventTriggered:(enum PLYEvent)event properties:(NSDictionary<NSString *, id> * _Nullable)properties {
15
15
  if (self.eventCommand) {
16
- NSDictionary<NSString *, id> *eventDict = @{@"name": [NSString fromPLYEvent:event], @"properties": properties};
16
+ // CDV-W-01: properties is _Nullable; inserting nil into an ObjC dictionary LITERAL
17
+ // throws NSInvalidArgumentException. Build it mutably and only set the key when non-nil.
18
+ NSMutableDictionary<NSString *, id> *eventDict = [NSMutableDictionary new];
19
+ [eventDict setObject:[NSString fromPLYEvent:event] forKey:@"name"];
20
+ if (properties != nil) {
21
+ [eventDict setObject:properties forKey:@"properties"];
22
+ }
17
23
  CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:eventDict];
18
24
 
19
25
  [pluginResult setKeepCallbackAsBool:YES];
@@ -38,6 +38,7 @@
38
38
  - (void)userLogout:(CDVInvokedUrlCommand*)command;
39
39
  - (void)setAttribute:(CDVInvokedUrlCommand*)command;
40
40
  - (void)getAnonymousUserId:(CDVInvokedUrlCommand*)command;
41
+ - (void)isAnonymous:(CDVInvokedUrlCommand*)command;
41
42
  - (void)allowDeeplink:(CDVInvokedUrlCommand*)command;
42
43
  - (void)allowCampaigns:(CDVInvokedUrlCommand*)command;
43
44
  - (void)handleDeeplink:(CDVInvokedUrlCommand*)command;
@@ -60,6 +61,7 @@
60
61
  - (void)unregisterActionInterceptor:(CDVInvokedUrlCommand*)command;
61
62
  - (void)completeActionInterceptor:(CDVInvokedUrlCommand*)command;
62
63
  - (void)closePresentation:(CDVInvokedUrlCommand*)command;
64
+ - (void)closeAllScreens:(CDVInvokedUrlCommand*)command;
63
65
  - (void)backPresentation:(CDVInvokedUrlCommand*)command;
64
66
  - (void)userDidConsumeSubscriptionContent:(CDVInvokedUrlCommand*)command;
65
67
  - (void)setUserAttributeWithStringArray:(CDVInvokedUrlCommand*)command;
@@ -72,14 +74,23 @@
72
74
  - (void)setUserAttributeWithDouble:(CDVInvokedUrlCommand*)command;
73
75
  - (void)setUserAttributeWithDate:(CDVInvokedUrlCommand*)command;
74
76
  - (void)userAttribute:(CDVInvokedUrlCommand*)command;
77
+ - (void)userAttributes:(CDVInvokedUrlCommand*)command;
78
+ - (void)incrementUserAttribute:(CDVInvokedUrlCommand*)command;
79
+ - (void)decrementUserAttribute:(CDVInvokedUrlCommand*)command;
75
80
  - (void)clearUserAttribute:(CDVInvokedUrlCommand*)command;
76
81
  - (void)clearUserAttributes:(CDVInvokedUrlCommand*)command;
77
82
  - (void)clearBuiltInAttributes:(CDVInvokedUrlCommand*)command;
83
+ - (void)getBuiltInAttributes:(CDVInvokedUrlCommand*)command;
84
+ - (void)getBuiltInAttribute:(CDVInvokedUrlCommand*)command;
78
85
  - (void)fetchPresentation:(CDVInvokedUrlCommand*)command;
79
86
  - (void)presentPresentation:(CDVInvokedUrlCommand*)command;
80
87
  - (void)signPromotionalOffer:(CDVInvokedUrlCommand*)command;
81
88
  - (void)isEligibleForIntroOffer:(CDVInvokedUrlCommand*)command;
82
89
  - (void)setThemeMode:(CDVInvokedUrlCommand*)command;
83
90
  - (void)addUserAttributeListener:(CDVInvokedUrlCommand*)command;
91
+ - (void)setDynamicOffering:(CDVInvokedUrlCommand*)command;
92
+ - (void)getDynamicOfferings:(CDVInvokedUrlCommand*)command;
93
+ - (void)removeDynamicOffering:(CDVInvokedUrlCommand*)command;
94
+ - (void)clearDynamicOfferings:(CDVInvokedUrlCommand*)command;
84
95
 
85
96
  @end