@purchasely/cordova-plugin-purchasely 6.0.1 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Package.swift CHANGED
@@ -56,7 +56,7 @@ let package = Package(
56
56
  // documents another. That makes the linked SDK depend on install time and lets
57
57
  // the two integration paths drift apart. Bump this with the podspec, in the
58
58
  // same commit, and VERSIONS.md with it.
59
- .package(url: "https://github.com/Purchasely/Purchasely-iOS.git", exact: "6.0.1")
59
+ .package(url: "https://github.com/Purchasely/Purchasely-iOS.git", exact: "6.1.2")
60
60
  ],
61
61
  targets: [
62
62
  .target(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purchasely/cordova-plugin-purchasely",
3
- "version": "6.0.1",
3
+ "version": "6.1.1",
4
4
  "description": "Purchasely is a solution to ease the integration and boost your In-App Purchases & Subscriptions on the App Store, Google Play Store, Amazon Appstore and Huawei App Gallery.",
5
5
  "cordova": {
6
6
  "id": "@purchasely/cordova-plugin-purchasely",
@@ -65,6 +65,11 @@
65
65
  },
66
66
  "homepage": "https://github.com/Purchasely/Purchasely-Cordova#readme",
67
67
  "overrides": {
68
- "js-yaml@3": "^3.15.1"
68
+ "js-yaml@3": "^3.15.2",
69
+ "browserslist": "^4.28.7"
70
+ },
71
+ "resolutions": {
72
+ "js-yaml": "^3.15.2",
73
+ "browserslist": "^4.28.7"
69
74
  }
70
75
  }
package/plugin.xml CHANGED
@@ -1,5 +1,5 @@
1
1
  <?xml version='1.0' encoding='utf-8'?>
2
- <plugin id="@purchasely/cordova-plugin-purchasely" version="6.0.1" xmlns="http://apache.org/cordova/ns/plugins/1.0" xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <plugin id="@purchasely/cordova-plugin-purchasely" version="6.1.1" xmlns="http://apache.org/cordova/ns/plugins/1.0" xmlns:android="http://schemas.android.com/apk/res/android">
3
3
  <name>Purchasely</name>
4
4
  <js-module name="Purchasely" src="www/Purchasely.js">
5
5
  <clobbers target="Purchasely" />
@@ -38,7 +38,7 @@
38
38
  <source url="https://github.com/CocoaPods/Specs.git"/>
39
39
  </config>
40
40
  <pods use-frameworks="true">
41
- <pod name="Purchasely" spec="6.0.1" nospm="true"/>
41
+ <pod name="Purchasely" spec="6.1.2" nospm="true"/>
42
42
  </pods>
43
43
  </podspec>
44
44
  </platform>
@@ -53,7 +53,7 @@
53
53
  <preference name="GradlePluginKotlinCodeStyle" value="official" />
54
54
  </config-file>
55
55
  <framework src="src/android/build-extras.gradle" custom="true" type="gradleReference" />
56
- <framework src="io.purchasely:core:6.0.2" />
56
+ <framework src="io.purchasely:core:6.1.1" />
57
57
  <source-file src="src/android/PurchaselyPlugin.kt" target-dir="java/cordova/plugin/purchasely" />
58
58
  </platform>
59
59
  </plugin>
@@ -16,6 +16,7 @@ import io.purchasely.ext.PLYEvent
16
16
  import io.purchasely.ext.PLYInterceptResult
17
17
  import io.purchasely.ext.PLYInterceptorInfo
18
18
  import io.purchasely.ext.PLYRunningMode
19
+ import io.purchasely.ext.PLYWebRedemptionListener
19
20
  import io.purchasely.ext.PurchaseListener
20
21
  import io.purchasely.ext.Purchasely
21
22
  import io.purchasely.ext.State
@@ -33,6 +34,7 @@ import io.purchasely.models.PLYPlan
33
34
  import io.purchasely.models.PLYPresentationPlan
34
35
  import io.purchasely.models.PLYProduct
35
36
  import io.purchasely.models.PLYSubscriptionData
37
+ import io.purchasely.models.PLYWebRedemptionResult
36
38
  import io.purchasely.views.presentation.PLYThemeMode
37
39
  import io.purchasely.views.presentation.models.PLYDimensionType
38
40
  import io.purchasely.views.presentation.models.PLYTransition
@@ -53,6 +55,9 @@ import java.util.Calendar
53
55
  import java.util.Date
54
56
  import java.util.Locale
55
57
  import java.util.TimeZone
58
+ import java.util.UUID
59
+ import java.net.URI
60
+ import java.net.URISyntaxException
56
61
  import java.util.concurrent.ConcurrentHashMap
57
62
 
58
63
  /**
@@ -87,9 +92,43 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
87
92
 
88
93
  override fun onDestroy() {
89
94
  job.cancel()
95
+ clearCallbackContexts()
90
96
  super.onDestroy()
91
97
  }
92
98
 
99
+ /**
100
+ * Cordova calls this when the WebView navigates, which invalidates every callbackId the
101
+ * previous page handed us.
102
+ */
103
+ override fun onReset() {
104
+ clearCallbackContexts()
105
+ super.onReset()
106
+ }
107
+
108
+ /**
109
+ * Drop every stored [CallbackContext].
110
+ *
111
+ * These live on the companion object, so they are STATIC: they outlive both the plugin
112
+ * instance and the WebView. Neither teardown path cleared them before, which left two
113
+ * problems. Each stale context holds a reference to the dead CordovaWebView, so the
114
+ * whole view tree leaked. And the native SDK listeners registered at `start()` outlive
115
+ * the activity, so an event arriving after a teardown was sent into a dead bridge
116
+ * instead of being dropped.
117
+ *
118
+ * The redemption case is the one that matters most. `webRedemptionListener` is set on
119
+ * the builder, and `Purchasely.Builder.build()` REASSIGNS the SDK's static listener on
120
+ * every call, so a page reload that starts again replaces the lambda rather than
121
+ * stacking one. Until it does, the lambda from the previous plugin instance is still
122
+ * live and reads `webRedemptionCallback` lazily, at fire time. Clearing here is what
123
+ * makes that window a clean no-op rather than a send into a dead callbackId.
124
+ */
125
+ private fun clearCallbackContexts() {
126
+ defaultCallback = null
127
+ eventsCallback = null
128
+ attributesCallback = null
129
+ webRedemptionCallback = null
130
+ }
131
+
93
132
  override fun execute(
94
133
  action: String,
95
134
  args: JSONArray,
@@ -103,6 +142,8 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
103
142
  "addUserAttributeListener" -> addUserAttributesListener(callbackContext)
104
143
  "removeUserAttributeListener" -> removeUserAttributesListener()
105
144
  "removeEventsListener" -> removeEventsListener()
145
+ "addWebRedemptionListener" -> addWebRedemptionListener(callbackContext)
146
+ "removeWebRedemptionListener" -> removeWebRedemptionListener(callbackContext)
106
147
  "getAnonymousUserId" -> getAnonymousUserId(callbackContext)
107
148
  "isAnonymous" -> isAnonymous(callbackContext)
108
149
  "userLogin" -> userLogin(getStringFromJson(args.getString(0)), callbackContext)
@@ -281,6 +322,37 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
281
322
  val allowCampaigns = if (options.has("allowCampaigns")) options.optBoolean("allowCampaigns") else null
282
323
  val deeplink = getStringFromJson(options.optString("deeplink"))
283
324
  val sdkVersion = getStringFromJson(options.optString("sdkVersion"))
325
+ // Both bridges report a refused-and-skipped option at the SAME severity, and
326
+ // deliberately with a plain log line on both: Log.e here, NSLog on iOS. Neither
327
+ // renders UI. Do not reach for anything that puts an overlay or a dialog in front
328
+ // of the host app: start() continues, the option was simply ignored, and a
329
+ // third-party SDK has no business interrupting someone else's app over an option
330
+ // it chose to skip.
331
+ // v6.1.0: three states, and they are not interchangeable. See [resolveProxyOption].
332
+ val proxyOption = resolveProxyOption(options)
333
+ if (proxyOption is PLYProxyOption.Invalid) {
334
+ Log.e("Purchasely", "`proxy` must be an https base URL, for example " +
335
+ "\"https://svc.purchasely.io\", or null to clear the proxy. Received a " +
336
+ "${proxyOption.rawValue?.length ?: 0}-character value. The proxy is not applied.")
337
+ }
338
+ val appHandlesRedemptionAlert = options.optBoolean("appHandlesRedemptionAlert", false)
339
+
340
+ // v6.1.0: JS has no UUID type, so the id crosses the bridge as a string and is parsed
341
+ // here. The native builder takes a UUID?, which is where the guarantee used to live; a
342
+ // string-typed bridge is the only place left to catch a bad value. Refuse it loudly and
343
+ // skip the option. The SDK still starts, matching how native treats an unusable proxy url.
344
+ val anonymousUserIdString = getStringFromJson(options.optString("anonymousUserId"))
345
+ val anonymousUserId = parseCanonicalUuid(anonymousUserIdString)
346
+ if (anonymousUserIdString != null && anonymousUserId == null) {
347
+ // The value is NOT logged. A mis-wired field lands here just as easily as a
348
+ // typo -- an email, an appUserId -- and logcat is collected during support. The
349
+ // length still distinguishes a truncated id from a wrong field. Matches iOS.
350
+ Log.e("Purchasely", "`anonymousUserId` must be a canonical UUID string, for example " +
351
+ "\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received a " +
352
+ "${anonymousUserIdString.length}-character value. " +
353
+ "The anonymous user id is not applied.")
354
+ }
355
+ val anonymousUserIdOverride = options.optBoolean("anonymousUserIdOverride", false)
284
356
 
285
357
  Purchasely.Builder(cordova.context)
286
358
  .apiKey(apiKey)
@@ -293,6 +365,22 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
293
365
  allowCampaigns?.let { this.allowCampaigns(it) }
294
366
  // Cold-start deeplink: replayed automatically once started.
295
367
  deeplink?.let { this.handleDeeplink(Uri.parse(it)) }
368
+ // The three 6.1.0 options are applied through a seam so a unit test can
369
+ // verify WHICH builder call each resolver state produces. Without it,
370
+ // swapping the Clear and Set branches passed every test in the repository.
371
+ applyStartOptions(
372
+ builder = this,
373
+ proxyOption = proxyOption,
374
+ anonymousUserId = anonymousUserId,
375
+ anonymousUserIdOverride = anonymousUserIdOverride,
376
+ appHandlesRedemptionAlert = appHandlesRedemptionAlert,
377
+ // Registered unconditionally: the native SDK has no runtime setter on
378
+ // purpose, because a redemption can settle during start(). It sends
379
+ // nothing when addWebRedemptionListener recorded no callback.
380
+ redemptionListener = PLYWebRedemptionListener { result ->
381
+ deliverRedemption(result)
382
+ },
383
+ )
296
384
  }
297
385
  .build()
298
386
 
@@ -392,6 +480,65 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
392
480
  Purchasely.eventListener = null
393
481
  }
