@apex-inc/capacitor-plugin 2.4.1 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -14
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +145 -15
- 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/android/src/main/java/inc/apex/capacitor/TrackStamps.kt +26 -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 +94 -18
- 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 +94 -18
- 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 +27 -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 +27 -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 +25 -6
- package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +26 -0
- package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +22 -1
- package/package.json +5 -2
- package/ios/Package.swift +0 -34
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
|
|
|
@@ -25,9 +25,10 @@ import { Apex } from "@apex-inc/capacitor-plugin";
|
|
|
25
25
|
await Apex.initialize({
|
|
26
26
|
workspaceKey: "prj_your_key",
|
|
27
27
|
// Optional:
|
|
28
|
-
// apiKey: "
|
|
28
|
+
// apiKey: "apex_pk_…", // app key only; see "API keys" below
|
|
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,
|
|
@@ -45,15 +46,30 @@ into your router instead.
|
|
|
45
46
|
|
|
46
47
|
### API keys
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
Use an app key (`apex_pk_…`). Make one under Settings, Organization, API
|
|
50
|
+
keys with the **App key** preset. It is safe to ship: it gives your app its
|
|
51
|
+
own rate limit and lets a sign-up add a new person, but it never changes a
|
|
52
|
+
person already in Apex, never counts revenue, and never sends anything to
|
|
53
|
+
your ad platforms.
|
|
54
|
+
|
|
55
|
+
Do not put a server key (`apex_sk_…`) or a personal key (`apex_uk_…`) in
|
|
56
|
+
your app. Anyone can unpack an app and read the key, and either one lets
|
|
57
|
+
them send purchases Apex counts as revenue and passes to your ad
|
|
58
|
+
platforms. Send purchases from your server with a server key and the
|
|
59
|
+
[Server Events API](https://app.apex.inc/docs/api/server-events), or
|
|
60
|
+
connect the App Store or Google Play.
|
|
61
|
+
|
|
62
|
+
### Signed sign-ins
|
|
63
|
+
|
|
64
|
+
To attach a sign-in to someone already in Apex, have your server sign the
|
|
65
|
+
email with your workspace's sign-in secret and pass the result as
|
|
66
|
+
`userHash`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
await Apex.identify({ email, userHash }); // userHash comes from your backend
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
See [Signed sign-ins](https://app.apex.inc/docs/identity/signed-sign-ins).
|
|
57
73
|
|
|
58
74
|
## Track events
|
|
59
75
|
|
|
@@ -163,25 +179,61 @@ if (!id && fallback === "idfv") {
|
|
|
163
179
|
|
|
164
180
|
## SKAdNetwork conversion values (iOS 4.0+)
|
|
165
181
|
|
|
182
|
+
You do not have to write this code. In the Apex dashboard, **Set up Apex →
|
|
183
|
+
Mobile → Say what Apple's numbers mean** asks one question (which event
|
|
184
|
+
means an install was worth it) and turns it into the full table of what
|
|
185
|
+
Apple's 0–63 values mean for your app. With `autoConversionValues` on
|
|
186
|
+
(the default on iOS), the plugin:
|
|
187
|
+
|
|
188
|
+
- fetches that table on `initialize()`, caches it for a day, and
|
|
189
|
+
revalidates by ETag;
|
|
190
|
+
- calls `SKAdNetwork.updatePostbackConversionValue` whenever `track()`
|
|
191
|
+
sees an event in the table, in the postback window the install is in;
|
|
192
|
+
- only ever moves the value up within a window, and stops after day 35
|
|
193
|
+
when Apple stops listening.
|
|
194
|
+
|
|
195
|
+
With `debug: true` every decision is logged as
|
|
196
|
+
`[apex-capacitor] skan updatePostbackConversionValue(32, high) for purchase in window 1`.
|
|
197
|
+
|
|
198
|
+
Read the table the plugin is acting on:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const { schema, mobileAppId, fetchedAt } = await Apex.getConversionValueSchema();
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
If you would rather run your own ladder, call `updateConversionValue`
|
|
205
|
+
yourself. The first manual call turns automatic values off for that
|
|
206
|
+
install — manual always wins — or pass `autoConversionValues: false` at
|
|
207
|
+
`initialize()`.
|
|
208
|
+
|
|
166
209
|
```ts
|
|
167
210
|
await Apex.updateConversionValue({
|
|
168
211
|
fineValue: 42,
|
|
169
212
|
coarseValue: "high",
|
|
213
|
+
lockWindow: false, // true asks Apple to send this window's postback now
|
|
170
214
|
});
|
|
171
215
|
```
|
|
172
216
|
|
|
217
|
+
Android has no SKAdNetwork; both calls are no-ops there.
|
|
218
|
+
|
|
173
219
|
## Deep links
|
|
174
220
|
|
|
175
221
|
```ts
|
|
176
222
|
// Cold start: read the URL that opened the app (if any).
|
|
177
|
-
const { url } = await Apex.getInitialDeepLink();
|
|
223
|
+
const { url, deepLinkHint } = await Apex.getInitialDeepLink();
|
|
178
224
|
|
|
179
225
|
// Warm-start / subsequent links while running:
|
|
180
|
-
await Apex.addListener("deepLink", ({ url }) => {
|
|
181
|
-
handleRoute(url);
|
|
226
|
+
await Apex.addListener("deepLink", ({ url, deepLinkHint }) => {
|
|
227
|
+
handleRoute(url, deepLinkHint);
|
|
182
228
|
});
|
|
183
229
|
```
|
|
184
230
|
|
|
231
|
+
`deepLinkHint` is the value the merchant set on the Apex Link (`apex_dl` on the
|
|
232
|
+
URL), handed over already parsed. It's null when the link didn't carry one. Use
|
|
233
|
+
it when your in-app routes don't look like your web routes — the link can say
|
|
234
|
+
`checkout` and your router can honor that without parsing a web path it doesn't
|
|
235
|
+
recognize. Apex never interprets the value.
|
|
236
|
+
|
|
185
237
|
## Sessions
|
|
186
238
|
|
|
187
239
|
```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
|
|
@@ -50,11 +52,24 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
50
52
|
|
|
51
53
|
private var offlineQueue: NativeOfflineQueue? = null
|
|
52
54
|
private var batchSender: NativeBatchSender? = null
|
|
55
|
+
private val flushHandler = Handler(Looper.getMainLooper())
|
|
56
|
+
private var flushTimer: Runnable? = null
|
|
57
|
+
private var pendingTrackFlush: Runnable? = null
|
|
53
58
|
private var prefs: SharedPreferences? = null
|
|
54
59
|
private var visitorId: String = ""
|
|
55
60
|
private var testMode: Boolean = false
|
|
56
61
|
private var debug: Boolean = false
|
|
57
62
|
private var lastInstallReferrer: String? = null
|
|
63
|
+
|
|
64
|
+
// Deferred deep linking. The Play referrer is the ONLY thing that
|
|
65
|
+
// survives the browser-tap → store → first-launch gap on Android, so
|
|
66
|
+
// `getInstallReferrer` has to be able to answer during first launch
|
|
67
|
+
// rather than returning null because the Play service hasn't
|
|
68
|
+
// connected yet. Calls that arrive before the fetch completes are
|
|
69
|
+
// parked and flushed by the listener.
|
|
70
|
+
private var installReferrerResolved: Boolean = false
|
|
71
|
+
private var installReferrerClickSeconds: Long = 0
|
|
72
|
+
private val pendingReferrerCalls = mutableListOf<PluginCall>()
|
|
58
73
|
// Phase 2-SDK — workspace-bound SDK key forwarded as
|
|
59
74
|
// `x-apex-api-key` on every event POST + on `identify()`.
|
|
60
75
|
private var apiKey: String? = null
|
|
@@ -83,7 +98,9 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
deepLinks.setWarmLinkHandler { url ->
|
|
86
|
-
val data = JSObject()
|
|
101
|
+
val data = JSObject()
|
|
102
|
+
.put("url", url)
|
|
103
|
+
.put("deepLinkHint", DeepLinkManager.apexHint(url) ?: JSONObject.NULL)
|
|
87
104
|
notifyListeners("deepLink", data)
|
|
88
105
|
}
|
|
89
106
|
|
|
@@ -156,6 +173,8 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
156
173
|
apiKey = apiKey,
|
|
157
174
|
debug = debug,
|
|
158
175
|
)
|
|
176
|
+
scheduleFlushTimer()
|
|
177
|
+
kickFlush("initialize")
|
|
159
178
|
|
|
160
179
|
// MMP-061 — release channel detection. Unlike iOS, Play doesn't
|
|
161
180
|
// surface internal/closed/open tracks at the device level
|
|
@@ -383,8 +402,20 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
383
402
|
|
|
384
403
|
@PluginMethod
|
|
385
404
|
fun getInstallReferrer(call: PluginCall) {
|
|
386
|
-
|
|
387
|
-
|
|
405
|
+
if (installReferrerResolved) {
|
|
406
|
+
resolveInstallReferrer(call)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
// Park the call until the Play service answers. Bounded, because
|
|
410
|
+
// first launch is the most latency-sensitive moment in the app's
|
|
411
|
+
// life and a wedged Play service must never hold up a screen.
|
|
412
|
+
synchronized(pendingReferrerCalls) { pendingReferrerCalls.add(call) }
|
|
413
|
+
Handler(Looper.getMainLooper()).postDelayed({
|
|
414
|
+
val stillPending = synchronized(pendingReferrerCalls) {
|
|
415
|
+
pendingReferrerCalls.remove(call)
|
|
416
|
+
}
|
|
417
|
+
if (stillPending) resolveInstallReferrer(call)
|
|
418
|
+
}, REFERRER_WAIT_MS)
|
|
388
419
|
}
|
|
389
420
|
|
|
390
421
|
@PluginMethod
|
|
@@ -442,6 +473,10 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
442
473
|
if (traits != null) {
|
|
443
474
|
payload.put("metadata", traits)
|
|
444
475
|
}
|
|
476
|
+
val userHash = call.getString("userHash")
|
|
477
|
+
if (!userHash.isNullOrEmpty()) {
|
|
478
|
+
payload.put("userHash", userHash)
|
|
479
|
+
}
|
|
445
480
|
|
|
446
481
|
Thread {
|
|
447
482
|
try {
|
|
@@ -709,6 +744,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
709
744
|
conn.setRequestProperty("x-apex-source", "sdk")
|
|
710
745
|
conn.setRequestProperty("x-apex-visitor-id", visitorId)
|
|
711
746
|
conn.setRequestProperty("x-apex-workspace", workspaceKey)
|
|
747
|
+
conn.setRequestProperty("x-apex-release-channel", releaseChannel)
|
|
712
748
|
apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
|
|
713
749
|
conn.connectTimeout = 10000
|
|
714
750
|
conn.readTimeout = 15000
|
|
@@ -755,17 +791,23 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
755
791
|
*/
|
|
756
792
|
private fun enqueueExposure(experimentId: String, variant: String) {
|
|
757
793
|
val queue = offlineQueue ?: return
|
|
794
|
+
// App version that saw the variant — the mobile equivalent of the web
|
|
795
|
+
// build id. Enables version-adoption curves + version-gating during a
|
|
796
|
+
// staged rollout (Phase 5, 2026-07-20).
|
|
797
|
+
val appVersion = try {
|
|
798
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0.0.0"
|
|
799
|
+
} catch (_: Exception) {
|
|
800
|
+
"0.0.0"
|
|
801
|
+
}
|
|
758
802
|
val payload = mutableMapOf<String, Any?>()
|
|
759
803
|
payload["type"] = "experiment_exposure"
|
|
760
804
|
payload["data"] = mapOf(
|
|
761
805
|
"experiment_id" to experimentId,
|
|
762
806
|
"variant_key" to variant,
|
|
763
807
|
"surface" to "mobile",
|
|
808
|
+
"app_version" to appVersion,
|
|
764
809
|
)
|
|
765
|
-
|
|
766
|
-
payload["platform"] = "android"
|
|
767
|
-
payload["timestamp"] = java.time.Instant.now().toString()
|
|
768
|
-
payload["visitorId"] = visitorId
|
|
810
|
+
TrackStamps.apply(payload, visitorId, testMode, java.time.Instant.now().toString())
|
|
769
811
|
payload["clientType"] = "native-android"
|
|
770
812
|
payload["mobile"] = mutableMapOf<String, Any?>(
|
|
771
813
|
"releaseChannel" to releaseChannel,
|
|
@@ -779,7 +821,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
779
821
|
)
|
|
780
822
|
try {
|
|
781
823
|
queue.enqueue(event)
|
|
782
|
-
|
|
824
|
+
kickFlush("track")
|
|
783
825
|
} catch (e: Exception) {
|
|
784
826
|
if (debug) println("[apex-capacitor] exposure enqueue failed: ${e.message}")
|
|
785
827
|
}
|
|
@@ -796,7 +838,10 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
796
838
|
@PluginMethod
|
|
797
839
|
fun getInitialDeepLink(call: PluginCall) {
|
|
798
840
|
val url = deepLinks.consumeInitialUrl()
|
|
799
|
-
val
|
|
841
|
+
val hint = url?.let { DeepLinkManager.apexHint(it) }
|
|
842
|
+
val data = JSObject()
|
|
843
|
+
.put("url", url ?: JSONObject.NULL)
|
|
844
|
+
.put("deepLinkHint", hint ?: JSONObject.NULL)
|
|
800
845
|
call.resolve(data)
|
|
801
846
|
}
|
|
802
847
|
|
|
@@ -871,6 +916,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
871
916
|
val id = call.getString("id") ?: UUID.randomUUID().toString().lowercase()
|
|
872
917
|
val payload = mutableMapOf<String, Any?>()
|
|
873
918
|
call.data.keys().forEach { key -> payload[key] = call.data.opt(key) }
|
|
919
|
+
TrackStamps.apply(payload, visitorId, testMode, java.time.Instant.now().toString())
|
|
874
920
|
|
|
875
921
|
// MMP-061 — stamp release channel under event.mobile so the
|
|
876
922
|
// server's TrackingEvent shape lands the field in the right
|
|
@@ -907,6 +953,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
907
953
|
)
|
|
908
954
|
try {
|
|
909
955
|
queue.enqueue(event)
|
|
956
|
+
kickFlush("track")
|
|
910
957
|
call.resolve()
|
|
911
958
|
} catch (e: Exception) {
|
|
912
959
|
call.reject("Failed to enqueue event: ${e.message}")
|
|
@@ -962,11 +1009,7 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
962
1009
|
consentGranted = granted
|
|
963
1010
|
if (granted) {
|
|
964
1011
|
// Resume: flush anything captured before this point.
|
|
965
|
-
|
|
966
|
-
val sender = batchSender
|
|
967
|
-
if (queue != null && sender != null) {
|
|
968
|
-
sender.flush(queue) { _, _ -> }
|
|
969
|
-
}
|
|
1012
|
+
kickFlush("consent")
|
|
970
1013
|
} else {
|
|
971
1014
|
// Revoke: drop anything queued but not yet sent.
|
|
972
1015
|
try {
|
|
@@ -984,6 +1027,54 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
984
1027
|
|
|
985
1028
|
// ── Internal ──────────────────────────────────────────────────────
|
|
986
1029
|
|
|
1030
|
+
override fun handleOnResume() {
|
|
1031
|
+
super.handleOnResume()
|
|
1032
|
+
kickFlush("foreground")
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
override fun handleOnDestroy() {
|
|
1036
|
+
flushTimer?.let { flushHandler.removeCallbacks(it) }
|
|
1037
|
+
pendingTrackFlush?.let { flushHandler.removeCallbacks(it) }
|
|
1038
|
+
super.handleOnDestroy()
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** Sends the queue every 30s so bursts and background captures leave the device. */
|
|
1042
|
+
private fun scheduleFlushTimer() {
|
|
1043
|
+
flushTimer?.let { flushHandler.removeCallbacks(it) }
|
|
1044
|
+
val tick = object : Runnable {
|
|
1045
|
+
override fun run() {
|
|
1046
|
+
kickFlush("timer")
|
|
1047
|
+
flushHandler.postDelayed(this, FLUSH_INTERVAL_MS)
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
flushTimer = tick
|
|
1051
|
+
flushHandler.postDelayed(tick, FLUSH_INTERVAL_MS)
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Fire-and-forget send. Track-triggered sends wait 250ms so a burst of
|
|
1056
|
+
* taps goes as one batch. The sender only runs one drain at a time.
|
|
1057
|
+
*/
|
|
1058
|
+
private fun kickFlush(reason: String) {
|
|
1059
|
+
val queue = offlineQueue ?: return
|
|
1060
|
+
val sender = batchSender ?: return
|
|
1061
|
+
val run = Runnable {
|
|
1062
|
+
if (debug) println("[apex-capacitor] flush kicked ($reason)")
|
|
1063
|
+
sender.flush(queue) { flushed, remaining ->
|
|
1064
|
+
if (debug && (flushed > 0 || remaining > 0)) {
|
|
1065
|
+
println("[apex-capacitor] flush done — sent $flushed, remaining $remaining")
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
if (reason == "track") {
|
|
1070
|
+
pendingTrackFlush?.let { flushHandler.removeCallbacks(it) }
|
|
1071
|
+
pendingTrackFlush = run
|
|
1072
|
+
flushHandler.postDelayed(run, TRACK_FLUSH_DEBOUNCE_MS)
|
|
1073
|
+
} else {
|
|
1074
|
+
run.run()
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
987
1078
|
private fun fetchInstallReferrer() {
|
|
988
1079
|
val client = InstallReferrerClient.newBuilder(context).build()
|
|
989
1080
|
client.startConnection(object : InstallReferrerStateListener {
|
|
@@ -992,6 +1083,11 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
992
1083
|
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
|
|
993
1084
|
val details = client.installReferrer
|
|
994
1085
|
lastInstallReferrer = details.installReferrer
|
|
1086
|
+
// The tap's own timestamp, not the install's. A
|
|
1087
|
+
// deferred link is only worth honouring while the
|
|
1088
|
+
// user still remembers tapping it, so JS ages the
|
|
1089
|
+
// referrer out against this.
|
|
1090
|
+
installReferrerClickSeconds = details.referrerClickTimestampSeconds
|
|
995
1091
|
}
|
|
996
1092
|
} catch (_: Exception) {
|
|
997
1093
|
// ignore
|
|
@@ -999,13 +1095,36 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
999
1095
|
try {
|
|
1000
1096
|
client.endConnection()
|
|
1001
1097
|
} catch (_: Exception) { }
|
|
1098
|
+
flushPendingReferrerCalls()
|
|
1002
1099
|
}
|
|
1003
1100
|
}
|
|
1004
1101
|
|
|
1005
|
-
|
|
1102
|
+
// A disconnect before the first response means Play will not
|
|
1103
|
+
// answer this launch. Resolve the waiters with what we have
|
|
1104
|
+
// (nothing) rather than leaving them to time out one by one.
|
|
1105
|
+
override fun onInstallReferrerServiceDisconnected() {
|
|
1106
|
+
flushPendingReferrerCalls()
|
|
1107
|
+
}
|
|
1006
1108
|
})
|
|
1007
1109
|
}
|
|
1008
1110
|
|
|
1111
|
+
private fun flushPendingReferrerCalls() {
|
|
1112
|
+
installReferrerResolved = true
|
|
1113
|
+
val waiting = synchronized(pendingReferrerCalls) {
|
|
1114
|
+
val copy = pendingReferrerCalls.toList()
|
|
1115
|
+
pendingReferrerCalls.clear()
|
|
1116
|
+
copy
|
|
1117
|
+
}
|
|
1118
|
+
for (call in waiting) resolveInstallReferrer(call)
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private fun resolveInstallReferrer(call: PluginCall) {
|
|
1122
|
+
val data = JSObject()
|
|
1123
|
+
.put("referrer", lastInstallReferrer ?: JSONObject.NULL)
|
|
1124
|
+
.put("clickTimestamp", installReferrerClickSeconds)
|
|
1125
|
+
call.resolve(data)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1009
1128
|
companion object {
|
|
1010
1129
|
/**
|
|
1011
1130
|
* Weak-ish reference to the live plugin so the FCM messaging
|
|
@@ -1015,6 +1134,17 @@ class ApexCapacitorPlugin : Plugin() {
|
|
|
1015
1134
|
@Volatile
|
|
1016
1135
|
var activePlugin: ApexCapacitorPlugin? = null
|
|
1017
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* How long `getInstallReferrer` will wait for Play before
|
|
1139
|
+
* answering with what it has. Google's own samples see the
|
|
1140
|
+
* connection settle in well under a second; past that the user is
|
|
1141
|
+
* staring at a held-back first screen for a nice-to-have.
|
|
1142
|
+
*/
|
|
1143
|
+
private const val REFERRER_WAIT_MS = 2500L
|
|
1144
|
+
|
|
1145
|
+
private const val FLUSH_INTERVAL_MS = 30_000L
|
|
1146
|
+
private const val TRACK_FLUSH_DEBOUNCE_MS = 250L
|
|
1147
|
+
|
|
1018
1148
|
/**
|
|
1019
1149
|
* Register a (possibly refreshed) FCM token with Apex from the
|
|
1020
1150
|
* 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,26 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fields every queued Android event must carry, matching iOS `track()`.
|
|
7
|
+
* Without `visitorId` the server accepts the batch but sets the event
|
|
8
|
+
* aside, so it never reaches the debug stream or the dashboards.
|
|
9
|
+
* Caller-supplied timestamp and visitorId win; platform is always android.
|
|
10
|
+
*/
|
|
11
|
+
internal object TrackStamps {
|
|
12
|
+
fun apply(
|
|
13
|
+
payload: MutableMap<String, Any?>,
|
|
14
|
+
visitorId: String,
|
|
15
|
+
testMode: Boolean,
|
|
16
|
+
nowIso: String,
|
|
17
|
+
) {
|
|
18
|
+
payload["platform"] = "android"
|
|
19
|
+
if (isMissing(payload["timestamp"])) payload["timestamp"] = nowIso
|
|
20
|
+
if (isMissing(payload["visitorId"])) payload["visitorId"] = visitorId
|
|
21
|
+
if (testMode) payload["testMode"] = true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private fun isMissing(value: Any?): Boolean =
|
|
25
|
+
value == null || value == JSONObject.NULL || (value is String && value.isBlank())
|
|
26
|
+
}
|
|
@@ -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"}
|