@apex-inc/capacitor-plugin 0.3.9 → 2.1.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 (72) hide show
  1. package/android/build.gradle +4 -2
  2. package/android/src/main/AndroidManifest.xml +35 -3
  3. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +403 -6
  4. package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +122 -0
  5. package/android/src/main/java/inc/apex/capacitor/ApexFirebaseMessagingService.kt +99 -0
  6. package/android/src/main/java/inc/apex/capacitor/NativeBatchSender.kt +260 -0
  7. package/dist/batch-sender.d.ts +12 -0
  8. package/dist/batch-sender.d.ts.map +1 -1
  9. package/dist/batch-sender.js +25 -0
  10. package/dist/batch-sender.js.map +1 -1
  11. package/dist/cart-helpers.d.ts +63 -0
  12. package/dist/cart-helpers.d.ts.map +1 -0
  13. package/dist/cart-helpers.js +50 -0
  14. package/dist/cart-helpers.js.map +1 -0
  15. package/dist/definitions.d.ts +142 -2
  16. package/dist/definitions.d.ts.map +1 -1
  17. package/dist/esm/batch-sender.d.ts +12 -0
  18. package/dist/esm/batch-sender.d.ts.map +1 -1
  19. package/dist/esm/batch-sender.js +25 -0
  20. package/dist/esm/batch-sender.js.map +1 -1
  21. package/dist/esm/cart-helpers.d.ts +63 -0
  22. package/dist/esm/cart-helpers.d.ts.map +1 -0
  23. package/dist/esm/cart-helpers.js +44 -0
  24. package/dist/esm/cart-helpers.js.map +1 -0
  25. package/dist/esm/definitions.d.ts +142 -2
  26. package/dist/esm/definitions.d.ts.map +1 -1
  27. package/dist/esm/events.d.ts +85 -0
  28. package/dist/esm/events.d.ts.map +1 -0
  29. package/dist/esm/events.js +96 -0
  30. package/dist/esm/events.js.map +1 -0
  31. package/dist/esm/index.d.ts +4 -5
  32. package/dist/esm/index.d.ts.map +1 -1
  33. package/dist/esm/index.js +19 -1
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/offline-queue.d.ts +15 -0
  36. package/dist/esm/offline-queue.d.ts.map +1 -1
  37. package/dist/esm/offline-queue.js +35 -0
  38. package/dist/esm/offline-queue.js.map +1 -1
  39. package/dist/esm/screen-view.d.ts +18 -0
  40. package/dist/esm/screen-view.d.ts.map +1 -0
  41. package/dist/esm/screen-view.js +28 -0
  42. package/dist/esm/screen-view.js.map +1 -0
  43. package/dist/esm/web.d.ts +29 -1
  44. package/dist/esm/web.d.ts.map +1 -1
  45. package/dist/esm/web.js +161 -0
  46. package/dist/esm/web.js.map +1 -1
  47. package/dist/events.d.ts +85 -0
  48. package/dist/events.d.ts.map +1 -0
  49. package/dist/events.js +99 -0
  50. package/dist/events.js.map +1 -0
  51. package/dist/index.d.ts +4 -5
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +27 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/offline-queue.d.ts +15 -0
  56. package/dist/offline-queue.d.ts.map +1 -1
  57. package/dist/offline-queue.js +35 -0
  58. package/dist/offline-queue.js.map +1 -1
  59. package/dist/screen-view.d.ts +18 -0
  60. package/dist/screen-view.d.ts.map +1 -0
  61. package/dist/screen-view.js +31 -0
  62. package/dist/screen-view.js.map +1 -0
  63. package/dist/web.d.ts +29 -1
  64. package/dist/web.d.ts.map +1 -1
  65. package/dist/web.js +161 -0
  66. package/dist/web.js.map +1 -1
  67. package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +124 -0
  68. package/ios/Sources/ApexCapacitorPlugin/BatchSender.swift +18 -0
  69. package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +19 -0
  70. package/ios/Sources/ApexCapacitorPlugin/PushNotificationManager.swift +47 -0
  71. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +280 -20
  72. package/package.json +1 -1