394
482
 
483
+ /**
484
+ * v6.1.0. Record the callback the redemption outcome is routed to.
485
+ *
486
+ * The native [PLYWebRedemptionListener] is registered on the builder chain in [start] (the
487
+ * SDK has no runtime setter, because a redemption can settle during `start()`), so this
488
+ * action only records where to send the outcome. JS must call it BEFORE `start()`.
489
+ */
490
+ private fun addWebRedemptionListener(callbackContext: CallbackContext) {
491
+ // Close the previous stream before replacing it, or its JS closure stays in
492
+ // cordova.callbacks forever. See [releaseCallbackStream].
493
+ releaseCallbackStream(webRedemptionCallback)
494
+ webRedemptionCallback = callbackContext
495
+ }
496
+
497
+ private fun removeWebRedemptionListener(callbackContext: CallbackContext) {
498
+ // The native listener stays registered; clearing the callback makes it a no-op.
499
+ releaseCallbackStream(webRedemptionCallback)
500
+ webRedemptionCallback = null
501
+ // Acknowledge the remove itself, so ITS callbackId is freed too. A void action that
502
+ // never answers leaks its own entry exactly like the listener's.
503
+ callbackContext.success()
504
+ }
505
+
506
+ /**
507
+ * Send one settled redemption to the JS listener.
508
+ *
509
+ * Everything the SDK hands over crosses here, so a test can fire a fabricated
510
+ * [PLYWebRedemptionResult] through the real delivery path and assert exactly what
511
+ * Cordova receives: the JSON body, and `keepCallback = true` so the stream stays open
512
+ * for the next redemption.
513
+ *
514
+ * A no-op when no listener is registered, which is why registering the SDK listener
515
+ * unconditionally at `start()` is behaviour-neutral.
516
+ */
517
+ internal fun deliverRedemption(result: PLYWebRedemptionResult) {
518
+ val callback = webRedemptionCallback ?: return
519
+ val pluginResult = PluginResult(
520
+ PluginResult.Status.OK,
521
+ webRedemptionResultToJson(result, Companion::transformSubscriptionToMap)
522
+ )
523
+ pluginResult.keepCallback = true
524
+ callback.sendPluginResult(pluginResult)
525
+ }
526
+
527
+ /**
528
+ * End a kept-alive Cordova callback stream, freeing its JavaScript closure.
529
+ *
530
+ * Every result the bridge sends a listener carries `keepCallback = true`, so dropping
531
+ * the native reference alone leaks the JS closure until the WebView reloads. NO_RESULT
532
+ * with `keepCallback = false` is cordova.js's own documented way out: it "is used to
533
+ * remove a callback from the list without calling the callbacks".
534
+ */
535
+ private fun releaseCallbackStream(callback: CallbackContext?) {
536
+ if (callback == null) return
537
+ val terminal = PluginResult(PluginResult.Status.NO_RESULT)
538
+ terminal.keepCallback = false
539
+ callback.sendPluginResult(terminal)
540
+ }
541
+
395
542
  private fun getAnonymousUserId(callbackContext: CallbackContext) {
396
543
  callbackContext.success(Purchasely.anonymousUserId)
397
544
  }
@@ -808,45 +955,10 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
808
955
  }
809
956
  }
810
957
 
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
-
833
958
  private fun transformSubscriptionsToJson(list: List<PLYSubscriptionData>): JSONArray {
834
959
  val result = JSONArray()
835
960
  for (data in list) {
836
- val map = HashMap(data.data.toMap())
837
- map["plan"] = transformPlanToMap(data.plan)
838
- map["product"] = normalizeProductPlans(data.product.toMap())
839
- map["subscriptionSource"] = when (data.data.storeType) {
840
- StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal
841
- StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal
842
- StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal
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
847
- else -> null
848
- }
849
- result.put(JSONObject(map))
961
+ result.put(JSONObject(transformSubscriptionToMap(data)))
850
962
  }
851
963
  return result
852
964
  }
