@apex-inc/capacitor-plugin 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +43 -5
  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 +33 -1
  18. package/dist/esm/events.d.ts.map +1 -1
  19. package/dist/esm/events.js +35 -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 +32 -4
  36. package/dist/esm/web.js.map +1 -1
  37. package/dist/events.d.ts +33 -1
  38. package/dist/events.d.ts.map +1 -1
  39. package/dist/events.js +35 -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 +32 -4
  56. package/dist/web.js.map +1 -1
  57. package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +43 -5
  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
@@ -30,6 +30,8 @@ object ApexEvents {
30
30
  const val CART_SNAPSHOT = "cart_snapshot"
31
31
  const val SUBSCRIPTION_STARTED = "subscription_started"
32
32
  const val SUBSCRIPTION_CANCELED = "subscription_canceled"
33
+ const val SUBSCRIPTION_UPGRADED = "subscription_upgraded"
34
+ const val SUBSCRIPTION_DOWNGRADED = "subscription_downgraded"
33
35
  const val PAYMENT_FAILED = "payment_failed"
34
36
  const val PAYMENT_RECOVERED = "payment_recovered"
35
37
  const val IN_APP_PURCHASE = "in_app_purchase"
@@ -45,6 +47,10 @@ object ApexEvents {
45
47
 
46
48
  // ─── Identity ───────────────────────────────────────────────────────
47
49
  const val USER_SIGNED_UP = "user_signed_up"
50
+ const val WAITLIST_JOINED = "waitlist_joined"
51
+ const val USER_INVITED = "user_invited"
52
+ const val USER_INVITE_ACCEPTED = "user_invite_accepted"
53
+ const val PASSWORD_RESET_REQUESTED = "password_reset_requested"
48
54
  const val USER_SIGNED_IN = "user_signed_in"
49
55
  const val USER_SIGNED_OUT = "user_signed_out"
50
56
  const val USER_IDENTIFIED = "user_identified"
@@ -71,6 +77,7 @@ object ApexEvents {
71
77
  const val CONTENT_VIEW = "content_view"
72
78
  const val FORM_SUBMIT = "form_submit"
73
79
  const val CLICK = "click"
80
+ const val UI_ACTION = "ui_action"
74
81
  const val ENGAGEMENT = "engagement"
75
82
  const val HEARTBEAT = "heartbeat"
76
83
  const val RAGE_CLICK = "rage_click"
@@ -111,6 +118,8 @@ object ApexEvents {
111
118
  // ─── Fulfillment ────────────────────────────────────────────────────
112
119
  const val ORDER_SHIPPED = "order_shipped"
113
120
  const val ORDER_DELIVERED = "order_delivered"
121
+ const val RETURN_REQUESTED = "return_requested"
122
+ const val RETURN_COMPLETED = "return_completed"
114
123
 
115
124
  // ─── Engagement (vertical additions) ────────────────────────────────
116
125
  const val FEATURE_USED = "feature_used"
@@ -124,6 +133,13 @@ object ApexEvents {
124
133
  const val BUYER_REVIEW_REQUESTED = "buyer_review_requested"
125
134
  const val PAYOUT_SENT = "payout_sent"
126
135
  const val TRUST_BADGE_EARNED = "trust_badge_earned"
136
+ const val SELLER_VIEWED = "seller_viewed"
137
+ const val ITEM_SAVED = "item_saved"
138
+ const val INQUIRY_STARTED = "inquiry_started"
139
+ const val MATCH_CREATED = "match_created"
140
+ const val TRANSACTION_CANCELED = "transaction_canceled"
141
+ const val DISPUTE_OPENED = "dispute_opened"
142
+ const val REVIEW_SUBMITTED = "review_submitted"
127
143
 
128
144
  // ─── Agency ─────────────────────────────────────────────────────────
129
145
  const val CLIENT_ONBOARDED = "client_onboarded"
@@ -136,18 +152,36 @@ object ApexEvents {
136
152
  const val USAGE_MILESTONE_REACHED = "usage_milestone_reached"
137
153
  const val USER_CHURNED = "user_churned"
138
154
  const val USER_REACTIVATED = "user_reactivated"
155
+ const val TRIAL_EXPIRING = "trial_expiring"
139
156
  const val TRIAL_EXPIRED = "trial_expired"
140
157
  const val INVOICE_PAID = "invoice_paid"
141
158
  const val SUBSCRIPTION_RENEWED = "subscription_renewed"
142
159
 
160
+ // Engagement suite (User Lifecycle)
161
+ const val REPORT_READY = "report_ready"
162
+ const val REPORT_VIEWED = "report_viewed"
163
+ const val CONTENT_VIEWED = "content_viewed"
164
+ const val TASK_COMPLETED = "task_completed"
165
+ const val API_REQUEST_COMPLETED = "api_request_completed"
166
+ const val PROJECT_CREATED = "project_created"
167
+ const val LISTING_VIEWED = "listing_viewed"
168
+ const val MESSAGE_SENT = "message_sent"
169
+ const val OFFER_SUBMITTED = "offer_submitted"
170
+ const val TRANSACTION_COMPLETED = "transaction_completed"
171
+ const val TASK_UPDATED = "task_updated"
172
+ const val ASSET_UPLOADED = "asset_uploaded"
173
+ const val COMMENT_ADDED = "comment_added"
174
+ const val TIME_LOGGED = "time_logged"
175
+ const val CLIENT_REPORT_VIEWED = "client_report_viewed"
176
+
143
177
  /** Apex Spec semver this SDK implements. */
144
- const val SPEC_VERSION = "1.2.0"
178
+ const val SPEC_VERSION = "1.5.0"
145
179
 
146
180
  /** Complete list of every canonical event name. */
147
181
  val ALL: List<String> = listOf(
148
182
  ADD_TO_CART, REMOVE_FROM_CART, PRODUCT_VIEW, ADD_TO_WISHLIST, CHECKOUT_STARTED,
149
- IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT,
150
- USER_SIGNED_UP, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
183
+ IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT, SUBSCRIPTION_UPGRADED, SUBSCRIPTION_DOWNGRADED,
184
+ USER_SIGNED_UP, WAITLIST_JOINED, USER_INVITED, USER_INVITE_ACCEPTED, PASSWORD_RESET_REQUESTED, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
151
185
  APP_INSTALL, APP_OPEN, APP_BACKGROUND, APP_UNINSTALL, APP_REINSTALL,
152
186
  SESSION_START, SESSION_END,
153
187
  PAGE_VIEW, PAGEVIEW, SCREEN_VIEW, SEARCH, SHARE, CONTENT_VIEW, FORM_SUBMIT,
@@ -160,11 +194,15 @@ object ApexEvents {
160
194
  REATTRIBUTION, REENGAGEMENT,
161
195
  SMART_BANNER_IMPRESSION, SMART_BANNER_CLICK, SMART_BANNER_DISMISS,
162
196
  CUSTOM, LIFECYCLE_TRANSITION, JS_ERROR,
163
- ORDER_SHIPPED, ORDER_DELIVERED,
197
+ ORDER_SHIPPED, ORDER_DELIVERED, RETURN_REQUESTED, RETURN_COMPLETED,
164
198
  FEATURE_USED, WEBINAR_REGISTERED, SURVEY_STARTED, SURVEY_COMPLETED,
165
199
  SELLER_SIGNUP, LISTING_CREATED, BUYER_REVIEW_REQUESTED, PAYOUT_SENT, TRUST_BADGE_EARNED,
200
+ SELLER_VIEWED, ITEM_SAVED, INQUIRY_STARTED, MATCH_CREATED, TRANSACTION_CANCELED, DISPUTE_OPENED, REVIEW_SUBMITTED,
166
201
  CLIENT_ONBOARDED, CAMPAIGN_MILESTONE_REACHED, CONTRACT_RENEWAL_APPROACHING, NEW_CAPABILITY_LAUNCHED,
167
202
  LOYALTY_MILESTONE_REACHED, USAGE_MILESTONE_REACHED, USER_CHURNED, USER_REACTIVATED,
168
- TRIAL_EXPIRED, INVOICE_PAID, SUBSCRIPTION_RENEWED,
203
+ TRIAL_EXPIRING, TRIAL_EXPIRED, INVOICE_PAID, SUBSCRIPTION_RENEWED,
204
+ REPORT_READY, REPORT_VIEWED, CONTENT_VIEWED, TASK_COMPLETED, API_REQUEST_COMPLETED, PROJECT_CREATED,
205
+ LISTING_VIEWED, MESSAGE_SENT, OFFER_SUBMITTED, TRANSACTION_COMPLETED, TASK_UPDATED,
206
+ ASSET_UPLOADED, COMMENT_ADDED, TIME_LOGGED, CLIENT_REPORT_VIEWED,
169
207
  )
170
208
  }
@@ -35,6 +35,33 @@ class DeepLinkManager {
35
35
  }
36
36
 
37
37
  companion object {
38
+ /**
39
+ * The `apex_dl` value an Apex Link carried, or null when it had none.
40
+ *
41
+ * Apex appends this on mobile clicks as a pass-through hint the merchant
42
+ * set on the link. Surfaced already-parsed so the app doesn't have to
43
+ * pick apart a query string; never interpreted here. Hand-parsed rather
44
+ * than via `Uri.parse` so this class stays pure Kotlin and testable off
45
+ * an Android device.
46
+ */
47
+ fun apexHint(url: String): String? {
48
+ val query = url.substringAfter('?', "").substringBefore('#')
49
+ if (query.isEmpty()) return null
50
+ for (pair in query.split('&')) {
51
+ val key = pair.substringBefore('=')
52
+ if (key != "apex_dl") continue
53
+ val raw = pair.substringAfter('=', "")
54
+ if (raw.isEmpty()) return null
55
+ val decoded = try {
56
+ java.net.URLDecoder.decode(raw, "UTF-8")
57
+ } catch (_: Exception) {
58
+ raw
59
+ }
60
+ return decoded.ifEmpty { null }
61
+ }
62
+ return null
63
+ }
64
+
38
65
  /** Is this a sensible deep link? Reject `file://` and empty schemes. */
39
66
  fun isValidDeepLink(url: String): Boolean {
40
67
  if (url.isBlank()) return false
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Deferred deep-link resolution.
3
+ *
4
+ * The problem this solves: a user taps a link in Safari or Chrome, does
5
+ * not have your app, installs it from the store, and opens it. The tap
6
+ * and the launch share no cookie and no storage, so by default your app
7
+ * has no idea the user was headed for a specific screen and drops them
8
+ * on your home screen. The tap that cost you money lands nowhere.
9
+ *
10
+ * Apex matches the launch back to the tap server-side and hands you the
11
+ * destination. Call it once, early, and route on the result:
12
+ *
13
+ * ```ts
14
+ * const link = await Apex.getDeferredDeepLink();
15
+ * if (link) router.navigateByUrl(link.deepLinkHint ?? new URL(link.url).pathname);
16
+ * ```
17
+ *
18
+ * Two properties worth knowing before you build on it:
19
+ *
20
+ * - **One-shot.** The match key is coarse (network + OS), so a link can
21
+ * only be claimed once. Call this on first launch and persist what
22
+ * you get; a second call returns null.
23
+ * - **Best-effort.** Matching is probabilistic and returns null more
24
+ * often than it returns a link — no network address, a VPN between
25
+ * tap and install, or a slow install all produce a miss. Treat a
26
+ * link as a bonus that improves the first run, never as a
27
+ * precondition for it.
28
+ *
29
+ * Implemented in TypeScript rather than in the native bridges because it
30
+ * is one HTTPS call with no platform APIs involved, and the webview can
31
+ * make it identically on iOS, Android, and in browser preview.
32
+ */
33
+ export interface DeferredDeepLinkResult {
34
+ /**
35
+ * The destination the tap was headed for, with Apex's own routing
36
+ * params (`apex_dl`, `apex_link`) already stripped.
37
+ */
38
+ url: string;
39
+ /**
40
+ * The link's `apex_dl` value — whatever screen identifier you put on
41
+ * the link in Apex. Null when the link carried none.
42
+ */
43
+ deepLinkHint: string | null;
44
+ /** Apex Link slug the tap came through, when it came through one. */
45
+ slug?: string;
46
+ /** Affiliate handle, for namespaced partner links. */
47
+ handle?: string;
48
+ source?: string;
49
+ campaign?: string;
50
+ /** ISO timestamp of the original tap. */
51
+ clickedAt: string;
52
+ /**
53
+ * How the launch was matched to the tap.
54
+ *
55
+ * `deterministic` means Google Play handed us the exact link the user
56
+ * tapped, via the install referrer — there is no guesswork in it.
57
+ * `probabilistic` means we matched on network address and OS, which is
58
+ * the best available on iOS and can, on a shared network, be wrong.
59
+ * Route on either; only gate irreversible actions (granting a credit,
60
+ * applying a referral bonus) on `deterministic`.
61
+ */
62
+ matchType: "deterministic" | "probabilistic";
63
+ }
64
+ export interface FetchDeferredDeepLinkOptions {
65
+ apiUrl: string;
66
+ workspaceKey: string;
67
+ platform: "ios" | "android";
68
+ apiKey?: string;
69
+ /** Abort the call after this many ms. Defaults to 5000. */
70
+ timeoutMs?: number;
71
+ }
72
+ export declare function fetchDeferredDeepLink(options: FetchDeferredDeepLinkOptions): Promise<DeferredDeepLinkResult | null>;
73
+ //# sourceMappingURL=deferred-link.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deferred-link.d.ts","sourceRoot":"","sources":["../src/deferred-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yCAAyC;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;OASG;IACH,SAAS,EAAE,eAAe,GAAG,eAAe,CAAC;CAC9C;AAED,MAAM,WAAW,4BAA4B;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,KAAK,GAAG,SAAS,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAwCxC"}
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ /**
3
+ * Deferred deep-link resolution.
4
+ *
5
+ * The problem this solves: a user taps a link in Safari or Chrome, does
6
+ * not have your app, installs it from the store, and opens it. The tap
7
+ * and the launch share no cookie and no storage, so by default your app
8
+ * has no idea the user was headed for a specific screen and drops them
9
+ * on your home screen. The tap that cost you money lands nowhere.
10
+ *
11
+ * Apex matches the launch back to the tap server-side and hands you the
12
+ * destination. Call it once, early, and route on the result:
13
+ *
14
+ * ```ts
15
+ * const link = await Apex.getDeferredDeepLink();
16
+ * if (link) router.navigateByUrl(link.deepLinkHint ?? new URL(link.url).pathname);
17
+ * ```
18
+ *
19
+ * Two properties worth knowing before you build on it:
20
+ *
21
+ * - **One-shot.** The match key is coarse (network + OS), so a link can
22
+ * only be claimed once. Call this on first launch and persist what
23
+ * you get; a second call returns null.
24
+ * - **Best-effort.** Matching is probabilistic and returns null more
25
+ * often than it returns a link — no network address, a VPN between
26
+ * tap and install, or a slow install all produce a miss. Treat a
27
+ * link as a bonus that improves the first run, never as a
28
+ * precondition for it.
29
+ *
30
+ * Implemented in TypeScript rather than in the native bridges because it
31
+ * is one HTTPS call with no platform APIs involved, and the webview can
32
+ * make it identically on iOS, Android, and in browser preview.
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.fetchDeferredDeepLink = fetchDeferredDeepLink;
36
+ const DEFAULT_TIMEOUT_MS = 5000;
37
+ async function fetchDeferredDeepLink(options) {
38
+ const { apiUrl, workspaceKey, platform, apiKey } = options;
39
+ if (!workspaceKey)
40
+ return null;
41
+ // First launch is the most latency-sensitive moment in the app's life.
42
+ // A slow or unreachable Apex must never hold up the first screen, so
43
+ // the call is bounded and every failure resolves to null.
44
+ const controller = new AbortController();
45
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
46
+ try {
47
+ const headers = {
48
+ "Content-Type": "application/json",
49
+ "x-apex-workspace": workspaceKey,
50
+ };
51
+ if (apiKey)
52
+ headers["x-apex-api-key"] = apiKey;
53
+ const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/api/mobile/deferred-link`, {
54
+ method: "POST",
55
+ headers,
56
+ body: JSON.stringify({ workspaceKey, platform }),
57
+ signal: controller.signal,
58
+ });
59
+ if (!response.ok)
60
+ return null;
61
+ const payload = (await response.json());
62
+ return payload.data ?? null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+ //# sourceMappingURL=deferred-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deferred-link.js","sourceRoot":"","sources":["../src/deferred-link.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;;AA6CH,sDA0CC;AA5CD,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEzB,KAAK,UAAU,qBAAqB,CACzC,OAAqC;IAErC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC3D,IAAI,CAAC,YAAY;QAAE,OAAO,IAAI,CAAC;IAE/B,uEAAuE;IACvE,qEAAqE;IACrE,0DAA0D;IAC1D,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,UAAU,CACtB,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EACxB,OAAO,CAAC,SAAS,IAAI,kBAAkB,CACxC,CAAC;IAEF,IAAI,CAAC;QACH,MAAM,OAAO,GAA2B;YACtC,cAAc,EAAE,kBAAkB;YAClC,kBAAkB,EAAE,YAAY;SACjC,CAAC;QACF,IAAI,MAAM;YAAE,OAAO,CAAC,gBAAgB,CAAC,GAAG,MAAM,CAAC;QAE/C,MAAM,QAAQ,GAAG,MAAM,KAAK,CAC1B,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,2BAA2B,EACxD;YACE,MAAM,EAAE,MAAM;YACd,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,YAAY,EAAE,QAAQ,EAAE,CAAC;YAChD,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CACF,CAAC;QAEF,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC;QAC9B,MAAM,OAAO,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAErC,CAAC;QACF,OAAO,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC"}