@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.
Files changed (65) hide show
  1. package/README.md +66 -14
  2. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +145 -15
  3. package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +11 -4
  4. package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +27 -0
  5. package/android/src/main/java/inc/apex/capacitor/TrackStamps.kt +26 -0
  6. package/dist/deferred-link.d.ts +73 -0
  7. package/dist/deferred-link.d.ts.map +1 -0
  8. package/dist/deferred-link.js +71 -0
  9. package/dist/deferred-link.js.map +1 -0
  10. package/dist/definitions.d.ts +94 -18
  11. package/dist/definitions.d.ts.map +1 -1
  12. package/dist/esm/deferred-link.d.ts +73 -0
  13. package/dist/esm/deferred-link.d.ts.map +1 -0
  14. package/dist/esm/deferred-link.js +68 -0
  15. package/dist/esm/deferred-link.js.map +1 -0
  16. package/dist/esm/definitions.d.ts +94 -18
  17. package/dist/esm/definitions.d.ts.map +1 -1
  18. package/dist/esm/events.d.ts +8 -1
  19. package/dist/esm/events.d.ts.map +1 -1
  20. package/dist/esm/events.js +8 -1
  21. package/dist/esm/events.js.map +1 -1
  22. package/dist/esm/index.d.ts +9 -0
  23. package/dist/esm/index.d.ts.map +1 -1
  24. package/dist/esm/index.js +157 -1
  25. package/dist/esm/index.js.map +1 -1
  26. package/dist/esm/referrer-link.d.ts +36 -0
  27. package/dist/esm/referrer-link.d.ts.map +1 -0
  28. package/dist/esm/referrer-link.js +103 -0
  29. package/dist/esm/referrer-link.js.map +1 -0
  30. package/dist/esm/skan-conversion-values.d.ts +175 -0
  31. package/dist/esm/skan-conversion-values.d.ts.map +1 -0
  32. package/dist/esm/skan-conversion-values.js +323 -0
  33. package/dist/esm/skan-conversion-values.js.map +1 -0
  34. package/dist/esm/web.d.ts +6 -1
  35. package/dist/esm/web.d.ts.map +1 -1
  36. package/dist/esm/web.js +27 -3
  37. package/dist/esm/web.js.map +1 -1
  38. package/dist/events.d.ts +8 -1
  39. package/dist/events.d.ts.map +1 -1
  40. package/dist/events.js +8 -1
  41. package/dist/events.js.map +1 -1
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +166 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/referrer-link.d.ts +36 -0
  47. package/dist/referrer-link.d.ts.map +1 -0
  48. package/dist/referrer-link.js +107 -0
  49. package/dist/referrer-link.js.map +1 -0
  50. package/dist/skan-conversion-values.d.ts +175 -0
  51. package/dist/skan-conversion-values.d.ts.map +1 -0
  52. package/dist/skan-conversion-values.js +331 -0
  53. package/dist/skan-conversion-values.js.map +1 -0
  54. package/dist/web.d.ts +6 -1
  55. package/dist/web.d.ts.map +1 -1
  56. package/dist/web.js +27 -3
  57. package/dist/web.js.map +1 -1
  58. package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +11 -4
  59. package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +16 -2
  60. package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +18 -4
  61. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +25 -6
  62. package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +26 -0
  63. package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +22 -1
  64. package/package.json +5 -2
  65. 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: "apex_sk_…", // see "API keys" below — unlocks verified identity stitching
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
- `identify()` and server-trusted flows work best with a workspace SDK key
49
- (`apex_sk_…`). Mint one in the Apex dashboard under **Settings →
50
- Workspace → AI agents & API keys**, and pass it as `apiKey` in
51
- `initialize()`. Without a key, identity stitches from untrusted contexts
52
- may be quarantined (held un-verified) rather than applied — events still
53
- flow, but traits and journey triggers wait until a verified stitch.
54
- Treat the value like a publishable key: assume it can be extracted from
55
- the app bundle; Apex rate-limits per key + per visitor to keep leaked
56
- keys low-yield.
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().put("url", url)
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
- val data = JSObject().put("referrer", lastInstallReferrer ?: JSONObject.NULL)
387
- call.resolve(data)
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
- if (testMode) payload["testMode"] = true
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
- batchSender?.flush(queue) { _, _ -> }
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 data = JSObject().put("url", url ?: JSONObject.NULL)
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
- val queue = offlineQueue
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
- override fun onInstallReferrerServiceDisconnected() { }
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.3.0"
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"}