@@ -1374,6 +1486,7 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1374
1486
  var defaultCallback: CallbackContext? = null
1375
1487
  var eventsCallback: CallbackContext? = null
1376
1488
  var attributesCallback: CallbackContext? = null
1489
+ var webRedemptionCallback: CallbackContext? = null
1377
1490
 
1378
1491
  // Serializes a v6 PLYPresentationOutcome to the wire contract. `result` is kept as an
1379
1492
  // int (PurchaseResult 0/1/2) for back-compat with the pre-6.0 JS layer.
@@ -1426,6 +1539,47 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1426
1539
  )
1427
1540
  }
1428
1541
 
1542
+ // These three live in the companion, not on the instance, so that
1543
+ // `Companion::transformSubscriptionToMap` is an UNBOUND reference. The SDK keeps
1544
+ // the redemption listener in a static (Purchasely.webRedemptionListener), so a
1545
+ // bound reference would keep the plugin, its Activity and its WebView reachable
1546
+ // for as long as that static holds the lambda. None of them touches instance state.
1547
+ // Hardening for the upcoming rc.4 native release: PLYPlan.toMap()'s raw "type" entry is
1548
+ // moving from an ordinal (Int) to the DistributionType name (String). transformPlanToMap
1549
+ // already overwrites "type" explicitly wherever it's used, but allProducts/
1550
+ // productWithIdentifier/the subscription's nested "product" field pass product.toMap()
1551
+ // straight through -- normalize those raw plan entries so the JS PlanType contract
1552
+ // (an ordinal) stays stable across both native formats. Both formats resolve to the same
1553
+ // ordinal since DistributionType's declared order already matches Purchasely.PlanType.
1554
+ private fun normalizePlanTypeOrdinal(raw: Any?): Int? = when (raw) {
1555
+ is Number -> raw.toInt()
1556
+ is String -> runCatching { DistributionType.valueOf(raw).ordinal }.getOrNull()
1557
+ else -> null
1558
+ }
1559
+
1560
+ private fun normalizeProductPlans(map: Map<String, Any?>): Map<String, Any?> {
1561
+ val plans = map["plans"] as? List<*> ?: return map
1562
+ val normalized = plans.map { plan ->
1563
+ val planMap = plan as? Map<*, *> ?: return@map plan
1564
+ HashMap(planMap).apply { this["type"] = normalizePlanTypeOrdinal(this["type"]) }
1565
+ }
1566
+ return HashMap(map).apply { this["plans"] = normalized }
1567
+ }
1568
+
1569
+ /**
1570
+ * Map one [PLYSubscriptionData] to the JS subscription shape.
1571
+ *
1572
+ * Shared by `userSubscriptions`, `userSubscriptionsHistory` and the web redemption
1573
+ * listener, whose `context.subscription` is the same type, so the three report one shape.
1574
+ */
1575
+ internal fun transformSubscriptionToMap(data: PLYSubscriptionData): Map<String, Any?> {
1576
+ return HashMap(data.data.toMap()).apply {
1577
+ this["plan"] = transformPlanToMap(data.plan)
1578
+ this["product"] = normalizeProductPlans(data.product.toMap())
1579
+ this["subscriptionSource"] = subscriptionSourceFor(data.data.storeType)
1580
+ }
1581
+ }
1582
+
1429
1583
  private fun transformPlanToMap(plan: PLYPlan?): Map<String?, Any?> {
1430
1584
  if (plan == null) return HashMap()
1431
1585
  val map = HashMap(plan.toMap())
@@ -1493,3 +1647,179 @@ class PurchaselyPlugin : CordovaPlugin(), CoroutineScope {
1493
1647
  */
1494
1648
  }
1495
1649
  }
