@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.
Files changed (63) hide show
  1. package/README.md +41 -4
  2. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +80 -5
  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/dist/deferred-link.d.ts +73 -0
  6. package/dist/deferred-link.d.ts.map +1 -0
  7. package/dist/deferred-link.js +71 -0
  8. package/dist/deferred-link.js.map +1 -0
  9. package/dist/definitions.d.ts +76 -4
  10. package/dist/definitions.d.ts.map +1 -1
  11. package/dist/esm/deferred-link.d.ts +73 -0
  12. package/dist/esm/deferred-link.d.ts.map +1 -0
  13. package/dist/esm/deferred-link.js +68 -0
  14. package/dist/esm/deferred-link.js.map +1 -0
  15. package/dist/esm/definitions.d.ts +76 -4
  16. package/dist/esm/definitions.d.ts.map +1 -1
  17. package/dist/esm/events.d.ts +8 -1
  18. package/dist/esm/events.d.ts.map +1 -1
  19. package/dist/esm/events.js +8 -1
  20. package/dist/esm/events.js.map +1 -1
  21. package/dist/esm/index.d.ts +9 -0
  22. package/dist/esm/index.d.ts.map +1 -1
  23. package/dist/esm/index.js +157 -1
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/referrer-link.d.ts +36 -0
  26. package/dist/esm/referrer-link.d.ts.map +1 -0
  27. package/dist/esm/referrer-link.js +103 -0
  28. package/dist/esm/referrer-link.js.map +1 -0
  29. package/dist/esm/skan-conversion-values.d.ts +175 -0
  30. package/dist/esm/skan-conversion-values.d.ts.map +1 -0
  31. package/dist/esm/skan-conversion-values.js +323 -0
  32. package/dist/esm/skan-conversion-values.js.map +1 -0
  33. package/dist/esm/web.d.ts +6 -1
  34. package/dist/esm/web.d.ts.map +1 -1
  35. package/dist/esm/web.js +26 -3
  36. package/dist/esm/web.js.map +1 -1
  37. package/dist/events.d.ts +8 -1
  38. package/dist/events.d.ts.map +1 -1
  39. package/dist/events.js +8 -1
  40. package/dist/events.js.map +1 -1
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +166 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/referrer-link.d.ts +36 -0
  46. package/dist/referrer-link.d.ts.map +1 -0
  47. package/dist/referrer-link.js +107 -0
  48. package/dist/referrer-link.js.map +1 -0
  49. package/dist/skan-conversion-values.d.ts +175 -0
  50. package/dist/skan-conversion-values.d.ts.map +1 -0
  51. package/dist/skan-conversion-values.js +331 -0
  52. package/dist/skan-conversion-values.js.map +1 -0
  53. package/dist/web.d.ts +6 -1
  54. package/dist/web.d.ts.map +1 -1
  55. package/dist/web.js +26 -3
  56. package/dist/web.js.map +1 -1
  57. package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +11 -4
  58. package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +16 -2
  59. package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +18 -4
  60. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +22 -6
  61. package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +26 -0
  62. package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +22 -1
  63. 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().put("url", url)
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
- val data = JSObject().put("referrer", lastInstallReferrer ?: JSONObject.NULL)
387
- call.resolve(data)
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 data = JSObject().put("url", url ?: JSONObject.NULL)
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
- override fun onInstallReferrerServiceDisconnected() { }
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.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,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"}
@@ -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 on first launch.
381
- * iOS: returns `{ referrer: null }` (no equivalent).
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
- /** Fires when a deep link opens the app while it is already running. */
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: {