@apex-inc/capacitor-plugin 2.4.1 → 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 +11 -4
- 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 +8 -1
- package/dist/esm/events.d.ts.map +1 -1
- package/dist/esm/events.js +8 -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 +26 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/events.d.ts +8 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +8 -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 +26 -3
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +11 -4
- 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
|
|
@@ -47,6 +47,10 @@ object ApexEvents {
|
|
|
47
47
|
|
|
48
48
|
// ─── Identity ───────────────────────────────────────────────────────
|
|
49
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"
|
|
50
54
|
const val USER_SIGNED_IN = "user_signed_in"
|
|
51
55
|
const val USER_SIGNED_OUT = "user_signed_out"
|
|
52
56
|
const val USER_IDENTIFIED = "user_identified"
|
|
@@ -73,6 +77,7 @@ object ApexEvents {
|
|
|
73
77
|
const val CONTENT_VIEW = "content_view"
|
|
74
78
|
const val FORM_SUBMIT = "form_submit"
|
|
75
79
|
const val CLICK = "click"
|
|
80
|
+
const val UI_ACTION = "ui_action"
|
|
76
81
|
const val ENGAGEMENT = "engagement"
|
|
77
82
|
const val HEARTBEAT = "heartbeat"
|
|
78
83
|
const val RAGE_CLICK = "rage_click"
|
|
@@ -147,11 +152,13 @@ object ApexEvents {
|
|
|
147
152
|
const val USAGE_MILESTONE_REACHED = "usage_milestone_reached"
|
|
148
153
|
const val USER_CHURNED = "user_churned"
|
|
149
154
|
const val USER_REACTIVATED = "user_reactivated"
|
|
155
|
+
const val TRIAL_EXPIRING = "trial_expiring"
|
|
150
156
|
const val TRIAL_EXPIRED = "trial_expired"
|
|
151
157
|
const val INVOICE_PAID = "invoice_paid"
|
|
152
158
|
const val SUBSCRIPTION_RENEWED = "subscription_renewed"
|
|
153
159
|
|
|
154
160
|
// Engagement suite (User Lifecycle)
|
|
161
|
+
const val REPORT_READY = "report_ready"
|
|
155
162
|
const val REPORT_VIEWED = "report_viewed"
|
|
156
163
|
const val CONTENT_VIEWED = "content_viewed"
|
|
157
164
|
const val TASK_COMPLETED = "task_completed"
|
|
@@ -168,13 +175,13 @@ object ApexEvents {
|
|
|
168
175
|
const val CLIENT_REPORT_VIEWED = "client_report_viewed"
|
|
169
176
|
|
|
170
177
|
/** Apex Spec semver this SDK implements. */
|
|
171
|
-
const val SPEC_VERSION = "1.
|
|
178
|
+
const val SPEC_VERSION = "1.5.0"
|
|
172
179
|
|
|
173
180
|
/** Complete list of every canonical event name. */
|
|
174
181
|
val ALL: List<String> = listOf(
|
|
175
182
|
ADD_TO_CART, REMOVE_FROM_CART, PRODUCT_VIEW, ADD_TO_WISHLIST, CHECKOUT_STARTED,
|
|
176
183
|
IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT, SUBSCRIPTION_UPGRADED, SUBSCRIPTION_DOWNGRADED,
|
|
177
|
-
USER_SIGNED_UP, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
|
|
184
|
+
USER_SIGNED_UP, WAITLIST_JOINED, USER_INVITED, USER_INVITE_ACCEPTED, PASSWORD_RESET_REQUESTED, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
|
|
178
185
|
APP_INSTALL, APP_OPEN, APP_BACKGROUND, APP_UNINSTALL, APP_REINSTALL,
|
|
179
186
|
SESSION_START, SESSION_END,
|
|
180
187
|
PAGE_VIEW, PAGEVIEW, SCREEN_VIEW, SEARCH, SHARE, CONTENT_VIEW, FORM_SUBMIT,
|
|
@@ -193,8 +200,8 @@ object ApexEvents {
|
|
|
193
200
|
SELLER_VIEWED, ITEM_SAVED, INQUIRY_STARTED, MATCH_CREATED, TRANSACTION_CANCELED, DISPUTE_OPENED, REVIEW_SUBMITTED,
|
|
194
201
|
CLIENT_ONBOARDED, CAMPAIGN_MILESTONE_REACHED, CONTRACT_RENEWAL_APPROACHING, NEW_CAPABILITY_LAUNCHED,
|
|
195
202
|
LOYALTY_MILESTONE_REACHED, USAGE_MILESTONE_REACHED, USER_CHURNED, USER_REACTIVATED,
|
|
196
|
-
TRIAL_EXPIRED, INVOICE_PAID, SUBSCRIPTION_RENEWED,
|
|
197
|
-
REPORT_VIEWED, CONTENT_VIEWED, TASK_COMPLETED, API_REQUEST_COMPLETED, PROJECT_CREATED,
|
|
203
|
+
TRIAL_EXPIRING, TRIAL_EXPIRED, INVOICE_PAID, SUBSCRIPTION_RENEWED,
|
|
204
|
+
REPORT_READY, REPORT_VIEWED, CONTENT_VIEWED, TASK_COMPLETED, API_REQUEST_COMPLETED, PROJECT_CREATED,
|
|
198
205
|
LISTING_VIEWED, MESSAGE_SENT, OFFER_SUBMITTED, TRANSACTION_COMPLETED, TASK_UPDATED,
|
|
199
206
|
ASSET_UPLOADED, COMMENT_ADDED, TIME_LOGGED, CLIENT_REPORT_VIEWED,
|
|
200
207
|
)
|
|
@@ -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"}
|
package/dist/definitions.d.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* See `plans/mobile-measurement-platform.md` Phase 1b for scope and rationale.
|
|
11
11
|
*/
|
|
12
12
|
import type { PluginListenerHandle } from "@capacitor/core";
|
|
13
|
+
import type { DeferredDeepLinkResult } from "./deferred-link";
|
|
14
|
+
import type { SkanSchemaSnapshot } from "./skan-conversion-values";
|
|
15
|
+
export type { DeferredDeepLinkResult } from "./deferred-link";
|
|
16
|
+
export type { SkanConversionSchema, SkanConversionValueEntry, SkanConversionWindow, SkanSchemaSnapshot, SkanWindow, } from "./skan-conversion-values";
|
|
13
17
|
/** iOS App Tracking Transparency authorization status. */
|
|
14
18
|
export type AttStatus = "authorized" | "denied" | "restricted" | "not-determined";
|
|
15
19
|
/** User-notification permission state returned by `registerForPushNotifications`. */
|
|
@@ -227,6 +231,25 @@ export interface InitializeOptions {
|
|
|
227
231
|
* server stamps `releaseChannelSource: "auto"` regardless.
|
|
228
232
|
*/
|
|
229
233
|
releaseChannel?: ReleaseChannel;
|
|
234
|
+
/**
|
|
235
|
+
* Mobile Measurement P2 — let Apex drive SKAdNetwork conversion values.
|
|
236
|
+
*
|
|
237
|
+
* When true (the default on iOS), the plugin fetches what Apple's 0–63
|
|
238
|
+
* numbers mean for this app — the table Set up Apex derives from "which
|
|
239
|
+
* event means an install was worth it?" — caches it for a day, and
|
|
240
|
+
* calls `updatePostbackConversionValue` whenever `track()` sees an
|
|
241
|
+
* event in that table. The first manual `updateConversionValue` call
|
|
242
|
+
* switches this off for the install, so a hand-rolled ladder always
|
|
243
|
+
* wins. No-op on Android and web.
|
|
244
|
+
*
|
|
245
|
+
* Set false to keep full manual control.
|
|
246
|
+
*/
|
|
247
|
+
autoConversionValues?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Which registered app's conversion value table to use, when the
|
|
250
|
+
* workspace has more than one. Defaults to the workspace's first app.
|
|
251
|
+
*/
|
|
252
|
+
mobileAppId?: string;
|
|
230
253
|
}
|
|
231
254
|
/**
|
|
232
255
|
* Options for {@link ApexCapacitorPlugin.identify}.
|
|
@@ -377,11 +400,19 @@ export interface ApexCapacitorPlugin {
|
|
|
377
400
|
*/
|
|
378
401
|
getAdvertisingId(): Promise<AdvertisingIdResult>;
|
|
379
402
|
/**
|
|
380
|
-
* Android: returns decoded Play Install Referrer string
|
|
381
|
-
*
|
|
403
|
+
* Android: returns the decoded Play Install Referrer string, waiting
|
|
404
|
+
* briefly for Play to connect if the call arrives during first launch.
|
|
405
|
+
* `clickTimestamp` is the Unix-seconds time of the original ad tap
|
|
406
|
+
* (0 when Play didn't report one), which is what makes a stale
|
|
407
|
+
* referrer detectable — Play keeps the string forever.
|
|
408
|
+
*
|
|
409
|
+
* iOS: returns `{ referrer: null, clickTimestamp: 0 }`. The App Store
|
|
410
|
+
* forwards nothing across an install, which is why iOS deferred deep
|
|
411
|
+
* linking has to fall back to a probabilistic server match.
|
|
382
412
|
*/
|
|
383
413
|
getInstallReferrer(): Promise<{
|
|
384
414
|
referrer: string | null;
|
|
415
|
+
clickTimestamp: number;
|
|
385
416
|
}>;
|
|
386
417
|
/** Gets the plugin-managed visitor ID. */
|
|
387
418
|
getVisitorId(): Promise<{
|
|
@@ -411,20 +442,55 @@ export interface ApexCapacitorPlugin {
|
|
|
411
442
|
/**
|
|
412
443
|
* Updates the SKAdNetwork 4.0 postback conversion value. No-op on Android.
|
|
413
444
|
* `fineValue` is 0-63 (fine conversion value); `coarseValue` is optional
|
|
414
|
-
* for SKAN 4.0's coarse-grained reporting
|
|
445
|
+
* for SKAN 4.0's coarse-grained reporting; `lockWindow` asks Apple to
|
|
446
|
+
* send the current window's postback now rather than waiting it out.
|
|
447
|
+
*
|
|
448
|
+
* Calling this yourself turns off `autoConversionValues` for the
|
|
449
|
+
* install — manual always wins.
|
|
415
450
|
*/
|
|
416
451
|
updateConversionValue(options: {
|
|
417
452
|
fineValue: number;
|
|
418
453
|
coarseValue?: SkanCoarseValue;
|
|
454
|
+
lockWindow?: boolean;
|
|
419
455
|
}): Promise<void>;
|
|
456
|
+
/**
|
|
457
|
+
* The conversion value table the plugin is acting on: what each of
|
|
458
|
+
* Apple's numbers means for this app, as set in Set up Apex. `schema`
|
|
459
|
+
* is null until the shop has answered the SKAN question (or before
|
|
460
|
+
* the first fetch lands). Read-only; edit it in the Apex dashboard.
|
|
461
|
+
*/
|
|
462
|
+
getConversionValueSchema(): Promise<SkanSchemaSnapshot>;
|
|
420
463
|
/**
|
|
421
464
|
* Returns the URL the app was opened with on cold start (Universal Link on
|
|
422
465
|
* iOS, App Link on Android). Returns `{ url: null }` if launched without a
|
|
423
466
|
* link. Use the `deepLink` listener for subsequent warm-start links.
|
|
467
|
+
*
|
|
468
|
+
* `deepLinkHint` is the `apex_dl` value the Apex Link carried, already pulled
|
|
469
|
+
* off the query string — null when the link had none. It is whatever the
|
|
470
|
+
* merchant put on the link in Apex; nothing interprets it but your router.
|
|
424
471
|
*/
|
|
425
472
|
getInitialDeepLink(): Promise<{
|
|
426
473
|
url: string | null;
|
|
474
|
+
deepLinkHint: string | null;
|
|
427
475
|
}>;
|
|
476
|
+
/**
|
|
477
|
+
* Resolve the link the user tapped BEFORE they installed the app.
|
|
478
|
+
*
|
|
479
|
+
* `getInitialDeepLink()` above covers the case where the app already
|
|
480
|
+
* exists and the OS hands it a URL. This covers the other case: the
|
|
481
|
+
* user tapped an Apex Link in a browser, did not have the app, went
|
|
482
|
+
* through the store, and is now opening it for the first time. No URL
|
|
483
|
+
* reaches the app in that flow, so Apex matches the launch back to the
|
|
484
|
+
* tap server-side and returns the destination it was headed for.
|
|
485
|
+
*
|
|
486
|
+
* Returns null when there is no match, which is the common case — call
|
|
487
|
+
* it, route on a hit, and carry on normally otherwise. Resolution is
|
|
488
|
+
* one-shot per tap, so call it once at first launch and persist the
|
|
489
|
+
* result rather than calling it again later.
|
|
490
|
+
*
|
|
491
|
+
* See `deferred-link.ts` for the matching properties and their limits.
|
|
492
|
+
*/
|
|
493
|
+
getDeferredDeepLink(): Promise<DeferredDeepLinkResult | null>;
|
|
428
494
|
getDeviceInfo(): Promise<DeviceInfo>;
|
|
429
495
|
/** Force-starts a new session, ending any current one. */
|
|
430
496
|
startSession(): Promise<{
|
|
@@ -514,9 +580,15 @@ export interface ApexCapacitorPlugin {
|
|
|
514
580
|
getConsent(): Promise<{
|
|
515
581
|
granted: boolean;
|
|
516
582
|
}>;
|
|
517
|
-
/**
|
|
583
|
+
/**
|
|
584
|
+
* Fires when a deep link opens the app while it is already running.
|
|
585
|
+
*
|
|
586
|
+
* `deepLinkHint` carries the link's `apex_dl` value when it had one — see
|
|
587
|
+
* `getInitialDeepLink()`.
|
|
588
|
+
*/
|
|
518
589
|
addListener(eventName: "deepLink", listenerFunc: (event: {
|
|
519
590
|
url: string;
|
|
591
|
+
deepLinkHint: string | null;
|
|
520
592
|
}) => void): Promise<PluginListenerHandle>;
|
|
521
593
|
/** Fires when the app moves between foreground and background. */
|
|
522
594
|
addListener(eventName: "appStateChange", listenerFunc: (event: {
|