1650
+
1651
+ /**
1652
+ * Map a native [StoreType] to the `subscriptionSource` wire value.
1653
+ *
1654
+ * The ordinal IS the wire value, and it matches iOS's `PLYSubscriptionSource` one for one:
1655
+ * apple 0, google 1, amazon 2, huawei 3, stripe 4, none 5. Verified against the shipped
1656
+ * 6.1.0 artifacts on both platforms, so there is no per-platform translation here.
1657
+ *
1658
+ * Listed exhaustively on purpose, and `internal` so a unit test drives THIS function
1659
+ * rather than re-deriving the mapping. The previous version named four stores and
1660
+ * collapsed `NONE` and `WEB_CHECKOUT_STRIPE` into a hardcoded 4, on the stated but FALSE
1661
+ * premise that iOS's `None` was 4. It is 5, and 4 is Stripe. So a Web2App subscription
1662
+ * reported `none`, and a sourceless one reported Stripe's value. An `else` branch is what
1663
+ * let that pass unnoticed; without one, a store type added by a future SDK fails the
1664
+ * Kotlin build here instead of silently reporting the wrong source.
1665
+ */
1666
+ internal fun subscriptionSourceFor(storeType: StoreType?): Int? = when (storeType) {
1667
+ StoreType.APPLE_APP_STORE -> StoreType.APPLE_APP_STORE.ordinal
1668
+ StoreType.GOOGLE_PLAY_STORE -> StoreType.GOOGLE_PLAY_STORE.ordinal
1669
+ StoreType.AMAZON_APP_STORE -> StoreType.AMAZON_APP_STORE.ordinal
1670
+ StoreType.HUAWEI_APP_GALLERY -> StoreType.HUAWEI_APP_GALLERY.ordinal
1671
+ StoreType.WEB_CHECKOUT_STRIPE -> StoreType.WEB_CHECKOUT_STRIPE.ordinal
1672
+ StoreType.NONE -> StoreType.NONE.ordinal
1673
+ null -> null
1674
+ }
1675
+
1676
+ /**
1677
+ * Apply the three 6.1.0 start options to a [Purchasely.Builder].
1678
+ *
1679
+ * Extracted so a unit test can assert WHICH builder call each resolver state produces. The
1680
+ * resolvers were covered on their own, but the branch consuming them was not: swapping the
1681
+ * `Clear` and `Set` cases, or inverting the override flag, passed every test in the
1682
+ * repository. That branch is the whole point of the release.
1683
+ *
1684
+ * `Absent` and `Invalid` make NO call, so the SDK keeps whatever it already has. `Clear`
1685
+ * calls `proxy(null)`, which is a real native operation and not the same as making no call.
1686
+ */
1687
+ internal fun applyStartOptions(
1688
+ builder: Purchasely.Builder,
1689
+ proxyOption: PLYProxyOption,
1690
+ anonymousUserId: UUID?,
1691
+ anonymousUserIdOverride: Boolean,
1692
+ appHandlesRedemptionAlert: Boolean,
1693
+ redemptionListener: PLYWebRedemptionListener,
1694
+ ) {
1695
+ when (proxyOption) {
1696
+ is PLYProxyOption.Absent -> {}
1697
+ is PLYProxyOption.Invalid -> {}
1698
+ is PLYProxyOption.Clear -> builder.proxy(null)
1699
+ is PLYProxyOption.Set -> builder.proxy(proxyOption.api)
1700
+ }
1701
+ anonymousUserId?.let { builder.anonymousUserId(it, anonymousUserIdOverride) }
1702
+ builder.webRedemptionListener(appHandlesRedemptionAlert, redemptionListener)
1703
+ }
1704
+
1705
+ /**
1706
+ * How the `proxy` start option resolves. Purchasely 6.1.0.
1707
+ *
1708
+ * The three JS states are not interchangeable, and a fourth case exists for a value that
1709
+ * will not convert to a URI. Collapsing [Absent] into [Clear] would turn every start into
1710
+ * an implicit clear; collapsing [Clear] into [Absent] would make a clear silently do
1711
+ * nothing.
1712
+ */
1713
+ internal sealed class PLYProxyOption {
1714
+ /** The key is absent. Make no native call: leave the current setting untouched. */
1715
+ object Absent : PLYProxyOption()
1716
+
1717
+ /** The key is present and null. Call `proxy(null)` to clear the proxy. */
1718
+ object Clear : PLYProxyOption()
1719
+
1720
+ /** The key holds a usable value. Call `proxy(api)`. */
1721
+ data class Set(val api: String) : PLYProxyOption()
1722
+
1723
+ /** The key holds a value that will not convert. Log it and make no native call. */
1724
+ data class Invalid(val rawValue: String?) : PLYProxyOption()
1725
+ }
1726
+
1727
+ /**
1728
+ * Resolve the `proxy` start option.
1729
+ *
1730
+ * Pure, and `internal` so a unit test drives the real bridge logic instead of a copy.
1731
+ *
1732
+ * `JSONObject.has` is what separates an absent key from an explicit null, and
1733
+ * `JSONObject.isNull` separates the null from a value. `optString` cannot do this on its
1734
+ * own: it renders `JSONObject.NULL` as the STRING `"null"`, which is exactly how a clear
1735
+ * used to be swallowed into the absent branch.
1736
+ *
1737
+ * The scheme, the host, and the absence of a query, a fragment and credentials are the
1738
+ * native SDK's business: it refuses a bad value with an error log and keeps the production
1739
+ * host, and it drops a trailing slash. This only rejects what will not convert at all,
1740
+ * which keeps the accepted set the same as the iOS bridge's `NSURL` conversion.
1741
+ */
1742
+ internal fun resolveProxyOption(options: JSONObject): PLYProxyOption {
1743
+ if (!options.has("proxy")) return PLYProxyOption.Absent
1744
+ if (options.isNull("proxy")) return PLYProxyOption.Clear
1745
+
1746
+ val raw = options.opt("proxy")
1747
+ if (raw !is String) return PLYProxyOption.Invalid(raw?.toString())
1748
+ // A blank value is refused HERE, to match iOS: `[NSURL URLWithString:@""]` is nil, so
1749
+ // the iOS bridge resolves Invalid and never calls native. Leaving it to the SDK would
1750
+ // make the two platforms accept different sets of strings for the same option.
1751
+ if (raw.isBlank()) return PLYProxyOption.Invalid(raw)
1752
+ return try {
1753
+ URI(raw)
1754
+ PLYProxyOption.Set(raw)
1755
+ } catch (e: URISyntaxException) {
1756
+ PLYProxyOption.Invalid(raw)
1757
+ }
1758
+ }
1759
+
1760
+ /**
1761
+ * Flatten a [PLYWebRedemptionResult] to the 5-key shape the JS listener receives.
1762
+ * See `addWebRedemptionListener` in www/Purchasely.js for the shape itself.
1763
+ *
1764
+ * [subscriptionToMap] is injected so a unit test can drive this without constructing an SDK
1765
+ * [PLYSubscriptionData]. Production passes the plugin's own `transformSubscriptionToMap`.
1766
+ *
1767
+ * EVERY NULL IS PUT AS [JSONObject.NULL] EXPLICITLY, and the object is built here rather
1768
+ * than returned as a `Map` for the caller to wrap. `JSONObject(Map)` disagrees across
1769
+ * implementations: Android's wraps a null and keeps the key, the reference `org.json` DROPS
1770
+ * the entry — which would reach JS as `undefined` instead of `null`.
1771
+ */
1772
+ internal fun webRedemptionResultToJson(
1773
+ result: PLYWebRedemptionResult,
1774
+ subscriptionToMap: (PLYSubscriptionData) -> Map<String, Any?>,
1775
+ ): JSONObject {
1776
+ val json = JSONObject()
1777
+ when (result) {
1778
+ is PLYWebRedemptionResult.Success -> {
1779
+ json.put("isSuccess", true)
1780
+ val context = result.context
1781
+ if (context == null) {
1782
+ json.put("context", JSONObject.NULL)
1783
+ } else {
1784
+ val subscription = context.subscription
1785
+ json.put("context", JSONObject().put(
1786
+ "subscription",
1787
+ if (subscription == null) JSONObject.NULL
1788
+ else JSONObject(subscriptionToMap(subscription))
1789
+ ))
1790
+ }
1791
+ json.put("replay", result.replay)
1792
+ json.put("errorCode", JSONObject.NULL)
1793
+ json.put("errorMessage", JSONObject.NULL)
1794
+ }
1795
+ is PLYWebRedemptionResult.Failure -> {
1796
+ json.put("isSuccess", false)
1797
+ json.put("context", JSONObject.NULL)
1798
+ // A failure still reports replay, so the shape never changes between branches.
1799
+ json.put("replay", false)
1800
+ json.put("errorCode", result.errorCode ?: JSONObject.NULL)
1801
+ json.put("errorMessage", result.errorMessage ?: JSONObject.NULL)
1802
+ }
1803
+ }
1804
+ return json
1805
+ }
1806
+
1807
+ /**
1808
+ * Parse a canonical UUID string, or return null.
1809
+ *
1810
+ * JS has no UUID type, so an anonymous user id crosses the bridge as a string.
1811
+ * `UUID.fromString` is lenient and accepts a short form such as `"1-2-3-4-5"` that the iOS
1812
+ * `NSUUID` parser refuses. The round-trip check makes both platforms agree on what
1813
+ * "canonical" means, so one id string is accepted, or refused, on both.
1814
+ *
1815
+ * `internal` so a unit test drives it without an Android logger. The caller logs a refusal.
1816
+ */
1817
+ internal fun parseCanonicalUuid(value: String?): UUID? {
1818
+ if (value == null) return null
1819
+ val parsed = try {
1820
+ UUID.fromString(value)
1821
+ } catch (e: IllegalArgumentException) {
1822
+ return null
1823
+ }
1824
+ return if (parsed.toString().equals(value, ignoreCase = true)) parsed else null
1825
+ }
@@ -8,7 +8,7 @@
8
8
  #import <Cordova/CDVPlugin.h>
9
9
  #import "CDVPurchasely.h"
10
10
 
11
- @interface CDVPurchasely (Events) <PLYEventDelegate> {
11
+ @interface CDVPurchasely (Events) <PLYEventDelegate, PLYWebRedemptionDelegate> {
12
12
 
13
13
  }
14
14
 
@@ -27,6 +27,40 @@
27
27
  }
28
28
  }
29
29
 
