@apex-inc/capacitor-plugin 0.3.9 → 2.2.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 (73) hide show
  1. package/README.md +5 -5
  2. package/android/build.gradle +4 -2
  3. package/android/src/main/AndroidManifest.xml +35 -3
  4. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +404 -7
  5. package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +122 -0
  6. package/android/src/main/java/inc/apex/capacitor/ApexFirebaseMessagingService.kt +99 -0
  7. package/android/src/main/java/inc/apex/capacitor/NativeBatchSender.kt +260 -0
  8. package/dist/batch-sender.d.ts +16 -4
  9. package/dist/batch-sender.d.ts.map +1 -1
  10. package/dist/batch-sender.js +29 -4
  11. package/dist/batch-sender.js.map +1 -1
  12. package/dist/cart-helpers.d.ts +63 -0
  13. package/dist/cart-helpers.d.ts.map +1 -0
  14. package/dist/cart-helpers.js +50 -0
  15. package/dist/cart-helpers.js.map +1 -0
  16. package/dist/definitions.d.ts +150 -5
  17. package/dist/definitions.d.ts.map +1 -1
  18. package/dist/esm/batch-sender.d.ts +16 -4
  19. package/dist/esm/batch-sender.d.ts.map +1 -1
  20. package/dist/esm/batch-sender.js +29 -4
  21. package/dist/esm/batch-sender.js.map +1 -1
  22. package/dist/esm/cart-helpers.d.ts +63 -0
  23. package/dist/esm/cart-helpers.d.ts.map +1 -0
  24. package/dist/esm/cart-helpers.js +44 -0
  25. package/dist/esm/cart-helpers.js.map +1 -0
  26. package/dist/esm/definitions.d.ts +150 -5
  27. package/dist/esm/definitions.d.ts.map +1 -1
  28. package/dist/esm/events.d.ts +85 -0
  29. package/dist/esm/events.d.ts.map +1 -0
  30. package/dist/esm/events.js +96 -0
  31. package/dist/esm/events.js.map +1 -0
  32. package/dist/esm/index.d.ts +5 -6
  33. package/dist/esm/index.d.ts.map +1 -1
  34. package/dist/esm/index.js +20 -2
  35. package/dist/esm/index.js.map +1 -1
  36. package/dist/esm/offline-queue.d.ts +15 -0
  37. package/dist/esm/offline-queue.d.ts.map +1 -1
  38. package/dist/esm/offline-queue.js +35 -0
  39. package/dist/esm/offline-queue.js.map +1 -1
  40. package/dist/esm/screen-view.d.ts +18 -0
  41. package/dist/esm/screen-view.d.ts.map +1 -0
  42. package/dist/esm/screen-view.js +28 -0
  43. package/dist/esm/screen-view.js.map +1 -0
  44. package/dist/esm/web.d.ts +29 -1
  45. package/dist/esm/web.d.ts.map +1 -1
  46. package/dist/esm/web.js +167 -2
  47. package/dist/esm/web.js.map +1 -1
  48. package/dist/events.d.ts +85 -0
  49. package/dist/events.d.ts.map +1 -0
  50. package/dist/events.js +99 -0
  51. package/dist/events.js.map +1 -0
  52. package/dist/index.d.ts +5 -6
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -3
  55. package/dist/index.js.map +1 -1
  56. package/dist/offline-queue.d.ts +15 -0
  57. package/dist/offline-queue.d.ts.map +1 -1
  58. package/dist/offline-queue.js +35 -0
  59. package/dist/offline-queue.js.map +1 -1
  60. package/dist/screen-view.d.ts +18 -0
  61. package/dist/screen-view.d.ts.map +1 -0
  62. package/dist/screen-view.js +31 -0
  63. package/dist/screen-view.js.map +1 -0
  64. package/dist/web.d.ts +29 -1
  65. package/dist/web.d.ts.map +1 -1
  66. package/dist/web.js +167 -2
  67. package/dist/web.js.map +1 -1
  68. package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +124 -0
  69. package/ios/Sources/ApexCapacitorPlugin/BatchSender.swift +24 -6
  70. package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +19 -0
  71. package/ios/Sources/ApexCapacitorPlugin/PushNotificationManager.swift +47 -0
  72. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +288 -28
  73. package/package.json +1 -1
package/README.md CHANGED
@@ -23,9 +23,9 @@ Call `initialize()` once at app startup, before any other plugin method.
23
23
  import { Apex } from "@apex-inc/capacitor-plugin";
24
24
 
25
25
  await Apex.initialize({
26
- projectKey: "prj_your_key",
26
+ workspaceKey: "prj_your_key",
27
27
  // Optional:
28
- // apiUrl: "https://api.apex.inc",
28
+ // apiUrl: "https://app.apex.inc",
29
29
  // sessionTimeoutMinutes: 30,
30
30
  // offlineQueueMaxSize: 1000,
31
31
  // testMode: false,
@@ -117,7 +117,7 @@ extension AppDelegate {
117
117
  Then enable the **Push Notifications** capability in Xcode (Signing &
118
118
  Capabilities → + → Push Notifications). Sandbox builds (Xcode-built
119
119
  installs to a real device) hit Apple's sandbox APNs endpoint —
120
- configure that environment in your Apex project settings.
120
+ configure that environment in your Apex workspace settings.
121
121
 
122
122
  ## iOS App Tracking Transparency
123
123
 
@@ -185,7 +185,7 @@ const { flushed, remaining } = await Apex.flushQueue();
185
185
 
186
186
  ```ts
187
187
  // At init:
188
- await Apex.initialize({ projectKey: "prj_test", testMode: true });
188
+ await Apex.initialize({ workspaceKey: "prj_test", testMode: true });
189
189
 
190
190
  // Or toggle at runtime:
191
191
  await Apex.setTestMode({ enabled: true });
@@ -199,4 +199,4 @@ The plugin runs in a browser or PWA too — identifiers return null, ATT is a no
199
199
 
200
200
  ## License
201
201
 
202
- Apache-2.0 — see [LICENSE](./LICENSE). See also the project [plan document](../../plans/mobile-measurement-platform.md) for the full MMP roadmap.
202
+ Apache-2.0 — see [LICENSE](./LICENSE). See also the workspace [plan document](../../plans/mobile-measurement-platform.md) for the full MMP roadmap.
@@ -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://app.apex.inc"
61
+ private var workspaceKey: 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()) {
81
- call.reject("projectKey is required")
121
+ val pk = call.getString("workspaceKey")
122
+ if (pk.isNullOrEmpty()) {
123
+ call.reject("workspaceKey is required")
82
124
  return
83
125
  }
126
+ workspaceKey = pk
84
127
  testMode = call.getBoolean("testMode", false) ?: false
85
128
  debug = call.getBoolean("debug", false) ?: false
129
+ apiBaseUrl = call.getString("apiUrl") ?: "https://app.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
+ workspaceKey = workspaceKey,
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("workspaceKey", workspaceKey)
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 = workspaceKey
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("workspaceKey", 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("workspaceKey", workspaceKey)
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-workspace", workspaceKey)
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 workspaceKey / 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 workspaceKey = prefs.getString("workspaceKey", null) ?: return
667
+ val apiBaseUrl = prefs.getString("apiBaseUrl", null) ?: "https://app.apex.inc"
668
+ val apiKey = prefs.getString("apiKey", null)
669
+ val visitorId = prefs.getString("visitorId", null) ?: return
670
+
671
+ val payload = JSObject()
672
+ .put("workspaceKey", workspaceKey)
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
  /**