@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 +77 -0
- package/package.json +11 -2
- package/plugin.xml +4 -4
- package/src/android/PurchaselyPlugin.kt +366 -36
- package/src/ios/CDVPurchasely+Events.h +1 -1
- package/src/ios/CDVPurchasely+Events.m +34 -0
- package/src/ios/CDVPurchasely.h +57 -0
- package/src/ios/CDVPurchasely.m +194 -3
- package/www/Purchasely.js +132 -2
- package/__tests__/Purchasely.test.js +0 -1826
- package/example/IOS_8_1_1_TEST_MATRIX.md +0 -25
- package/example/android.sh +0 -10
- package/example/config.xml +0 -27
- package/example/e2e/README.md +0 -44
- package/example/e2e/helpers/driver.js +0 -187
- package/example/e2e/package.json +0 -19
- package/example/e2e/specs/bridge.e2e.js +0 -154
- package/example/e2e/specs/dismiss.e2e.js +0 -57
- package/example/e2e/tools/ci_run_e2e.sh +0 -63
- package/example/e2e/tools/ci_run_e2e_ios.sh +0 -56
- package/example/e2e/wdio.android.conf.js +0 -24
- package/example/e2e/wdio.ios.conf.js +0 -44
- package/example/e2e/wdio.shared.conf.js +0 -28
- package/example/ios.sh +0 -17
- package/example/package-lock.json +0 -859
- package/example/package.json +0 -37
- package/example/www/css/index.css +0 -110
- package/example/www/img/logo.png +0 -0
- package/example/www/index.html +0 -78
- package/example/www/js/index.js +0 -453
- package/purchasely.iml +0 -11
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|