30
+ /// `PLYWebRedemptionDelegate`. The SDK calls this on the main thread, once per settled
31
+ /// redemption, on success and on failure alike, and always after the matching
32
+ /// REDEMPTION_CONSUMED / REDEMPTION_FAILED event reached the event delegate.
33
+ ///
34
+ /// Mapped to the flat 5-key shape the Android bridge emits, so one JS listener drives both
35
+ /// platforms. `context` and `context.subscription` stay separately nullable: a success can
36
+ /// carry no context at all, and a present context can carry no subscription.
37
+ ///
38
+ /// `errorMessage` can hold the backend's masked email hint for an expired link. The
39
+ /// REDEMPTION_FAILED event drops that hint on purpose; this channel keeps it, so the app
40
+ /// can tell the user where the fresh link went.
41
+ ///
42
+ /// The Android bridge carries it too: `RedemptionOutcome.Expired.toResult()` appends the
43
+ /// same hint. So the "show it, never log it" rule the JS docs state is unconditional, and
44
+ /// must not be written as an iOS-only caveat.
45
+ - (void)webRedemptionCompletedWithResult:(PLYWebRedemptionResult * _Nonnull)result {
46
+ if (self.webRedemptionCommand == nil) {
47
+ return;
48
+ }
49
+
50
+ PLYSubscription *subscription = result.context.subscription;
51
+ NSDictionary<NSString *, id> *body =
52
+ [CDVPurchasely webRedemptionBodyWithSuccess:result.isSuccess
53
+ hasContext:result.context != nil
54
+ subscription:subscription != nil ? subscription.asDictionary : nil
55
+ replay:result.replay
56
+ errorCode:result.errorCode
57
+ errorMessage:result.errorMessage];
58
+
59
+ CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:body];
60
+ [pluginResult setKeepCallbackAsBool:YES];
61
+ [self.commandDelegate sendPluginResult:pluginResult callbackId:self.webRedemptionCommand.callbackId];
62
+ }
63
+
30
64
  - (void)reloadContent: (NSNotification *)aNotification {
31
65
  if (self.purchasedCommand) {
32
66
  CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK];
@@ -8,11 +8,59 @@
8
8
  #import <Cordova/CDVPlugin.h>
9
9
  #import <Purchasely/Purchasely-Swift.h>
10
10
 
11
+ /// How the `proxy` start option resolves. Purchasely 6.1.0.
12
+ ///
13
+ /// The three JS states are NOT interchangeable, and a fourth case exists for a value the
14
+ /// bridge cannot convert. `proxyWithApi:` takes an `NSURL *_Nullable`, where nil means
15
+ /// CLEAR, so an unconvertible string must skip the modifier rather than pass nil: passing
16
+ /// nil would silently disable a proxy the app explicitly asked for, because of a typo.
17
+ typedef NS_ENUM(NSInteger, CDVPurchaselyProxyOption) {
18
+ /// The key is absent. Make no native call: leave the current setting untouched.
19
+ CDVPurchaselyProxyOptionAbsent = 0,
20
+ /// The key is present and null. Call `proxyWithApi:nil` to clear the proxy.
21
+ CDVPurchaselyProxyOptionClear,
22
+ /// The key holds a convertible string. Call `proxyWithApi:` with the URL.
23
+ CDVPurchaselyProxyOptionSet,
24
+ /// The key holds a string `NSURL` cannot convert. Log and make no native call.
25
+ CDVPurchaselyProxyOptionInvalid
26
+ };
27
+
11
28
  // Protocol conformance (PLYEventDelegate / PLYUserAttributeDelegate) is declared on the
12
29
  // CDVPurchasely (Events) and (UserAttributes) categories, which implement the delegate methods.
13
30
  @interface CDVPurchasely : CDVPlugin {
14
31
  }
15
32
 
33
+ /// Resolve the `proxy` start option to one of the four cases above.
34
+ ///
35
+ /// Pure, and exposed so a unit test drives the real bridge logic instead of a copy. `value`
36
+ /// is the raw option, so `nil` for an absent key and `NSNull` for an explicit JS null.
37
+ /// `outUrl` receives the URL only for `CDVPurchaselyProxyOptionSet`.
38
+ + (CDVPurchaselyProxyOption)proxyOptionFor:(id _Nullable)value url:(NSURL * _Nullable * _Nullable)outUrl;
39
+
40
+ /// Build the flat 5-key body a settled Web2App redemption reports to JS.
41
+ ///
42
+ /// Takes primitives rather than a `PLYWebRedemptionResult`, because that class declares
43
+ /// `init` unavailable and a test cannot construct one. `hasContext` is separate from
44
+ /// `subscription` on purpose: a present context carrying no subscription is NOT the same as
45
+ /// no context at all, and both must stay expressible.
46
+ ///
47
+ /// Exposed so the XCTest target asserts the real shape the delegate emits, rather than a
48
+ /// copy of it. Matches the React Native bridge's seam of the same name.
49
+ + (NSDictionary<NSString *, id> * _Nonnull)webRedemptionBodyWithSuccess:(BOOL)isSuccess
50
+ hasContext:(BOOL)hasContext
51
+ subscription:(NSDictionary * _Nullable)subscription
52
+ replay:(BOOL)replay
53
+ errorCode:(NSString * _Nullable)errorCode
54
+ errorMessage:(NSString * _Nullable)errorMessage;
55
+
56
+ /// Parse a canonical UUID string, or return nil.
57
+ ///
58
+ /// JS has no UUID type, so an anonymous user id crosses the bridge as a string. Exposed so
59
+ /// a unit test can pin the cross-platform contract: this refuses the lenient short form
60
+ /// (`"1-2-3-4-5"`) that Android's `UUID.fromString` accepts, which is why the Android
61
+ /// bridge adds a round-trip check.
62
+ + (NSUUID * _Nullable)canonicalUUIDFromString:(id _Nullable)value;
63
+
16
64
  // The presentation currently displayed (v6 uses id<PLYPresentation> for close()/back()).
17
65
  @property (nonatomic, strong) id<PLYPresentation> currentPresentation;
18
66
 
@@ -20,6 +68,12 @@
20
68
  @property CDVInvokedUrlCommand* eventCommand;
21
69
  @property CDVInvokedUrlCommand* attributeCommand;
22
70
 
71
+ // Purchasely 6.1.0. The command `addWebRedemptionListener` recorded, or nil. The
72
+ // PLYWebRedemptionDelegate is registered on the builder chain in `start:` (the native SDK
73
+ // has no runtime setter), so this is the only switch: nil makes
74
+ // `webRedemptionCompletedWithResult:` a no-op.
75
+ @property CDVInvokedUrlCommand* webRedemptionCommand;
76
+
23
77
  @property (nonatomic) NSMutableArray<id<PLYPresentation>> *presentationsLoaded;
24
78
 
25
79
  @property (nonatomic) CDVInvokedUrlCommand* purchaseResolve;
@@ -57,6 +111,9 @@
57
111
  - (void)userSubscriptionsHistory:(CDVInvokedUrlCommand*)command;
58
112
  - (void)addEventsListener:(CDVInvokedUrlCommand*)command;
59
113
  - (void)removeEventsListener:(CDVInvokedUrlCommand*)command;
114
+ - (void)releaseCallbackStream:(CDVInvokedUrlCommand * _Nullable)command;
115
+ - (void)addWebRedemptionListener:(CDVInvokedUrlCommand*)command;
116
+ - (void)removeWebRedemptionListener:(CDVInvokedUrlCommand*)command;
60
117
  - (void)registerActionInterceptor:(CDVInvokedUrlCommand*)command;
61
118
  - (void)unregisterActionInterceptor:(CDVInvokedUrlCommand*)command;
62
119
  - (void)completeActionInterceptor:(CDVInvokedUrlCommand*)command;
@@ -29,6 +29,82 @@
29
29
  self.pendingInterceptCompletions = [NSMutableDictionary new];
30
30
  }
