@apex-inc/capacitor-plugin 2.4.0 → 2.5.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/README.md +41 -4
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +80 -5
- package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +43 -5
- package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +27 -0
- package/dist/deferred-link.d.ts +73 -0
- package/dist/deferred-link.d.ts.map +1 -0
- package/dist/deferred-link.js +71 -0
- package/dist/deferred-link.js.map +1 -0
- package/dist/definitions.d.ts +76 -4
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/deferred-link.d.ts +73 -0
- package/dist/esm/deferred-link.d.ts.map +1 -0
- package/dist/esm/deferred-link.js +68 -0
- package/dist/esm/deferred-link.js.map +1 -0
- package/dist/esm/definitions.d.ts +76 -4
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/events.d.ts +33 -1
- package/dist/esm/events.d.ts.map +1 -1
- package/dist/esm/events.js +35 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +157 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/referrer-link.d.ts +36 -0
- package/dist/esm/referrer-link.d.ts.map +1 -0
- package/dist/esm/referrer-link.js +103 -0
- package/dist/esm/referrer-link.js.map +1 -0
- package/dist/esm/skan-conversion-values.d.ts +175 -0
- package/dist/esm/skan-conversion-values.d.ts.map +1 -0
- package/dist/esm/skan-conversion-values.js +323 -0
- package/dist/esm/skan-conversion-values.js.map +1 -0
- package/dist/esm/web.d.ts +6 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +32 -4
- package/dist/esm/web.js.map +1 -1
- package/dist/events.d.ts +33 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +35 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +166 -1
- package/dist/index.js.map +1 -1
- package/dist/referrer-link.d.ts +36 -0
- package/dist/referrer-link.d.ts.map +1 -0
- package/dist/referrer-link.js +107 -0
- package/dist/referrer-link.js.map +1 -0
- package/dist/skan-conversion-values.d.ts +175 -0
- package/dist/skan-conversion-values.d.ts.map +1 -0
- package/dist/skan-conversion-values.js +331 -0
- package/dist/skan-conversion-values.js.map +1 -0
- package/dist/web.d.ts +6 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +32 -4
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +43 -5
- package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +16 -2
- package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +18 -4
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +22 -6
- package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +26 -0
- package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +22 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Apex Capacitor plugin — iOS and Android attribution, events, deep linking, SKA
|
|
|
4
4
|
|
|
5
5
|
Ships alongside `apex.js` (the web snippet) so a Capacitor app gets unified identity and events across WebView + native APIs with one dependency pattern.
|
|
6
6
|
|
|
7
|
-
> **Scope.** This plugin is for Capacitor apps. Native iOS (Swift), native Android (Kotlin), React Native, and Flutter apps need their own SDKs — see https://apex.inc/docs/mobile/which-sdk.
|
|
7
|
+
> **Scope.** This plugin is for Capacitor apps. Native iOS (Swift), native Android (Kotlin), React Native, and Flutter apps need their own SDKs — see https://app.apex.inc/docs/mobile/which-sdk.
|
|
8
8
|
|
|
9
9
|
> **Try it in 5 minutes.** The [sample app](https://github.com/apex-incorporated/capacitor-sample-app) is a three-screen Capacitor + React app that exercises every API in this plugin. `git clone`, `npm install`, `npm run dev`, watch events land in your Apex dashboard.
|
|
10
10
|
|
|
@@ -28,6 +28,7 @@ await Apex.initialize({
|
|
|
28
28
|
// apiKey: "apex_sk_…", // see "API keys" below — unlocks verified identity stitching
|
|
29
29
|
// apiUrl: "https://app.apex.inc",
|
|
30
30
|
// autoTrackScreenViews: true, // default — screen_view auto-fires from SPA navigation
|
|
31
|
+
// autoConversionValues: true, // default — Apex drives SKAdNetwork values from Set up Apex (iOS)
|
|
31
32
|
// sessionTimeoutMinutes: 30,
|
|
32
33
|
// offlineQueueMaxSize: 1000,
|
|
33
34
|
// testMode: false,
|
|
@@ -163,25 +164,61 @@ if (!id && fallback === "idfv") {
|
|
|
163
164
|
|
|
164
165
|
## SKAdNetwork conversion values (iOS 4.0+)
|
|
165
166
|
|
|
167
|
+
You do not have to write this code. In the Apex dashboard, **Set up Apex →
|
|
168
|
+
Mobile → Say what Apple's numbers mean** asks one question (which event
|
|
169
|
+
means an install was worth it) and turns it into the full table of what
|
|
170
|
+
Apple's 0–63 values mean for your app. With `autoConversionValues` on
|
|
171
|
+
(the default on iOS), the plugin:
|
|
172
|
+
|
|
173
|
+
- fetches that table on `initialize()`, caches it for a day, and
|
|
174
|
+
revalidates by ETag;
|
|
175
|
+
- calls `SKAdNetwork.updatePostbackConversionValue` whenever `track()`
|
|
176
|
+
sees an event in the table, in the postback window the install is in;
|
|
177
|
+
- only ever moves the value up within a window, and stops after day 35
|
|
178
|
+
when Apple stops listening.
|
|
179
|
+
|
|
180
|
+
With `debug: true` every decision is logged as
|
|
181
|
+
`[apex-capacitor] skan updatePostbackConversionValue(32, high) for purchase in window 1`.
|
|
182
|
+
|
|
183
|
+
Read the table the plugin is acting on:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const { schema, mobileAppId, fetchedAt } = await Apex.getConversionValueSchema();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
If you would rather run your own ladder, call `updateConversionValue`
|
|
190
|
+
yourself. The first manual call turns automatic values off for that
|
|
191
|
+
install — manual always wins — or pass `autoConversionValues: false` at
|
|
192
|
+
`initialize()`.
|
|
193
|
+
|
|
166
194
|
```ts
|
|
167
195
|
await Apex.updateConversionValue({
|
|
168
196
|
fineValue: 42,
|
|
169
197
|
coarseValue: "high",
|
|
198
|
+
lockWindow: false, // true asks Apple to send this window's postback now
|
|
170
199
|
});
|
|
171
200
|
```
|
|
172
201
|
|
|
202
|
+
Android has no SKAdNetwork; both calls are no-ops there.
|
|
203
|
+
|
|
173
204
|
## Deep links
|
|
174
205
|
|
|
175
206
|
```ts
|
|
176
207
|
// Cold start: read the URL that opened the app (if any).
|
|
177
|
-
const { url } = await Apex.getInitialDeepLink();
|
|
208
|
+
const { url, deepLinkHint } = await Apex.getInitialDeepLink();
|
|
178
209
|
|
|
179
210
|
// Warm-start / subsequent links while running:
|
|
180
|
-
await Apex.addListener("deepLink", ({ url }) => {
|
|
181
|
-
handleRoute(url);
|
|
211
|
+
await Apex.addListener("deepLink", ({ url, deepLinkHint }) => {
|
|
212
|
+
handleRoute(url, deepLinkHint);
|
|
182
213
|
});
|
|
183
214
|
```
|
|
184
215
|
|
|
216
|
+
`deepLinkHint` is the value the merchant set on the Apex Link (`apex_dl` on the
|
|
217
|
+
URL), handed over already parsed. It's null when the link didn't carry one. Use
|
|
218
|
+
it when your in-app routes don't look like your web routes — the link can say
|
|
219
|
+
`checkout` and your router can honor that without parsing a web path it doesn't
|
|
220
|
+
recognize. Apex never interprets the value.
|
|
221
|
+
|
|
185
222
|
## Sessions
|
|
186
223
|
|
|
187
224
|
```ts
|
|
@@ -4,6 +4,8 @@ import android.content.Context
|
|
|
4
4
|
import android.content.SharedPreferences
|
|
5
5
|
import android.net.Uri
|
|
6
6
|
import android.os.Build
|
|
7
|
+
import android.os.Handler
|
|
8
|
+
import android.os.Looper
|
|
7
9
|
import android.provider.Settings
|
|
8
10
|
import com.android.installreferrer.api.InstallReferrerClient
|
|
9
11
|
import com.android.installreferrer.api.InstallReferrerStateListener
|
|
@@ -55,6 +57,16 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
55
57
|
private var testMode: Boolean = false
|
|
56
58
|
private var debug: Boolean = false
|
|
57
59
|
private var lastInstallReferrer: String? = null
|
|
60
|
+
|
|
61
|
+
// Deferred deep linking. The Play referrer is the ONLY thing that
|
|
62
|
+
// survives the browser-tap → store → first-launch gap on Android, so
|
|
63
|
+
// `getInstallReferrer` has to be able to answer during first launch
|
|
64
|
+
// rather than returning null because the Play service hasn't
|
|
65
|
+
// connected yet. Calls that arrive before the fetch completes are
|
|
66
|
+
// parked and flushed by the listener.
|
|
67
|
+
private var installReferrerResolved: Boolean = false
|
|
68
|
+
private var installReferrerClickSeconds: Long = 0
|
|
69
|
+
private val pendingReferrerCalls = mutableListOf<PluginCall>()
|
|
58
70
|
// Phase 2-SDK — workspace-bound SDK key forwarded as
|
|
59
71
|
// `x-apex-api-key` on every event POST + on `identify()`.
|
|
60
72
|
private var apiKey: String? = null
|
|
@@ -83,7 +95,9 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
deepLinks.setWarmLinkHandler { url ->
|
|
86
|
-
val data = JSObject()
|
|
98
|
+
val data = JSObject()
|
|
99
|
+
.put("url", url)
|
|
100
|
+
.put("deepLinkHint", DeepLinkManager.apexHint(url) ?: JSONObject.NULL)
|
|
87
101
|
notifyListeners("deepLink", data)
|
|
88
102
|
}
|
|
89
103
|
|
|
@@ -383,8 +397,20 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
383
397
|
|
|
384
398
|
@PluginMethod
|
|
385
399
|
fun getInstallReferrer(call: PluginCall) {
|
|
386
|
-
|
|
387
|
-
|
|
400
|
+
if (installReferrerResolved) {
|
|
401
|
+
resolveInstallReferrer(call)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
// Park the call until the Play service answers. Bounded, because
|
|
405
|
+
// first launch is the most latency-sensitive moment in the app's
|
|
406
|
+
// life and a wedged Play service must never hold up a screen.
|
|
407
|
+
synchronized(pendingReferrerCalls) { pendingReferrerCalls.add(call) }
|
|
408
|
+
Handler(Looper.getMainLooper()).postDelayed({
|
|
409
|
+
val stillPending = synchronized(pendingReferrerCalls) {
|
|
410
|
+
pendingReferrerCalls.remove(call)
|
|
411
|
+
}
|
|
412
|
+
if (stillPending) resolveInstallReferrer(call)
|
|
413
|
+
}, REFERRER_WAIT_MS)
|
|
388
414
|
}
|
|
389
415
|
|
|
390
416
|
@PluginMethod
|
|
@@ -709,6 +735,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
709
735
|
conn.setRequestProperty("x-apex-source", "sdk")
|
|
710
736
|
conn.setRequestProperty("x-apex-visitor-id", visitorId)
|
|
711
737
|
conn.setRequestProperty("x-apex-workspace", workspaceKey)
|
|
738
|
+
conn.setRequestProperty("x-apex-release-channel", releaseChannel)
|
|
712
739
|
apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
|
|
713
740
|
conn.connectTimeout = 10000
|
|
714
741
|
conn.readTimeout = 15000
|
|
@@ -755,12 +782,21 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
755
782
|
*/
|
|
756
783
|
private fun enqueueExposure(experimentId: String, variant: String) {
|
|
757
784
|
val queue = offlineQueue ?: return
|
|
785
|
+
// App version that saw the variant — the mobile equivalent of the web
|
|
786
|
+
// build id. Enables version-adoption curves + version-gating during a
|
|
787
|
+
// staged rollout (Phase 5, 2026-07-20).
|
|
788
|
+
val appVersion = try {
|
|
789
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0.0.0"
|
|
790
|
+
} catch (_: Exception) {
|
|
791
|
+
"0.0.0"
|
|
792
|
+
}
|
|
758
793
|
val payload = mutableMapOf<String, Any?>()
|
|
759
794
|
payload["type"] = "experiment_exposure"
|
|
760
795
|
payload["data"] = mapOf(
|
|
761
796
|
"experiment_id" to experimentId,
|
|
762
797
|
"variant_key" to variant,
|
|
763
798
|
"surface" to "mobile",
|
|
799
|
+
"app_version" to appVersion,
|
|
764
800
|
)
|
|
765
801
|
if (testMode) payload["testMode"] = true
|
|
766
802
|
payload["platform"] = "android"
|
|
@@ -796,7 +832,10 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
796
832
|
@PluginMethod
|
|
797
833
|
fun getInitialDeepLink(call: PluginCall) {
|
|
798
834
|
val url = deepLinks.consumeInitialUrl()
|
|
799
|
-
val
|
|
835
|
+
val hint = url?.let { DeepLinkManager.apexHint(it) }
|
|
836
|
+
val data = JSObject()
|
|
837
|
+
.put("url", url ?: JSONObject.NULL)
|
|
838
|
+
.put("deepLinkHint", hint ?: JSONObject.NULL)
|
|
800
839
|
call.resolve(data)
|
|
801
840
|
}
|
|
802
841
|
|
|
@@ -992,6 +1031,11 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
992
1031
|
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
|
|
993
1032
|
val details = client.installReferrer
|
|
994
1033
|
lastInstallReferrer = details.installReferrer
|
|
1034
|
+
// The tap's own timestamp, not the install's. A
|
|
1035
|
+
// deferred link is only worth honouring while the
|
|
1036
|
+
// user still remembers tapping it, so JS ages the
|
|
1037
|
+
// referrer out against this.
|
|
1038
|
+
installReferrerClickSeconds = details.referrerClickTimestampSeconds
|
|
995
1039
|
}
|
|
996
1040
|
} catch (_: Exception) {
|
|
997
1041
|
// ignore
|
|
@@ -999,13 +1043,36 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
999
1043
|
try {
|
|
1000
1044
|
client.endConnection()
|
|
1001
1045
|
} catch (_: Exception) { }
|
|
1046
|
+
flushPendingReferrerCalls()
|
|
1002
1047
|
}
|
|
1003
1048
|
}
|
|
1004
1049
|
|
|
1005
|
-
|
|
1050
|
+
// A disconnect before the first response means Play will not
|
|
1051
|
+
// answer this launch. Resolve the waiters with what we have
|
|
1052
|
+
// (nothing) rather than leaving them to time out one by one.
|
|
1053
|
+
override fun onInstallReferrerServiceDisconnected() {
|
|
1054
|
+
flushPendingReferrerCalls()
|
|
1055
|
+
}
|
|
1006
1056
|
})
|
|
1007
1057
|
}
|
|
1008
1058
|
|
|
1059
|
+
private fun flushPendingReferrerCalls() {
|
|
1060
|
+
installReferrerResolved = true
|
|
1061
|
+
val waiting = synchronized(pendingReferrerCalls) {
|
|
1062
|
+
val copy = pendingReferrerCalls.toList()
|
|
1063
|
+
pendingReferrerCalls.clear()
|
|
1064
|
+
copy
|
|
1065
|
+
}
|
|
1066
|
+
for (call in waiting) resolveInstallReferrer(call)
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private fun resolveInstallReferrer(call: PluginCall) {
|
|
1070
|
+
val data = JSObject()
|
|
1071
|
+
.put("referrer", lastInstallReferrer ?: JSONObject.NULL)
|
|
1072
|
+
.put("clickTimestamp", installReferrerClickSeconds)
|
|
1073
|
+
call.resolve(data)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1009
1076
|
companion object {
|
|
1010
1077
|
/**
|
|
1011
1078
|
* Weak-ish reference to the live plugin so the FCM messaging
|
|
@@ -1015,6 +1082,14 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
1015
1082
|
@Volatile
|
|
1016
1083
|
var activePlugin: ApexCapacitorPlugin? = null
|
|
1017
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* How long `getInstallReferrer` will wait for Play before
|
|
1087
|
+
* answering with what it has. Google's own samples see the
|
|
1088
|
+
* connection settle in well under a second; past that the user is
|
|
1089
|
+
* staring at a held-back first screen for a nice-to-have.
|
|
1090
|
+
*/
|
|
1091
|
+
private const val REFERRER_WAIT_MS = 2500L
|
|
1092
|
+
|
|
1018
1093
|
/**
|
|
1019
1094
|
* Register a (possibly refreshed) FCM token with Apex from the
|
|
1020
1095
|
* messaging service. Reads the persisted workspaceKey / apiBaseUrl
|
|
@@ -30,6 +30,8 @@ object ApexEvents {
|
|
|
30
30
|
const val CART_SNAPSHOT = "cart_snapshot"
|
|
31
31
|
const val SUBSCRIPTION_STARTED = "subscription_started"
|
|
32
32
|
const val SUBSCRIPTION_CANCELED = "subscription_canceled"
|
|
33
|
+
const val SUBSCRIPTION_UPGRADED = "subscription_upgraded"
|
|
34
|
+
const val SUBSCRIPTION_DOWNGRADED = "subscription_downgraded"
|
|
33
35
|
const val PAYMENT_FAILED = "payment_failed"
|
|
34
36
|
const val PAYMENT_RECOVERED = "payment_recovered"
|
|
35
37
|
const val IN_APP_PURCHASE = "in_app_purchase"
|
|
@@ -45,6 +47,10 @@ object ApexEvents {
|
|
|
45
47
|
|
|
46
48
|
// ─── Identity ───────────────────────────────────────────────────────
|
|
47
49
|
const val USER_SIGNED_UP = "user_signed_up"
|
|
50
|
+
const val WAITLIST_JOINED = "waitlist_joined"
|
|
51
|
+
const val USER_INVITED = "user_invited"
|
|
52
|
+
const val USER_INVITE_ACCEPTED = "user_invite_accepted"
|
|
53
|
+
const val PASSWORD_RESET_REQUESTED = "password_reset_requested"
|
|
48
54
|
const val USER_SIGNED_IN = "user_signed_in"
|
|
49
55
|
const val USER_SIGNED_OUT = "user_signed_out"
|
|
50
56
|
const val USER_IDENTIFIED = "user_identified"
|
|
@@ -71,6 +77,7 @@ object ApexEvents {
|
|
|
71
77
|
const val CONTENT_VIEW = "content_view"
|
|
72
78
|
const val FORM_SUBMIT = "form_submit"
|
|
73
79
|
const val CLICK = "click"
|
|
80
|
+
const val UI_ACTION = "ui_action"
|
|
74
81
|
const val ENGAGEMENT = "engagement"
|
|
75
82
|
const val HEARTBEAT = "heartbeat"
|
|
76
83
|
const val RAGE_CLICK = "rage_click"
|
|
@@ -111,6 +118,8 @@ object ApexEvents {
|
|
|
111
118
|
// ─── Fulfillment ────────────────────────────────────────────────────
|
|
112
119
|
const val ORDER_SHIPPED = "order_shipped"
|
|
113
120
|
const val ORDER_DELIVERED = "order_delivered"
|
|
121
|
+
const val RETURN_REQUESTED = "return_requested"
|
|
122
|
+
const val RETURN_COMPLETED = "return_completed"
|
|
114
123
|
|
|
115
124
|
// ─── Engagement (vertical additions) ────────────────────────────────
|
|
116
125
|
const val FEATURE_USED = "feature_used"
|
|
@@ -124,6 +133,13 @@ object ApexEvents {
|
|
|
124
133
|
const val BUYER_REVIEW_REQUESTED = "buyer_review_requested"
|
|
125
134
|
const val PAYOUT_SENT = "payout_sent"
|
|
126
135
|
const val TRUST_BADGE_EARNED = "trust_badge_earned"
|
|
136
|
+
const val SELLER_VIEWED = "seller_viewed"
|
|
137
|
+
const val ITEM_SAVED = "item_saved"
|
|
138
|
+
const val INQUIRY_STARTED = "inquiry_started"
|
|
139
|
+
const val MATCH_CREATED = "match_created"
|
|
140
|
+
const val TRANSACTION_CANCELED = "transaction_canceled"
|
|
141
|
+
const val DISPUTE_OPENED = "dispute_opened"
|
|
142
|
+
const val REVIEW_SUBMITTED = "review_submitted"
|
|
127
143
|
|
|
128
144
|
// ─── Agency ─────────────────────────────────────────────────────────
|
|
129
145
|
const val CLIENT_ONBOARDED = "client_onboarded"
|
|
@@ -136,18 +152,36 @@ object ApexEvents {
|
|
|
136
152
|
const val USAGE_MILESTONE_REACHED = "usage_milestone_reached"
|
|
137
153
|
const val USER_CHURNED = "user_churned"
|
|
138
154
|
const val USER_REACTIVATED = "user_reactivated"
|
|
155
|
+
const val TRIAL_EXPIRING = "trial_expiring"
|
|
139
156
|
const val TRIAL_EXPIRED = "trial_expired"
|
|
140
157
|
const val INVOICE_PAID = "invoice_paid"
|
|
141
158
|
const val SUBSCRIPTION_RENEWED = "subscription_renewed"
|
|
142
159
|
|
|
160
|
+
// Engagement suite (User Lifecycle)
|
|
161
|
+
const val REPORT_READY = "report_ready"
|
|
162
|
+
const val REPORT_VIEWED = "report_viewed"
|
|
163
|
+
const val CONTENT_VIEWED = "content_viewed"
|
|
164
|
+
const val TASK_COMPLETED = "task_completed"
|
|
165
|
+
const val API_REQUEST_COMPLETED = "api_request_completed"
|
|
166
|
+
const val PROJECT_CREATED = "project_created"
|
|
167
|
+
const val LISTING_VIEWED = "listing_viewed"
|
|
168
|
+
const val MESSAGE_SENT = "message_sent"
|
|
169
|
+
const val OFFER_SUBMITTED = "offer_submitted"
|
|
170
|
+
const val TRANSACTION_COMPLETED = "transaction_completed"
|
|
171
|
+
const val TASK_UPDATED = "task_updated"
|
|
172
|
+
const val ASSET_UPLOADED = "asset_uploaded"
|
|
173
|
+
const val COMMENT_ADDED = "comment_added"
|
|
174
|
+
const val TIME_LOGGED = "time_logged"
|
|
175
|
+
const val CLIENT_REPORT_VIEWED = "client_report_viewed"
|
|
176
|
+
|
|
143
177
|
/** Apex Spec semver this SDK implements. */
|
|
144
|
-
const val SPEC_VERSION = "1.
|
|
178
|
+
const val SPEC_VERSION = "1.5.0"
|
|
145
179
|
|
|
146
180
|
/** Complete list of every canonical event name. */
|
|
147
181
|
val ALL: List<String> = listOf(
|
|
148
182
|
ADD_TO_CART, REMOVE_FROM_CART, PRODUCT_VIEW, ADD_TO_WISHLIST, CHECKOUT_STARTED,
|
|
149
|
-
IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT,
|
|
150
|
-
USER_SIGNED_UP, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
|
|
183
|
+
IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT, SUBSCRIPTION_UPGRADED, SUBSCRIPTION_DOWNGRADED,
|
|
184
|
+
USER_SIGNED_UP, WAITLIST_JOINED, USER_INVITED, USER_INVITE_ACCEPTED, PASSWORD_RESET_REQUESTED, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
|
|
151
185
|
APP_INSTALL, APP_OPEN, APP_BACKGROUND, APP_UNINSTALL, APP_REINSTALL,
|
|
152
186
|
SESSION_START, SESSION_END,
|
|
153
187
|
PAGE_VIEW, PAGEVIEW, SCREEN_VIEW, SEARCH, SHARE, CONTENT_VIEW, FORM_SUBMIT,
|
|
@@ -160,11 +194,15 @@ object ApexEvents {
|
|
|
160
194
|
REATTRIBUTION, REENGAGEMENT,
|
|
161
195
|
SMART_BANNER_IMPRESSION, SMART_BANNER_CLICK, SMART_BANNER_DISMISS,
|
|
162
196
|
CUSTOM, LIFECYCLE_TRANSITION, JS_ERROR,
|
|
163
|
-
ORDER_SHIPPED, ORDER_DELIVERED,
|
|
197
|
+
ORDER_SHIPPED, ORDER_DELIVERED, RETURN_REQUESTED, RETURN_COMPLETED,
|
|
164
198
|
FEATURE_USED, WEBINAR_REGISTERED, SURVEY_STARTED, SURVEY_COMPLETED,
|
|
165
199
|
SELLER_SIGNUP, LISTING_CREATED, BUYER_REVIEW_REQUESTED, PAYOUT_SENT, TRUST_BADGE_EARNED,
|
|
200
|
+
SELLER_VIEWED, ITEM_SAVED, INQUIRY_STARTED, MATCH_CREATED, TRANSACTION_CANCELED, DISPUTE_OPENED, REVIEW_SUBMITTED,
|
|
166
201
|
CLIENT_ONBOARDED, CAMPAIGN_MILESTONE_REACHED, CONTRACT_RENEWAL_APPROACHING, NEW_CAPABILITY_LAUNCHED,
|
|
167
202
|
LOYALTY_MILESTONE_REACHED, USAGE_MILESTONE_REACHED, USER_CHURNED, USER_REACTIVATED,
|
|
168
|
-
TRIAL_EXPIRED, INVOICE_PAID, SUBSCRIPTION_RENEWED,
|
|
203
|
+
TRIAL_EXPIRING, TRIAL_EXPIRED, INVOICE_PAID, SUBSCRIPTION_RENEWED,
|
|
204
|
+
REPORT_READY, REPORT_VIEWED, CONTENT_VIEWED, TASK_COMPLETED, API_REQUEST_COMPLETED, PROJECT_CREATED,
|
|
205
|
+
LISTING_VIEWED, MESSAGE_SENT, OFFER_SUBMITTED, TRANSACTION_COMPLETED, TASK_UPDATED,
|
|
206
|
+
ASSET_UPLOADED, COMMENT_ADDED, TIME_LOGGED, CLIENT_REPORT_VIEWED,
|
|
169
207
|
)
|
|
170
208
|
}
|
|
@@ -35,6 +35,33 @@ class DeepLinkManager {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
companion object {
|
|
38
|
+
/**
|
|
39
|
+
* The `apex_dl` value an Apex Link carried, or null when it had none.
|
|
40
|
+
*
|
|
41
|
+
* Apex appends this on mobile clicks as a pass-through hint the merchant
|
|
42
|
+
* set on the link. Surfaced already-parsed so the app doesn't have to
|
|
43
|
+
* pick apart a query string; never interpreted here. Hand-parsed rather
|
|
44
|
+
* than via `Uri.parse` so this class stays pure Kotlin and testable off
|
|
45
|
+
* an Android device.
|
|
46
|
+
*/
|
|
47
|
+
fun apexHint(url: String): String? {
|
|
48
|
+
val query = url.substringAfter('?', "").substringBefore('#')
|
|
49
|
+
if (query.isEmpty()) return null
|
|
50
|
+
for (pair in query.split('&')) {
|
|
51
|
+
val key = pair.substringBefore('=')
|
|
52
|
+
if (key != "apex_dl") continue
|
|
53
|
+
val raw = pair.substringAfter('=', "")
|
|
54
|
+
if (raw.isEmpty()) return null
|
|
55
|
+
val decoded = try {
|
|
56
|
+
java.net.URLDecoder.decode(raw, "UTF-8")
|
|
57
|
+
} catch (_: Exception) {
|
|
58
|
+
raw
|
|
59
|
+
}
|
|
60
|
+
return decoded.ifEmpty { null }
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
/** Is this a sensible deep link? Reject `file://` and empty schemes. */
|
|
39
66
|
fun isValidDeepLink(url: String): Boolean {
|
|
40
67
|
if (url.isBlank()) return false
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred deep-link resolution.
|
|
3
|
+
*
|
|
4
|
+
* The problem this solves: a user taps a link in Safari or Chrome, does
|
|
5
|
+
* not have your app, installs it from the store, and opens it. The tap
|
|
6
|
+
* and the launch share no cookie and no storage, so by default your app
|
|
7
|
+
* has no idea the user was headed for a specific screen and drops them
|
|
8
|
+
* on your home screen. The tap that cost you money lands nowhere.
|
|
9
|
+
*
|
|
10
|
+
* Apex matches the launch back to the tap server-side and hands you the
|
|
11
|
+
* destination. Call it once, early, and route on the result:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const link = await Apex.getDeferredDeepLink();
|
|
15
|
+
* if (link) router.navigateByUrl(link.deepLinkHint ?? new URL(link.url).pathname);
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Two properties worth knowing before you build on it:
|
|
19
|
+
*
|
|
20
|
+
* - **One-shot.** The match key is coarse (network + OS), so a link can
|
|
21
|
+
* only be claimed once. Call this on first launch and persist what
|
|
22
|
+
* you get; a second call returns null.
|
|
23
|
+
* - **Best-effort.** Matching is probabilistic and returns null more
|
|
24
|
+
* often than it returns a link — no network address, a VPN between
|
|
25
|
+
* tap and install, or a slow install all produce a miss. Treat a
|
|
26
|
+
* link as a bonus that improves the first run, never as a
|
|
27
|
+
* precondition for it.
|
|
28
|
+
*
|
|
29
|
+
* Implemented in TypeScript rather than in the native bridges because it
|
|
30
|
+
* is one HTTPS call with no platform APIs involved, and the webview can
|
|
31
|
+
* make it identically on iOS, Android, and in browser preview.
|
|
32
|
+
*/
|
|
33
|
+
export interface DeferredDeepLinkResult {
|
|
34
|
+
/**
|
|
35
|
+
* The destination the tap was headed for, with Apex's own routing
|
|
36
|
+
* params (`apex_dl`, `apex_link`) already stripped.
|
|
37
|
+
*/
|
|
38
|
+
url: string;
|
|
39
|
+
/**
|
|
40
|
+
* The link's `apex_dl` value — whatever screen identifier you put on
|
|
41
|
+
* the link in Apex. Null when the link carried none.
|
|
42
|
+
*/
|
|
43
|
+
deepLinkHint: string | null;
|
|
44
|
+
/** Apex Link slug the tap came through, when it came through one. */
|
|
45
|
+
slug?: string;
|
|
46
|
+
/** Affiliate handle, for namespaced partner links. */
|
|
47
|
+
handle?: string;
|
|
48
|
+
source?: string;
|
|
49
|
+
campaign?: string;
|
|
50
|
+
/** ISO timestamp of the original tap. */
|
|
51
|
+
clickedAt: string;
|
|
52
|
+
/**
|
|
53
|
+
* How the launch was matched to the tap.
|
|
54
|
+
*
|
|
55
|
+
* `deterministic` means Google Play handed us the exact link the user
|
|
56
|
+
* tapped, via the install referrer — there is no guesswork in it.
|
|
57
|
+
* `probabilistic` means we matched on network address and OS, which is
|
|
58
|
+
* the best available on iOS and can, on a shared network, be wrong.
|
|
59
|
+
* Route on either; only gate irreversible actions (granting a credit,
|
|
60
|
+
* applying a referral bonus) on `deterministic`.
|
|
61
|
+
*/
|
|
62
|
+
matchType: "deterministic" | "probabilistic";
|
|
63
|
+
}
|
|
64
|
+
export interface FetchDeferredDeepLinkOptions {
|
|
65
|
+
apiUrl: string;
|
|
66
|
+
workspaceKey: string;
|
|
67
|
+
platform: "ios" | "android";
|
|
68
|
+
apiKey?: string;
|
|
69
|
+
/** Abort the call after this many ms. Defaults to 5000. */
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
}
|
|
72
|
+
export declare function fetchDeferredDeepLink(options: FetchDeferredDeepLinkOptions): Promise<DeferredDeepLinkResult | null>;
|
|
73
|
+
//# sourceMappingURL=deferred-link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deferred-link.d.ts","sourceRoot":"","sources":["../src/deferred-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,SAAS,EAAE,eAAe,GAAG,eAAe,CAAC;CAC9C;AAED,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,KAAK,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAwCxC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deferred deep-link resolution.
|
|
4
|
+
*
|
|
5
|
+
* The problem this solves: a user taps a link in Safari or Chrome, does
|
|
6
|
+
* not have your app, installs it from the store, and opens it. The tap
|
|
7
|
+
* and the launch share no cookie and no storage, so by default your app
|
|
8
|
+
* has no idea the user was headed for a specific screen and drops them
|
|
9
|
+
* on your home screen. The tap that cost you money lands nowhere.
|
|
10
|
+
*
|
|
11
|
+
* Apex matches the launch back to the tap server-side and hands you the
|
|
12
|
+
* destination. Call it once, early, and route on the result:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const link = await Apex.getDeferredDeepLink();
|
|
16
|
+
* if (link) router.navigateByUrl(link.deepLinkHint ?? new URL(link.url).pathname);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Two properties worth knowing before you build on it:
|
|
20
|
+
*
|
|
21
|
+
* - **One-shot.** The match key is coarse (network + OS), so a link can
|
|
22
|
+
* only be claimed once. Call this on first launch and persist what
|
|
23
|
+
* you get; a second call returns null.
|
|
24
|
+
* - **Best-effort.** Matching is probabilistic and returns null more
|
|
25
|
+
* often than it returns a link — no network address, a VPN between
|
|
26
|
+
* tap and install, or a slow install all produce a miss. Treat a
|
|
27
|
+
* link as a bonus that improves the first run, never as a
|
|
28
|
+
* precondition for it.
|
|
29
|
+
*
|
|
30
|
+
* Implemented in TypeScript rather than in the native bridges because it
|
|
31
|
+
* is one HTTPS call with no platform APIs involved, and the webview can
|
|
32
|
+
* make it identically on iOS, Android, and in browser preview.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.fetchDeferredDeepLink = fetchDeferredDeepLink;
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
37
|
+
async function fetchDeferredDeepLink(options) {
|
|
38
|
+
const { apiUrl, workspaceKey, platform, apiKey } = options;
|
|
39
|
+
if (!workspaceKey)
|
|
40
|
+
return null;
|
|
41
|
+
// First launch is the most latency-sensitive moment in the app's life.
|
|
42
|
+
// A slow or unreachable Apex must never hold up the first screen, so
|
|
43
|
+
// the call is bounded and every failure resolves to null.
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
46
|
+
try {
|
|
47
|
+
const headers = {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"x-apex-workspace": workspaceKey,
|
|
50
|
+
};
|
|
51
|
+
if (apiKey)
|
|
52
|
+
headers["x-apex-api-key"] = apiKey;
|
|
53
|
+
const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/api/mobile/deferred-link`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers,
|
|
56
|
+
body: JSON.stringify({ workspaceKey, platform }),
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok)
|
|
60
|
+
return null;
|
|
61
|
+
const payload = (await response.json());
|
|
62
|
+
return payload.data ?? null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=deferred-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deferred-link.js","sourceRoot":"","sources":["../src/deferred-link.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;;AA6CH,sDA0CC;AA5CD,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEzB,KAAK,UAAU,qBAAqB,CACzC,OAAqC;IAErC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC3D,IAAI,CAAC,YAAY;QAAE,OAAO,IAAI,CAAC;IAE/B,uEAAuE;IACvE,qEAAqE;IACrE,0DAA0D;IAC1D,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,UAAU,CACtB,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EACxB,OAAO,CAAC,SAAS,IAAI,kBAAkB,CACxC,CAAC;IAEF,IAAI,CAAC;QACH,MAAM,OAAO,GAA2B;YACtC,cAAc,EAAE,kBAAkB;YAClC,kBAAkB,EAAE,YAAY;SACjC,CAAC;QACF,IAAI,MAAM;YAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,MAAM,CAAC;QAE/C,MAAM,QAAQ,GAAG,MAAM,KAAK,CAC1B,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,2BAA2B,EACxD;YACE,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,YAAY,EAAE,QAAQ,EAAE,CAAC;YAChD,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CACF,CAAC;QAEF,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC;QAC9B,MAAM,OAAO,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAErC,CAAC;QACF,OAAO,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC"}
|