@purchasely/cordova-plugin-purchasely 6.0.0 → 6.1.0

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 ADDED
@@ -0,0 +1,77 @@
1
+ // swift-tools-version:5.9
2
+ //
3
+ // Swift Package Manager manifest for the iOS plugin.
4
+ //
5
+ // `plugin.xml` marks the ios platform with package="swift", which makes two hosts read
6
+ // this file instead of building the plugin through CocoaPods:
7
+ //
8
+ // * Cordova iOS 8 and later (Apache's own mechanism)
9
+ // * Capacitor in SPM mode, which is the Capacitor 8 default
10
+ //
11
+ // Capacitor is the reason this exists. Its SPM path never reads <podspec>, so before this
12
+ // manifest a Capacitor 8 app scaffolded with the default package manager linked the plugin
13
+ // with no Purchasely SDK behind it at all, silently.
14
+ //
15
+ // The <podspec> block stays in plugin.xml for CocoaPods consumers: Cordova iOS 7 and
16
+ // earlier, and Capacitor in CocoaPods mode. Its pod carries nospm="true" so Cordova iOS 8
17
+ // does not install the pod AND this package and link Purchasely twice.
18
+ //
19
+ // KEEP THE FIRST DEPENDENCY EXACTLY AS WRITTEN, AND ADD NO PROSE ABOVE IT.
20
+ // The Capacitor CLI rewrites this file in place on every sync
21
+ // (@capacitor/cli, dist/ios/update.js:83-89). It rewrites the first occurrence of the
22
+ // Apache org slug, every occurrence of the upstream repository slug, and then pins the
23
+ // version by replacing everything between the rewritten URL and the next ")". Both slugs
24
+ // are lowercase and hyphenated, and both first appear in the Cordova dependency below.
25
+ // Writing either one in a comment above that line silently retargets the URL to a
26
+ // repository that does not exist, and the plugin then builds with no SDK. The rewrite is
27
+ // idempotent, and this comment is deliberately written without those two literals.
28
+ //
29
+ // Note for local development: npm installs a `file:` dependency as a symlink, so a
30
+ // `npx cap sync` in a sample app that consumes this plugin by path rewrites THIS file in
31
+ // the repository, not a copy. Check `git status` after syncing a local sample.
32
+ //
33
+ // The package and product names must equal the npm package id: the Capacitor CLI hardcodes
34
+ // `plugin.id` on both sides of the generated dependency (dist/util/spm.js:110-112, :164).
35
+
36
+ import PackageDescription
37
+
38
+ let package = Package(
39
+ name: "@purchasely/cordova-plugin-purchasely",
40
+ // 13.0, not higher: Cordova iOS 8 builds its aggregate CordovaPlugins target at 13.0,
41
+ // and a package declaring more fails the build outright ("requires minimum platform
42
+ // version 15.0 ... but this target supports 13.0"). The Purchasely xcframework itself
43
+ // is built for iOS 13.4, so that floor still applies at link time, exactly as it does
44
+ // through the podspec.
45
+ platforms: [.iOS(.v13)],
46
+ products: [
47
+ .library(
48
+ name: "@purchasely/cordova-plugin-purchasely",
49
+ targets: ["PurchaselyCordovaPlugin"]
50
+ )
51
+ ],
52
+ dependencies: [
53
+ .package(url: "https://github.com/apache/cordova-ios.git", from: "8.0.0"),
54
+ // exact, not `from:`. A range permits any 6.x, so a fresh resolve picks up
55
+ // whatever is newest while the podspec pins one version and VERSIONS.md
56
+ // documents another. That makes the linked SDK depend on install time and lets
57
+ // the two integration paths drift apart. Bump this with the podspec, in the
58
+ // same commit, and VERSIONS.md with it.
59
+ .package(url: "https://github.com/Purchasely/Purchasely-iOS.git", exact: "6.1.0")
60
+ ],
61
+ targets: [
62
+ .target(
63
+ name: "PurchaselyCordovaPlugin",
64
+ dependencies: [
65
+ .product(name: "Cordova", package: "cordova-ios"),
66
+ .product(name: "Purchasely", package: "Purchasely-iOS")
67
+ ],
68
+ path: "src/ios",
69
+ publicHeadersPath: ".",
70
+ cSettings: [
71
+ // The Hybrid/ type-marshalling headers are imported unqualified from the
72
+ // sources next to them, so their directory has to be on the search path.
73
+ .headerSearchPath("Hybrid")
74
+ ]
75
+ )
76
+ ]
77
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purchasely/cordova-plugin-purchasely",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
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",
@@ -41,6 +41,12 @@
41
41
  "author": "Purchasely",
42
42
  "license": "ISC",
43
43
  "main": "index.js",
44
+ "files": [
45
+ "src",
46
+ "www",
47
+ "plugin.xml",
48
+ "Package.swift"
49
+ ],
44
50
  "directories": {
45
51
  "example": "example"
46
52
  },
@@ -57,5 +63,8 @@
57
63
  "bugs": {
58
64
  "url": "https://github.com/Purchasely/Purchasely-Cordova/issues"
59
65
  },
60
- "homepage": "https://github.com/Purchasely/Purchasely-Cordova#readme"
66
+ "homepage": "https://github.com/Purchasely/Purchasely-Cordova#readme",
67
+ "overrides": {
68
+ "js-yaml@3": "^3.15.1"
69
+ }
61
70
  }
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.0" 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.0" 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" />
@@ -8,7 +8,7 @@
8
8
  <engine name="cordova" version=">=11.0.0"/>
9
9
  <engine name="cordova-android" version=">=12.0.0"/>
10
10
  </engines>
11
- <platform name="ios">
11
+ <platform name="ios" package="swift">
12
12
  <config-file parent="/*" target="config.xml">
13
13
  <feature name="Purchasely">
14
14
  <param name="ios-package" value="CDVPurchasely" />
@@ -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.0"/>
41
+ <pod name="Purchasely" spec="6.1.0" 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.1" />
56
+ <framework src="io.purchasely:core:6.1.0" />
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