31
31
 
32
+ + (CDVPurchaselyProxyOption)proxyOptionFor:(id _Nullable)value url:(NSURL * _Nullable * _Nullable)outUrl {
33
+ if (outUrl != NULL) {
34
+ *outUrl = nil;
35
+ }
36
+ // An absent key and an explicit null are different operations. Check NSNull FIRST:
37
+ // it is a real object, so an `isKindOfClass:[NSString class]` test would fall through
38
+ // to the absent branch and turn a requested clear into a silent no-op.
39
+ if (value == nil) {
40
+ return CDVPurchaselyProxyOptionAbsent;
41
+ }
42
+ if (value == [NSNull null]) {
43
+ return CDVPurchaselyProxyOptionClear;
44
+ }
45
+ if (![value isKindOfClass:[NSString class]]) {
46
+ return CDVPurchaselyProxyOptionInvalid;
47
+ }
48
+ // Do not validate the scheme, the host, a query, a fragment or credentials here. The
49
+ // native SDK refuses those with an error log and keeps the production host, and it
50
+ // drops a trailing slash. The bridge only rejects what will not convert at all.
51
+ NSURL *url = [NSURL URLWithString:(NSString *)value];
52
+ if (url == nil) {
53
+ return CDVPurchaselyProxyOptionInvalid;
54
+ }
55
+ if (outUrl != NULL) {
56
+ *outUrl = url;
57
+ }
58
+ return CDVPurchaselyProxyOptionSet;
59
+ }
60
+
61
+ + (NSDictionary<NSString *, id> * _Nonnull)webRedemptionBodyWithSuccess:(BOOL)isSuccess
62
+ hasContext:(BOOL)hasContext
63
+ subscription:(NSDictionary * _Nullable)subscription
64
+ replay:(BOOL)replay
65
+ errorCode:(NSString * _Nullable)errorCode
66
+ errorMessage:(NSString * _Nullable)errorMessage {
67
+ // Every key is always present, on both branches, so a JS listener reads one shape
68
+ // whether the redemption was granted or refused. Absence is NSNull, never a missing
69
+ // key: a missing key reaches JS as `undefined` instead of `null`.
70
+ id context = [NSNull null];
71
+ if (hasContext) {
72
+ context = @{ @"subscription": subscription ?: [NSNull null] };
73
+ }
74
+ return @{
75
+ @"isSuccess": @(isSuccess),
76
+ @"context": context,
77
+ @"replay": @(replay),
78
+ @"errorCode": errorCode ?: [NSNull null],
79
+ @"errorMessage": errorMessage ?: [NSNull null]
80
+ };
81
+ }
82
+
83
+ + (NSUUID * _Nullable)canonicalUUIDFromString:(id _Nullable)value {
84
+ if (![value isKindOfClass:[NSString class]]) {
85
+ return nil;
86
+ }
87
+ return [[NSUUID alloc] initWithUUIDString:(NSString *)value];
88
+ }
89
+
90
+ /// Cordova calls this when the WebView navigates, which invalidates every callbackId the
91
+ /// previous page handed us. Without it the stored commands stay live and every listener
92
+ /// callback is sent to a dead callbackId after a reload.
93
+ ///
94
+ /// The redemption case is the one that matters most: `webRedemptionDelegate:` is set on the
95
+ /// builder at `start:`, and the SDK holds the delegate weakly, so this object keeps
96
+ /// receiving outcomes for as long as the plugin lives.
97
+ /// `webRedemptionCompletedWithResult:` reads `webRedemptionCommand` at fire time, so a
98
+ /// reloaded page can re-register and keep working; clearing here makes the window in
99
+ /// between a clean no-op rather than a send on a dead callbackId.
100
+ - (void)onReset {
101
+ self.eventCommand = nil;
102
+ self.attributeCommand = nil;
103
+ self.webRedemptionCommand = nil;
104
+ self.purchasedCommand = nil;
105
+ [super onReset];
106
+ }
107
+
32
108
  - (void)start:(CDVInvokedUrlCommand*)command {
33
109
  // v6: a single options dictionary (see the JS↔native contract), no longer positional args.
34
110
  NSDictionary *opts = [command argumentAtIndex:0];
@@ -91,6 +167,68 @@
91
167
  builder = [builder allowCampaigns:allowCampaigns.boolValue];
92
168
  }
93
169
 
170
+ // Both bridges report a refused-and-skipped option at the SAME severity, and
171
+ // deliberately with a plain log line on both: NSLog here, Log.e on Android. Neither
172
+ // renders UI. Do not reach for anything that puts an overlay or an alert in front of
173
+ // the host app: start() continues, the option was simply ignored, and a third-party
174
+ // SDK has no business interrupting someone else's app over an option it chose to skip.
175
+
176
+ // v6.1.0: JS has no UUID type, so the id crosses the bridge as a string and is parsed
177
+ // here. The native builder takes an NSUUID, which is where the guarantee used to live;
178
+ // a string-typed bridge is the only place left to catch a bad value. Refuse it loudly
179
+ // and skip the option. The SDK still starts, matching how native treats an unusable
180
+ // proxy url.
181
+ id anonymousUserId = opts[@"anonymousUserId"];
182
+ if ([anonymousUserId isKindOfClass:[NSString class]]) {
183
+ NSUUID *parsed = [CDVPurchasely canonicalUUIDFromString:anonymousUserId];
184
+ if (parsed == nil) {
185
+ // The value is NOT logged. A mis-wired field lands here just as easily as a
186
+ // typo -- an email, an appUserId -- and a device log is captured during
187
+ // support. The length is enough to tell a truncated id from a wrong field.
188
+ NSLog(@"[Purchasely] `anonymousUserId` must be a canonical UUID string, for example "
189
+ "\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\". Received a %lu-character value. "
190
+ "The anonymous user id is not applied.",
191
+ (unsigned long)((NSString *)anonymousUserId).length);
192
+ } else {
193
+ NSNumber *override = opts[@"anonymousUserIdOverride"];
194
+ BOOL shouldOverride = [override isKindOfClass:[NSNumber class]] ? override.boolValue : NO;
195
+ builder = [builder appAnonymousUserId:parsed override:shouldOverride];
196
+ }
197
+ }
198
+
199
+ // v6.1.0: three states, and they are not interchangeable. An absent key makes no
200
+ // native call and leaves the current setting untouched; an explicit null clears the
201
+ // proxy and returns to api.purchasely.io, which is a supported operation and not an
202
+ // error; a string routes the API host. A value NSURL cannot convert skips the
203
+ // modifier, because `proxyWithApi:nil` means CLEAR, not "ignore this value", so
204
+ // passing nil would silently disable a proxy the app asked for.
205
+ NSURL *proxyUrl = nil;
206
+ switch ([CDVPurchasely proxyOptionFor:opts[@"proxy"] url:&proxyUrl]) {
207
+ case CDVPurchaselyProxyOptionAbsent:
208
+ break;
209
+ case CDVPurchaselyProxyOptionClear:
210
+ builder = [builder proxyWithApi:nil];
211
+ break;
212
+ case CDVPurchaselyProxyOptionSet:
213
+ builder = [builder proxyWithApi:proxyUrl];
214
+ break;
215
+ case CDVPurchaselyProxyOptionInvalid:
216
+ NSLog(@"[Purchasely] `proxy` must be an https base URL, for example "
217
+ "\"https://svc.purchasely.io\", or null to clear the proxy. Received "
218
+ "\"%@\". The proxy is not applied.", opts[@"proxy"]);
219
+ break;
220
+ }
221
+
222
+ // v6.1.0: registered unconditionally. The native SDK has no runtime setter on purpose,
223
+ // because a redemption can settle during `start()` (a cold start that the `ply/redeem`
224
+ // link itself triggered, or a token a previous launch left pending). The delegate
225
+ // callback returns early when `addWebRedemptionListener` recorded no command, so this
226
+ // is behaviour-neutral by default.
227
+ NSNumber *handlesRedemptionAlert = opts[@"appHandlesRedemptionAlert"];
228
+ builder = [builder webRedemptionDelegate:self
229
+ appHandlesRedemptionAlert:[handlesRedemptionAlert isKindOfClass:[NSNumber class]]
230
+ ? handlesRedemptionAlert.boolValue : NO];
231
+
94
232
  // Cold-start deeplink URL captured at launch (handled automatically once start completes).