@@ -57,8 +57,10 @@ dependencies {
57
57
  implementation "com.android.installreferrer:installreferrer:$installreferrerVersion"
58
58
  // Play Services Ads for GAID
59
59
  implementation "com.google.android.gms:play-services-ads-identifier:18.0.1"
60
- // Firebase Messaging for FCM push tokens (used in Phase 5f uninstall tracking)
61
- compileOnly "com.google.firebase:firebase-messaging:23.4.0"
60
+ // Firebase Messaging for FCM push tokens + alert notifications.
61
+ // `implementation` (not compileOnly) so the messaging service +
62
+ // token APIs are available at runtime for Android push.
63
+ implementation "com.google.firebase:firebase-messaging:23.4.0"
62
64
 
63
65
  testImplementation "junit:junit:$junitVersion"
64
66
  testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
@@ -1,8 +1,40 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
- <!-- Access Google Advertising ID (GAID) on API 24+. -->
4
2
  <uses-permission android:name="com.google.android.gms.permission.AD_ID" />
5
- <!-- Network required for batch-sender event delivery. -->
6
3
  <uses-permission android:name="android.permission.INTERNET" />
7
4
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5
+ <!-- Android 13+ runtime permission for posting notifications (FCM alerts). -->
6
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7
+
8
+ <!-- FCM messaging service: receives the device token (onNewToken) and
9
+ alert messages (onMessageReceived). Merged into the host app's
10
+ manifest by the Android build. -->
11
+ <application>
12
+ <service
13
+ android:name="inc.apex.capacitor.ApexFirebaseMessagingService"
14
+ android:exported="false">
15
+ <intent-filter>
16
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
17
+ </intent-filter>
18
+ </service>
19
+ </application>
20
+
21
+ <!--
22
+ MMP-061 — release channel detection. On Android 11+ (API 30),
23
+ `PackageManager.getInstallerPackageName(pkg)` returns null unless
24
+ the calling package declares a <queries> element listing the
25
+ installers it cares about. Without this, half of the Play
26
+ Store + sideloaded device base would read as "unknown" / "sideloaded".
27
+
28
+ We use `PackageManager.getInstallSourceInfo()` on API 30+ (no
29
+ <queries> needed for the caller's OWN package). The query block
30
+ below explicitly lists Play + AOSP installer packages so the
31
+ legacy `getInstallerPackageName` fallback on API 29 and below
32
+ also resolves correctly.
33
+ -->
34
+ <queries>
35
+ <package android:name="com.android.vending" />
36
+ <package android:name="com.google.android.feedback" />
37
+ <package android:name="com.amazon.venezia" />
38
+ <package android:name="com.sec.android.app.samsungapps" />
39
+ </queries>
8
40
  </manifest>
@@ -13,6 +13,7 @@ import com.getcapacitor.PluginCall
13
13
  import com.getcapacitor.PluginMethod
14
14
  import com.getcapacitor.annotation.CapacitorPlugin
15
15
  import com.google.android.gms.ads.identifier.AdvertisingIdClient
16
+ import com.google.firebase.messaging.FirebaseMessaging
16
17
  import java.io.File
17
18
  import java.util.Locale
18
19
  import java.util.TimeZone
@@ -47,11 +48,23 @@ class ApexCapacitorPlugin : Plugin() {
47
48
  }
48
49
 
49
50
  private var offlineQueue: NativeOfflineQueue? = null
51
+ private var batchSender: NativeBatchSender? = null
50
52
  private var prefs: SharedPreferences? = null
51
53
  private var visitorId: String = ""
52
54
  private var testMode: Boolean = false
53
55
  private var debug: Boolean = false
54
56
  private var lastInstallReferrer: String? = null
57
+ // Phase 2-SDK — workspace-bound SDK key forwarded as
58
+ // `x-apex-api-key` on every event POST + on `identify()`.
59
+ private var apiKey: String? = null
60
+ private var apiBaseUrl: String = "https://api.apex.inc"
61
+ private var projectKey: String = ""
62
+
63
+ // MMP-061 — cached release channel + source. Detected once per
64
+ // launch in `initialize()` from installer-package source +
65
+ // `BuildConfig.DEBUG` + optional JS override.
66
+ private var releaseChannel: String = "unknown"
67
+ private var releaseChannelSource: String = "auto"
55
68
 
56
69
  override fun load() {
57
70
  val ctx = context
@@ -72,17 +85,50 @@ class ApexCapacitorPlugin : Plugin() {
72
85
  deepLinks.setInitialUrl(url)
73
86
  }
74
87
  }
88
+
89
+ // Register as the active plugin so the FCM messaging service can
90
+ // forward token refreshes + taps to JS listeners.
91
+ activePlugin = this
92
+
93
+ // If the app was launched by tapping an FCM notification, the
94
+ // launcher intent carries the apex push data as extras — forward
95
+ // it as `pushClicked` so the app can report engagement + route.
96
+ activity.intent?.extras?.let { extras ->
97
+ if (extras.getString("apex_messageId") != null ||
98
+ extras.getString("apex_apexToken") != null ||
99
+ extras.getString("apex_url") != null
100
+ ) {
101
+ emitPushClicked(
102
+ url = extras.getString("apex_url"),
103
+ messageId = extras.getString("apex_messageId"),
104
+ apexToken = extras.getString("apex_apexToken"),
105
+ )
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Forward a push tap to JS listeners (`pushClicked`). */
111
+ private fun emitPushClicked(url: String?, messageId: String?, apexToken: String?) {
112
+ val data = JSObject()
113
+ if (url != null) data.put("url", url)
114
+ if (messageId != null) data.put("messageId", messageId)
115
+ if (apexToken != null) data.put("apexToken", apexToken)
116
+ notifyListeners("pushClicked", JSObject().put("data", data))
75
117
  }
76
118
 
77
119
  @PluginMethod
78
120
  fun initialize(call: PluginCall) {
79
- val projectKey = call.getString("projectKey")
80
- if (projectKey.isNullOrEmpty()) {
121
+ val pk = call.getString("projectKey")
122
+ if (pk.isNullOrEmpty()) {
81
123
  call.reject("projectKey is required")
82
124
  return
83
125
  }
126
+ projectKey = pk
84
127
  testMode = call.getBoolean("testMode", false) ?: false
85
128
  debug = call.getBoolean("debug", false) ?: false
129
+ apiBaseUrl = call.getString("apiUrl") ?: "https://api.apex.inc"
130
+ val rawKey = call.getString("apiKey")
131
+ apiKey = if (rawKey.isNullOrEmpty()) null else rawKey
86
132
  val maxSize = call.getInt("offlineQueueMaxSize") ?: 1000
87
133
 
88
134
  val queueFile = File(context.filesDir, "apex-capacitor/events.json")
@@ -90,12 +136,200 @@ class ApexCapacitorPlugin : Plugin() {
90
136
  val storage = FileQueueStorage(queueFile)
91
137
  offlineQueue = NativeOfflineQueue(storage, maxSize = maxSize)
92
138
 
139
+ // MMP-215 — drain the queue. Without this, every event captured
140
+ // while the webview was unmounted is stranded in apex-capacitor/
141
+ // events.json forever.
142
+ batchSender = NativeBatchSender(
143
+ apiBaseUrl = apiBaseUrl,
144
+ projectKey = projectKey,
145
+ platformHeader = "android",
146
+ apiKey = apiKey,
147
+ debug = debug,
148
+ )
149
+
150
+ // MMP-061 — release channel detection. Unlike iOS, Play doesn't
151
+ // surface internal/closed/open tracks at the device level
152
+ // (`com.android.vending` is the installer for every Play install).
153
+ // So an explicit override here is the canonical Android path
154
+ // for tagging closed-test builds until Play Console API
155
+ // reconciliation lands (MMP-069). Wire `Apex.initialize({
156
+ // releaseChannel: BuildConfig.RELEASE_CHANNEL })` from your
157
+ // Gradle build flavors and the channel reflects the flavor.
158
+ val override = call.getString("releaseChannel")
159
+ if (!override.isNullOrEmpty() && isValidReleaseChannel(override)) {
160
+ releaseChannel = override
161
+ releaseChannelSource = "override"
162
+ if (debug) {
163
+ println("[apex-capacitor] releaseChannel override = $override")
164
+ }
165
+ } else {
166
+ releaseChannel = detectReleaseChannel()
167
+ releaseChannelSource = "auto"
168
+ if (debug) {
169
+ println("[apex-capacitor] releaseChannel detected = $releaseChannel")
170
+ }
171
+ }
172
+
173
+ // Persist the bits the FCM messaging service needs to register a
174
+ // refreshed token on its own (it runs in a separate process
175
+ // context without access to the plugin's in-memory fields).
176
+ prefs?.edit()
177
+ ?.putString("projectKey", projectKey)
178
+ ?.putString("apiBaseUrl", apiBaseUrl)
179
+ ?.putString("apiKey", apiKey)
180
+ ?.apply()
181
+
93
182
  // Fire-and-forget install referrer retrieval on init.
94
183
  fetchInstallReferrer()
95
184
 
96
185
  call.resolve()
97
186
  }
98
187
 
188
+ // ── Push notifications (FCM) ──────────────────────────────────────
189
+ @PluginMethod
190
+ fun registerForPushNotifications(call: PluginCall) {
191
+ try {
192
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
193
+ if (!task.isSuccessful) {
194
+ call.resolve(
195
+ JSObject().put("permission", "denied").put("token", null as String?),
196
+ )
197
+ return@addOnCompleteListener
198
+ }
199
+ val token = task.result
200
+ if (token.isNullOrEmpty()) {
201
+ call.resolve(
202
+ JSObject().put("permission", "denied").put("token", null as String?),
203
+ )
204
+ return@addOnCompleteListener
205
+ }
206
+ // Auto-register with Apex (same contract as iOS) so the
207
+ // dashboard sees the device immediately.
208
+ registerFcmToken(token)
209
+ notifyListeners(
210
+ "pushTokenReceived",
211
+ JSObject().put("token", token).put("platform", "android"),
212
+ )
213
+ call.resolve(
214
+ JSObject().put("permission", "granted").put("token", token),
215
+ )
216
+ }
217
+ } catch (e: Exception) {
218
+ call.reject("FCM registration failed: ${e.message ?: "unknown"}")
219
+ }
220
+ }
221
+
222
+ /**
223
+ * POST the FCM token to Apex's `/api/mobile/push-token` (same route
224
+ * the iOS plugin auto-registers against). Best-effort + background.
225
+ */
226
+ private fun registerFcmToken(token: String) {
227
+ val pk = projectKey
228
+ if (pk.isEmpty()) return
229
+ val appVersion = try {
230
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName
231
+ } catch (_: Exception) {
232
+ null
233
+ }
234
+ val payload = JSObject()
235
+ .put("projectKey", pk)
236
+ .put("visitorId", visitorId)
237
+ .put("platform", "android")
238
+ .put("token", token)
239
+ .put("bundleId", context.packageName)
240
+ .put("appVersion", appVersion ?: "")
241
+ Thread {
242
+ try {
243
+ val url = java.net.URL("${apiBaseUrl.trimEnd('/')}/api/mobile/push-token")
244
+ val conn = url.openConnection() as java.net.HttpURLConnection
245
+ conn.requestMethod = "POST"
246
+ conn.doOutput = true
247
+ conn.setRequestProperty("Content-Type", "application/json")
248
+ apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
249
+ conn.connectTimeout = 10000
250
+ conn.readTimeout = 15000
251
+ conn.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
252
+ conn.responseCode
253
+ conn.disconnect()
254
+ } catch (e: Exception) {
255
+ if (debug) println("[apex-capacitor] FCM token register failed: ${e.message}")
256
+ }
257
+ }.start()
258
+ }
259
+
260
+ /**
261
+ * MMP-061 — detect the build's `releaseChannel` from the installer
262
+ * package source + `BuildConfig.DEBUG`.
263
+ *
264
+ * Detection waterfall:
265
+ * 1. `BuildConfig.DEBUG=true` → `xcode-debug` (Gradle debug).
266
+ * Returned before installer-source so debug builds installed
267
+ * via Play Internal still report as `xcode-debug` for the
268
+ * developer's own machine.
269
+ * 2. **API 30+** uses `PackageManager.getInstallSourceInfo()` —
270
+ * doesn't require a `<queries>` declaration for the caller's
271
+ * own package, so it works regardless of manifest.
272
+ * 3. **API 29 and below** uses the deprecated
273
+ * `getInstallerPackageName()`. Works on every API level but
274
+ * requires the `<queries>` block we added in the manifest so
275
+ * the result isn't silently null on API 30+.
276
+ *
277
+ * Mapping (post-API-version split):
278
+ * - `com.android.vending` (Play Store) → `play-production`
279
+ * - `com.google.android.feedback` (legacy Play) → `play-production`
280
+ * - any other non-null installer (Amazon, Samsung) → `sideloaded`
281
+ * - `null` (sideloaded APK, adb install, unknown) → `sideloaded`
282
+ */
283
+ private fun detectReleaseChannel(): String {
284
+ // Step 1 — Gradle DEBUG build.
285
+ try {
286
+ val buildConfigClass = Class.forName("${context.packageName}.BuildConfig")
287
+ val isDebug = buildConfigClass.getField("DEBUG").getBoolean(null)
288
+ if (isDebug) return "xcode-debug"
289
+ } catch (_: Throwable) {
290
+ // BuildConfig might not be reachable in some test environments.
291
+ // Fall through to installer-source detection.
292
+ }
293
+
294
+ // Step 2 / 3 — installer source.
295
+ val installer: String? = try {
296
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
297
+ val info = context.packageManager.getInstallSourceInfo(context.packageName)
298
+ info.installingPackageName
299
+ } else {
300
+ @Suppress("DEPRECATION")
301
+ context.packageManager.getInstallerPackageName(context.packageName)
302
+ }
303
+ } catch (_: Exception) {
304
+ null
305
+ }
306
+
307
+ return when (installer) {
308
+ "com.android.vending" -> "play-production"
309
+ "com.google.android.feedback" -> "play-production"
310
+ null -> "sideloaded"
311
+ else -> "sideloaded"
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Validate a JS-supplied `releaseChannel` override. Anything we don't
317
+ * recognise gets ignored (we fall back to auto-detection). Mirrors
318
+ * the type union in `packages/capacitor/src/definitions.ts`.
319
+ */
320
+ private fun isValidReleaseChannel(channel: String): Boolean {
321
+ return when (channel) {
322
+ "xcode-debug",
323
+ "testflight",
324
+ "app-store",
325
+ "play-internal",
326
+ "play-production",
327
+ "sideloaded",
328
+ "unknown" -> true
329
+ else -> false
330
+ }
331
+ }
332
+
99
333
  // ── ATT ───────────────────────────────────────────────────────────
100
334
  @PluginMethod
101
335
  fun requestTrackingAuthorization(call: PluginCall) {
@@ -160,6 +394,67 @@ class ApexCapacitorPlugin : Plugin() {
160
394
  call.resolve()
161
395
  }
162
396
 
397
+ // ── Identity (Phase 2-SDK) ────────────────────────────────────────
398
+ //
399
+ // POSTs to `${apiBaseUrl}/api/identity/stitch` with the current
400
+ // visitor id + supplied email + optional userId/traits. The server
401
+ // resolves the email to an Apex Contact, stamps verifiedAt = now,
402
+ // and runs the verified-mode downstream pipeline (affiliate
403
+ // stamping, scoring, journey dispatch).
404
+ //
405
+ // We use a background thread (`Thread {}`) rather than coroutines
406
+ // to keep the plugin's dependency footprint minimal — adding
407
+ // kotlinx.coroutines here would bloat every consumer's APK by ~1MB.
408
+ @PluginMethod
409
+ fun identify(call: PluginCall) {
410
+ val email = call.getString("email")?.trim()
411
+ if (email.isNullOrEmpty()) {
412
+ call.reject("email is required")
413
+ return
414
+ }
415
+ val userId = call.getString("userId")
416
+ val traits = call.getObject("traits")
417
+
418
+ val payload = JSObject()
419
+ .put("visitorId", visitorId)
420
+ .put("email", email)
421
+ .put("projectKey", projectKey)
422
+ .put("timestamp", java.time.Instant.now().toString())
423
+ if (!userId.isNullOrEmpty()) {
424
+ payload.put("userId", userId)
425
+ }
426
+ if (traits != null) {
427
+ payload.put("metadata", traits)
428
+ }
429
+
430
+ Thread {
431
+ try {
432
+ val url = java.net.URL("${apiBaseUrl.trimEnd('/')}/api/identity/stitch")
433
+ val conn = url.openConnection() as java.net.HttpURLConnection
434
+ conn.requestMethod = "POST"
435
+ conn.doOutput = true
436
+ conn.setRequestProperty("Content-Type", "application/json")
437
+ conn.setRequestProperty("x-apex-source", "sdk")
438
+ conn.setRequestProperty("x-apex-project", projectKey)
439
+ apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
440
+ conn.connectTimeout = 10000
441
+ conn.readTimeout = 15000
442
+ conn.outputStream.use { os ->
443
+ os.write(payload.toString().toByteArray(Charsets.UTF_8))
444
+ }
445
+ val code = conn.responseCode
446
+ conn.disconnect()
447
+ if (code in 200..299) {
448
+ call.resolve()
449
+ } else {
450
+ call.reject("identify: server rejected (HTTP $code)")
451
+ }
452
+ } catch (e: Exception) {
453
+ call.reject("identify network error: ${e.message ?: "unknown"}")
454
+ }
455
+ }.start()
456
+ }
457
+
163
458
  // ── SKAN (iOS-only) ───────────────────────────────────────────────
164
459
  @PluginMethod
165
460
  fun updateConversionValue(call: PluginCall) {
@@ -196,6 +491,10 @@ class ApexCapacitorPlugin : Plugin() {
196
491
  .put("bundleId", context.packageName)
197
492
  .put("timezone", TimeZone.getDefault().id)
198
493
  .put("locale", localeTag)
494
+ // MMP-061 — surface release channel for sample apps and
495
+ // developer-facing UIs.
496
+ .put("releaseChannel", releaseChannel)
497
+ .put("releaseChannelSource", releaseChannelSource)
199
498
  call.resolve(data)
200
499
  }
201
500
 
@@ -237,6 +536,33 @@ class ApexCapacitorPlugin : Plugin() {
237
536
  val payload = mutableMapOf<String, Any?>()
238
537
  call.data.keys().forEach { key -> payload[key] = call.data.opt(key) }
239
538
 
539
+ // MMP-061 — stamp release channel under event.mobile so the
540
+ // server's TrackingEvent shape lands the field in the right
541
+ // slot. On Android, installer source doesn't change post-install
542
+ // (a sideload-then-Play-upgrade still reports `sideloaded`), so
543
+ // unlike iOS we don't re-detect on `app_open`.
544
+ val mobileSlot: MutableMap<String, Any?> = when (val existing = payload["mobile"]) {
545
+ is JSObject -> {
546
+ val map = mutableMapOf<String, Any?>()
547
+ existing.keys().forEach { k -> map[k] = existing.opt(k) }
548
+ map
549
+ }
550
+ is Map<*, *> -> existing.entries.associate { (k, v) -> (k as String) to v }.toMutableMap()
551
+ else -> mutableMapOf()
552
+ }
553
+ mobileSlot["releaseChannel"] = releaseChannel
554
+ mobileSlot["releaseChannelSource"] = releaseChannelSource
555
+ payload["mobile"] = mobileSlot
556
+
557
+ // MMP-081 — runtime context. Native Android plugin always
558
+ // stamps `native-android`; we don't overwrite a value the
559
+ // caller already set (preserves test fixtures and unusual
560
+ // workflows). Stamped at enqueue time so it travels with the
561
+ // event whichever sender drains the queue.
562
+ if (payload["clientType"] == null) {
563
+ payload["clientType"] = "native-android"
564
+ }
565
+
240
566
  val event = NativeQueuedEvent(
241
567
  id = id,
242
568
  payload = payload,
@@ -269,10 +595,22 @@ class ApexCapacitorPlugin : Plugin() {
269
595
  @PluginMethod
270
596
  fun flushQueue(call: PluginCall) {
271
597
  val queue = offlineQueue
272
- val data = JSObject()
273
- .put("flushed", 0)
274
- .put("remaining", queue?.size() ?: 0)
275
- call.resolve(data)
598
+ val sender = batchSender
599
+ if (queue == null || sender == null) {
600
+ // Plugin not initialized — preserve the prior no-op shape so
601
+ // callers don't have to special-case this branch.
602
+ val data = JSObject()
603
+ .put("flushed", 0)
604
+ .put("remaining", 0)
605
+ call.resolve(data)
606
+ return
607
+ }
608
+ sender.flush(queue) { flushed, remaining ->
609
+ val data = JSObject()
610
+ .put("flushed", flushed)
611
+ .put("remaining", remaining)
612
+ call.resolve(data)
613
+ }
276
614
  }
277
615
 
278
616
  @PluginMethod
@@ -304,6 +642,65 @@ class ApexCapacitorPlugin : Plugin() {
304
642
  override fun onInstallReferrerServiceDisconnected() { }
305
643
  })
306
644
  }
645
+
646
+ companion object {
647
+ /**
648
+ * Weak-ish reference to the live plugin so the FCM messaging
649
+ * service (a separate Android component) can forward a refreshed
650
+ * token / a tap to JS listeners when the webview is alive.
651
+ */
652
+ @Volatile
653
+ var activePlugin: ApexCapacitorPlugin? = null
654
+
655
+ /**
656
+ * Register a (possibly refreshed) FCM token with Apex from the
657
+ * messaging service. Reads the persisted projectKey / apiBaseUrl
658
+ * / apiKey / visitorId from SharedPreferences so it works even
659
+ * when no plugin instance is alive (background token refresh).
660
+ */
661
+ fun registerTokenFromService(appContext: Context, token: String) {
662
+ val prefs = appContext.getSharedPreferences(
663
+ "apex-capacitor",
664
+ Context.MODE_PRIVATE,
665
+ )
666
+ val projectKey = prefs.getString("projectKey", null) ?: return
667
+ val apiBaseUrl = prefs.getString("apiBaseUrl", null) ?: "https://api.apex.inc"
668
+ val apiKey = prefs.getString("apiKey", null)
669
+ val visitorId = prefs.getString("visitorId", null) ?: return
670
+
671
+ val payload = JSObject()
672
+ .put("projectKey", projectKey)
673
+ .put("visitorId", visitorId)
674
+ .put("platform", "android")
675
+ .put("token", token)
676
+ .put("bundleId", appContext.packageName)
677
+ Thread {
678
+ try {
679
+ val url = java.net.URL("${apiBaseUrl.trimEnd('/')}/api/mobile/push-token")
680
+ val conn = url.openConnection() as java.net.HttpURLConnection
681
+ conn.requestMethod = "POST"
682
+ conn.doOutput = true
683
+ conn.setRequestProperty("Content-Type", "application/json")
684
+ apiKey?.let { conn.setRequestProperty("x-apex-api-key", it) }
685
+ conn.connectTimeout = 10000
686
+ conn.readTimeout = 15000
687
+ conn.outputStream.use {
688
+ it.write(payload.toString().toByteArray(Charsets.UTF_8))
689
+ }
690
+ conn.responseCode
691
+ conn.disconnect()
692
+ } catch (_: Exception) {
693
+ // best-effort
694
+ }
695
+ }.start()
696
+
697
+ // If the plugin is alive, surface the refreshed token to JS.
698
+ activePlugin?.notifyListeners(
699
+ "pushTokenReceived",
700
+ JSObject().put("token", token).put("platform", "android"),
701
+ )
702
+ }
703
+ }
307
704
  }
308
705
 
309
706
  /**
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Apex Spec — canonical event name constants for the Android SDK.
3
+ *
4
+ * Hand-authored from `app/src/lib/core/apex-spec/events.ts`. Kept in sync
5
+ * via the parity test at
6
+ * `app/src/lib/core/apex-spec/__tests__/sdk-parity.test.ts` — CI fails if a
7
+ * canonical name is added/renamed/removed in the registry without a matching
8
+ * update here.
9
+ *
10
+ * Usage from Kotlin:
11
+ *
12
+ * plugin.track(type = ApexEvents.ADD_TO_CART, data = mapOf("product_id" to "sku_123"))
13
+ *
14
+ * Each constant is the unversioned canonical name as the developer writes it
15
+ * in `track()` calls. Versions are an internal storage primitive; the SDK
16
+ * never carries a version field.
17
+ */
18
+
19
+ package inc.apex.capacitor
20
+
21
+ object ApexEvents {
22
+ // ─── Commerce ───────────────────────────────────────────────────────
23
+ const val ADD_TO_CART = "add_to_cart"
24
+ const val REMOVE_FROM_CART = "remove_from_cart"
25
+ const val PRODUCT_VIEW = "product_view"
26
+ const val ADD_TO_WISHLIST = "add_to_wishlist"
27
+ const val CHECKOUT_STARTED = "checkout_started"
28
+ const val IN_APP_PURCHASE = "in_app_purchase"
29
+ const val PURCHASE_REFUNDED = "purchase_refunded"
30
+ const val SUBSCRIPTION_EVENT = "subscription_event"
31
+ const val ORDER_PLACED = "order_placed"
32
+ const val FIRST_SALE_COMPLETED = "first_sale_completed"
33
+
34
+ // ─── Marketing (merchant-fired triggers) ────────────────────────────
35
+ const val PRICE_DROPPED = "price_dropped"
36
+ const val RESTOCK_DUE = "restock_due"
37
+ const val REVIEW_REQUEST_DUE = "review_request_due"
38
+
39
+ // ─── Identity ───────────────────────────────────────────────────────
40
+ const val USER_SIGNED_UP = "user_signed_up"
41
+ const val USER_SIGNED_IN = "user_signed_in"
42
+ const val USER_SIGNED_OUT = "user_signed_out"
43
+ const val USER_IDENTIFIED = "user_identified"
44
+
45
+ // ─── Lifecycle ──────────────────────────────────────────────────────
46
+ const val APP_INSTALL = "app_install"
47
+ const val APP_OPEN = "app_open"
48
+ const val APP_BACKGROUND = "app_background"
49
+ const val APP_UNINSTALL = "app_uninstall"
50
+ const val APP_REINSTALL = "app_reinstall"
51
+
52
+ // ─── Session ────────────────────────────────────────────────────────
53
+ const val SESSION_START = "session_start"
54
+ const val SESSION_END = "session_end"
55
+
56
+ // ─── Engagement ─────────────────────────────────────────────────────
57
+ const val PAGE_VIEW = "page_view"
58
+ const val PAGEVIEW = "pageview"
59
+ const val SCREEN_VIEW = "screen_view"
60
+ const val SEARCH = "search"
61
+ const val SHARE = "share"
62
+ const val CONTENT_VIEW = "content_view"
63
+ const val FORM_SUBMIT = "form_submit"
64
+ const val CLICK = "click"
65
+ const val ENGAGEMENT = "engagement"
66
+ const val HEARTBEAT = "heartbeat"
67
+ const val RAGE_CLICK = "rage_click"
68
+ const val DEAD_CLICK = "dead_click"
69
+ const val GOAL_CONVERSION = "goal_conversion"
70
+ const val ONBOARDING_STEP_COMPLETED = "onboarding_step_completed"
71
+
72
+ // ─── Product (PLG lifecycle outcomes) ───────────────────────────────
73
+ const val ONBOARDING_COMPLETED = "onboarding_completed"
74
+ const val ACTIVATED = "activated"
75
+
76
+ // ─── Communication ──────────────────────────────────────────────────
77
+ const val PUSH_RECEIVED = "push_received"
78
+ const val PUSH_OPENED = "push_opened"
79
+ const val EMAIL_OPENED = "email_opened"
80
+ const val EMAIL_CLICKED = "email_clicked"
81
+ const val IN_APP_MESSAGE_SEEN = "in_app_message_seen"
82
+ const val IN_APP_MESSAGE_CLICKED = "in_app_message_clicked"
83
+
84
+ // ─── Deep link ──────────────────────────────────────────────────────
85
+ const val DEEP_LINK_OPEN = "deep_link_open"
86
+
87
+ // ─── Attribution ────────────────────────────────────────────────────
88
+ const val REATTRIBUTION = "reattribution"
89
+ const val REENGAGEMENT = "reengagement"
90
+
91
+ // ─── Smart banner ───────────────────────────────────────────────────
92
+ const val SMART_BANNER_IMPRESSION = "smart_banner_impression"
93
+ const val SMART_BANNER_CLICK = "smart_banner_click"
94
+ const val SMART_BANNER_DISMISS = "smart_banner_dismiss"
95
+
96
+ // ─── System ─────────────────────────────────────────────────────────
97
+ const val CUSTOM = "custom"
98
+ const val LIFECYCLE_TRANSITION = "lifecycle_transition"
99
+ const val JS_ERROR = "js_error"
100
+
101
+ /** Apex Spec semver this SDK implements. */
102
+ const val SPEC_VERSION = "1.1.0"
103
+
104
+ /** Complete list of every canonical event name. */
105
+ val ALL: List<String> = listOf(
106
+ ADD_TO_CART, REMOVE_FROM_CART, PRODUCT_VIEW, ADD_TO_WISHLIST, CHECKOUT_STARTED,
107
+ IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT,
108
+ USER_SIGNED_UP, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
109
+ APP_INSTALL, APP_OPEN, APP_BACKGROUND, APP_UNINSTALL, APP_REINSTALL,
110
+ SESSION_START, SESSION_END,
111
+ PAGE_VIEW, PAGEVIEW, SCREEN_VIEW, SEARCH, SHARE, CONTENT_VIEW, FORM_SUBMIT,
112
+ CLICK, ENGAGEMENT, HEARTBEAT, RAGE_CLICK, DEAD_CLICK, GOAL_CONVERSION,
113
+ ONBOARDING_STEP_COMPLETED,
114
+ ONBOARDING_COMPLETED, ACTIVATED,
115
+ PUSH_RECEIVED, PUSH_OPENED, EMAIL_OPENED, EMAIL_CLICKED,
116
+ IN_APP_MESSAGE_SEEN, IN_APP_MESSAGE_CLICKED,
117
+ DEEP_LINK_OPEN,
118
+ REATTRIBUTION, REENGAGEMENT,
119
+ SMART_BANNER_IMPRESSION, SMART_BANNER_CLICK, SMART_BANNER_DISMISS,
120
+ CUSTOM, LIFECYCLE_TRANSITION, JS_ERROR,
121
+ )
122
+ }