95
233
  NSString *deeplink = opts[@"deeplink"];
96
234
  if ([deeplink isKindOfClass:[NSString class]] && deeplink.length > 0) {
@@ -527,6 +665,41 @@
527
665
  self.eventCommand = nil;
528
666
  }
529
667
 
668
+ /// End a kept-alive Cordova callback stream, freeing its JavaScript closure.
669
+ ///
670
+ /// Every result this bridge sends a listener carries `keepCallback:YES`, so dropping the
671
+ /// native command alone leaks the JS closure until the WebView reloads. NO_RESULT with
672
+ /// `keepCallback:NO` is cordova.js's own documented way out: it "is used to remove a
673
+ /// callback from the list without calling the callbacks".
674
+ - (void)releaseCallbackStream:(CDVInvokedUrlCommand * _Nullable)command {
675
+ if (command == nil) {
676
+ return;
677
+ }
678
+ CDVPluginResult *terminal = [CDVPluginResult resultWithStatus:CDVCommandStatus_NO_RESULT];
679
+ [terminal setKeepCallbackAsBool:NO];
680
+ [self.commandDelegate sendPluginResult:terminal callbackId:command.callbackId];
681
+ }
682
+
683
+ // v6.1.0. The PLYWebRedemptionDelegate is registered on the builder chain in `start:` (the
684
+ // native SDK has no runtime setter, because a redemption can settle during start()), so
685
+ // this action only records the command to route the outcome to. Call it BEFORE start().
686
+ - (void)addWebRedemptionListener:(CDVInvokedUrlCommand*)command {
687
+ // Close the previous stream before replacing it, or its JS closure stays in
688
+ // cordova.callbacks forever.
689
+ [self releaseCallbackStream:self.webRedemptionCommand];
690
+ self.webRedemptionCommand = command;
691
+ }
692
+
693
+ - (void)removeWebRedemptionListener:(CDVInvokedUrlCommand*)command {
694
+ // The delegate stays registered. Clearing the command makes
695
+ // `webRedemptionCompletedWithResult:` a no-op.
696
+ [self releaseCallbackStream:self.webRedemptionCommand];
697
+ self.webRedemptionCommand = nil;
698
+ // Acknowledge the remove itself, so ITS callbackId is freed too. A void action that
699
+ // never answers leaks its own entry exactly like the listener's.
700
+ [self successFor:command resultBool:YES];
701
+ }
702
+
530
703
  - (void)removeUserAttributeListener:(CDVInvokedUrlCommand*)command {
531
704
  // v6 `setUserAttributeDelegate:` is _Nonnull (no native unregister). Clearing
532
705
  // attributeCommand makes the user-attribute callbacks a no-op.
package/www/Purchasely.js CHANGED
@@ -59,11 +59,25 @@ function presentationDispatcher(success, callbacks) {
59
59
  // allowDeeplink (bool, optional)
60
60
  // allowCampaigns (bool, optional)
61
61
  // deeplink (string, optional — cold-start deeplink URL)
62
+ //
63
+ // Purchasely 6.1.0 adds four options:
64
+ // anonymousUserId (string, optional — a canonical UUID string; a bad value is
65
+ // logged and skipped, start() still succeeds)
66
+ // anonymousUserIdOverride (bool, optional — false; true SPLITS the user history)
67
+ // proxy (string|null, optional — Android+iOS. THREE STATES:
68
+ // 'https://…' routes the API host
69
+ // null CLEARS it, back to api.purchasely.io
70
+ // key absent leaves the current setting untouched
71
+ // A clear is a supported native operation, not an error.)
72
+ // appHandlesRedemptionAlert (bool, optional — false keeps the SDK popin, true hands the
73
+ // result screen to the app. See addWebRedemptionListener.)
74
+ //
75
+ // README.md "What is new in 6.1.0" is the reference for all four.
62
76
  exports.start = function (options, success, error) {
63
77
  var opts = options || {};
64
78
  var cordovaSdkVersion = cordova.define.moduleMap['cordova/plugin_list'].exports['metadata']['cordova-plugin-purchasely']
65
79
  if(!cordovaSdkVersion) {
66
- cordovaSdkVersion = "6.0.1";
80
+ cordovaSdkVersion = "6.1.1";
67
81
  }
68
82
  opts.sdkVersion = cordovaSdkVersion;
69
83
  exec(success, error, 'Purchasely', 'start', [opts]);
@@ -89,7 +103,76 @@ PLYStartBuilder.prototype.storekitVersion = function (value) { this._options.sto
89
103
  PLYStartBuilder.prototype.storeKit1 = function (value) { this._options.storeKit1 = value; return this; };
90
104
  PLYStartBuilder.prototype.deeplink = function (value) { this._options.deeplink = value; return this; };
91
105
 
106
+ // Purchasely 6.1.0. `id` must be a canonical UUID string; `override` defaults to false.
107
+ // See the exports.start option block for the full contract.
108
+ PLYStartBuilder.prototype.anonymousUserId = function (id, override) {
109
+ this._options.anonymousUserId = id;
110
+ this._options.anonymousUserIdOverride = override === undefined ? false : override;
111
+ return this;
112
+ };
113
+
114
+ // Purchasely 6.1.0. Pass an https base URL to route the API host, or null to CLEAR a
115
+ // proxy and return to api.purchasely.io. Both differ from never calling the modifier,
116
+ // which leaves the current setting untouched. See the exports.start option block.
117
+ //
118
+ // An argument is required. `proxy()` with none is refused, because the no-argument native
119
+ // modifiers disagree across platforms: iOS `proxy()` routes through Purchasely's own
120
+ // proxy at svc.purchasely.io, while Android `proxy()` clears. A Cordova shorthand would
121
+ // therefore mean two different things on the two platforms.
122
+ //
123
+ // `undefined` is never stored: JSON.stringify drops an undefined-valued key, which would
124
+ // make an explicit clear indistinguishable from an absent option on both natives.
125
+ PLYStartBuilder.prototype.proxy = function (api) {
126
+ // An explicit `undefined` is treated exactly like no argument at all, and NOT as null.
127
+ // The two public entry points have to agree on the same input: JSON.stringify drops an
128
+ // undefined-valued key, so `start({ proxy: undefined })` reaches native as Absent.
129
+ // Mapping it to null here would make `builder(k).proxy(config.proxy)` CLEAR the proxy
130
+ // whenever config.proxy has not loaded yet, which is the opposite of leaving it alone.
131
+ if (arguments.length === 0 || api === undefined) {
132
+ defaultError('[Purchasely] proxy() requires an argument: an https base URL, or ' +
133
+ 'null to clear the proxy. The proxy option is not applied.');
134
+ return this;
135
+ }
136
+ this._options.proxy = api;
137
+ return this;
138
+ };
139
+
140
+ // Purchasely 6.1.0. false (the default) keeps the SDK's own redemption popin.
141
+ PLYStartBuilder.prototype.appHandlesRedemptionAlert = function (handles) {
142
+ this._options.appHandlesRedemptionAlert = handles;
143
+ return this;
144
+ };
145
+
146
+ // Purchasely 6.1.0: the PRIMARY way to receive Web2App redemption outcomes.
147
+ //
148
+ // Purchasely.builder(apiKey).webRedemptionListener(onRedemption, true).start()
149
+ //
150
+ // The callback never crosses the bridge: each native registers ITSELF as the delegate at
151
+ // start() and forwards outcomes as a Cordova callback stream, so this is a JS concern only.
152
+ //
153
+ // STORED HERE, SUBSCRIBED IN start(), just before the native call. Do not move it back:
154
+ // subscribing at chain time leaks a live callback from a builder that is never started. A
155
+ // redemption can only settle once the SDK runs, so subscribing here still covers one that
156
+ // settles DURING start(), which is the case the feature exists for.
157
+ //
158
+ // 2nd argument = appHandlesRedemptionAlert; omitting it keeps the native default. Callback
159
+ // first, matching iOS -- the natives disagree on order (Android takes the flag first).
160
+ PLYStartBuilder.prototype.webRedemptionListener = function (callback, appHandlesRedemptionAlert) {
161
+ // Kept off _options on purpose: that object is the exec payload, and a callback has no
162
+ // business being serialized into it.
163
+ this._webRedemptionCallback = callback;
164
+ if (appHandlesRedemptionAlert !== undefined) {
165
+ this._options.appHandlesRedemptionAlert = appHandlesRedemptionAlert;
166
+ }
167
+ return this;
168
+ };
169
+
92
170
  PLYStartBuilder.prototype.start = function (success, error) {
171
+ // Subscribe immediately before the native start call, never earlier. See
172
+ // webRedemptionListener above for why this is deferred to here.
173
+ if (this._webRedemptionCallback) {
174
+ exports.addWebRedemptionListener(this._webRedemptionCallback);
175
+ }
93
176
  if (success) {
94
177
  exports.start(this._options, success, error);
95
178
  return undefined;
@@ -118,6 +201,37 @@ exports.addEventsListener = function (success, error) {
118
201
  exec(success, error, 'Purchasely', 'addEventsListener', []);
119
202
  };
120
203
 
204
+ // Purchasely 6.1.0: the outcome of a Web2App redemption ({scheme}://ply/redeem/{token}).
205
+ //
206
+ // SECONDARY PATH, for replacing the listener while the SDK already runs. Prefer
207
+ // builder(apiKey).webRedemptionListener(cb). Both share ONE native slot: last caller wins.
208
+ //
209
+ // success receives { isSuccess, context, replay, errorCode, errorMessage }:
210
+ // isSuccess Bool.
211
+ // context { subscription } or null, and `subscription` is separately nullable.
212
+ // Same shape as userSubscriptions(), so purchaseToken, nextRenewalDate and
213
+ // cancelledDate may be absent -- Android sends an explicit null, iOS omits
214
+ // the key. A truthiness check covers both; `!== undefined` does not.
215
+ // replay Bool. The SERVER says the token was redeemed before. False on failure.
216
+ // errorCode 'EXPIRED_REDEMPTION_TOKEN' | 'INVALID_REDEMPTION_TOKEN' | null.
217
+ // errorMessage Human-readable, English, or null. Never contains the token.
218
+ //
219
+ // Called on the main thread, exactly once per settled redemption.
220
+ //
221
+ // PRIVACY, BOTH PLATFORMS: errorMessage for an expired link can carry a MASKED EMAIL
222
+ // ADDRESS. Show it to the user; never log it or send it to analytics or a crash reporter.
223
+ // The rule is unconditional -- do NOT gate it on a platform check. The REDEMPTION_FAILED
224
+ // event drops the hint, so that channel is safe.
225
+ exports.addWebRedemptionListener = function (success, error) {
226
+ exec(success, error, 'Purchasely', 'addWebRedemptionListener', []);
227
+ };
228
+
229
+ // Purchasely 6.1.0: stop receiving redemption outcomes. The native delegate stays
230
+ // registered (it is fixed at start()); clearing the callback makes it a no-op.
231
+ exports.removeWebRedemptionListener = function () {
232
+ exec(() => {}, defaultError, 'Purchasely', 'removeWebRedemptionListener', []);
233
+ };
234
+
121
235
  exports.addUserAttributeListener = function(success, error) {
122
236
  exec(success, error, 'Purchasely', 'addUserAttributeListener', []);
123
237
  };
@@ -508,6 +622,11 @@ exports.userDidConsumeSubscriptionContent = function () {
508
622
 
509
623
  // PAR-29: invalidateCache forces a fresh fetch instead of returning the cached list
510
624
  // (native default false on both platforms).
625
+ // A subscription's purchaseToken, nextRenewalDate and cancelledDate can all be ABSENT, and
626
+ // the two platforms report absence differently: Android sends the key with an explicit
627
+ // null, iOS omits it entirely (and never emits purchaseToken at all, because the native
628
+ // PLYSubscription has no such property). Handle both -- a truthiness check covers them,
629
+ // `!== undefined` does not. Same shape as the web redemption context's `subscription`.
511
630
  exports.userSubscriptions = function (success, error, invalidateCache) {
512
631
  exec(success, defaultError, 'Purchasely', 'userSubscriptions', [!!invalidateCache]);
513
632
  };
@@ -731,12 +850,23 @@ exports.PurchaseResult = {
731
850
  RESTORED: 2
732
851
  }
733
852
 
853
+ // Values are the NATIVE raw values, verified against the shipped 6.1.0 artifacts:
854
+ // iOS PLYSubscriptionSource (stripe = 4, none = 5) and Android StoreType ordinals
855
+ // (WEB_CHECKOUT_STRIPE = 4, NONE = 5). Both platforms agree, so there is no
856
+ // per-platform mapping here and no renumbering hazard.
857
+ //
858
+ // `webCheckoutStripe` was missing and `none` was 4, which was correct before the native
859
+ // SDKs inserted the Stripe case at 4 (Android ~5.5.0) and pushed NONE to 5. A Web2App
860
+ // subscription therefore reported `none`, and a genuinely sourceless one reported a value
861
+ // this object had no name for. A Web2App redemption grants a subscription from exactly
862
+ // that source, so `context.subscription` is the payload most likely to carry it.
734
863
  exports.SubscriptionSource = {
735
864
  appleAppStore: 0,
736
865
  googlePlayStore: 1,
737
866
  amazonAppstore: 2,
738
867
  huaweiAppGallery: 3,
739
- none: 4
868
+ webCheckoutStripe: 4,
869
+ none: 5
740
870
  }
741
871
 
742
872
  exports.PlanType = {