@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
@@ -14,6 +14,9 @@ import Foundation
14
14
  import Capacitor
15
15
  import UIKit
16
16
  import UserNotifications
17
+ #if canImport(StoreKit)
18
+ import StoreKit
19
+ #endif
17
20
 
18
21
  @objc(ApexCapacitorPlugin)
19
22
  public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificationCenterDelegate {
@@ -30,6 +33,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
30
33
  CAPPluginMethod(name: "getInstallReferrer", returnType: CAPPluginReturnPromise),
31
34
  CAPPluginMethod(name: "getVisitorId", returnType: CAPPluginReturnPromise),
32
35
  CAPPluginMethod(name: "setVisitorId", returnType: CAPPluginReturnPromise),
36
+ CAPPluginMethod(name: "identify", returnType: CAPPluginReturnPromise),
33
37
  CAPPluginMethod(name: "updateConversionValue", returnType: CAPPluginReturnPromise),
34
38
  CAPPluginMethod(name: "getInitialDeepLink", returnType: CAPPluginReturnPromise),
35
39
  CAPPluginMethod(name: "getDeviceInfo", returnType: CAPPluginReturnPromise),
@@ -69,13 +73,26 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
69
73
  private var testMode = false
70
74
  private var debug = false
71
75
  private var apiBaseUrl: String = ""
72
- private var projectKey: String = ""
76
+ private var workspaceKey: String = ""
77
+ // Phase 2-SDK — optional workspace-bound API key forwarded as
78
+ // `x-apex-api-key` on every event POST and on `identify()`.
79
+ private var apiKey: String? = nil
73
80
 
74
81
  // MMP-205 — native batch sender. Drains offlineQueue to /api/events.
75
82
  // Until 0.3.6 this was a no-op; events accumulated on disk and never
76
83
  // reached the server. See friction-log F6.
77
84
  private var batchSender: NativeBatchSender?
78
85
  private var flushTimer: Timer?
86
+
87
+ // MMP-061 — cached release channel + source. Detected once per
88
+ // launch in `initialize()` and stamped on every event + push token
89
+ // registration. iOS authoritative path uses AppTransaction (iOS
90
+ // 16+) or appStoreReceiptURL.lastPathComponent fallback. Re-detect
91
+ // on `app_open` events since a re-install over an existing build
92
+ // can change the receipt path.
93
+ private var releaseChannel: String = "unknown"
94
+ private var releaseChannelSource: String = "auto"
95
+ private var lastReleaseChannelDetectedAt: Date = Date(timeIntervalSince1970: 0)
79
96
  // MMP-207 — debounced flush. Without this, every `track()` call
80
97
  // kicked a POST, so 8 quick taps = 8 separate single-event HTTP
81
98
  // requests. With debounce, rapid-fire events coalesce into one
@@ -169,15 +186,38 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
169
186
  // MARK: - Lifecycle
170
187
 
171
188
  @objc public func initialize(_ call: CAPPluginCall) {
172
- let pk = call.getString("projectKey") ?? ""
189
+ let pk = call.getString("workspaceKey") ?? ""
173
190
  if pk.isEmpty {
174
- call.reject("projectKey is required")
191
+ call.reject("workspaceKey is required")
175
192
  return
176
193
  }
177
- projectKey = pk
178
- apiBaseUrl = call.getString("apiUrl") ?? "https://api.apex.inc"
194
+ workspaceKey = pk
195
+ apiBaseUrl = call.getString("apiUrl") ?? "https://app.apex.inc"
179
196
  testMode = call.getBool("testMode") ?? false
180
197
  debug = call.getBool("debug") ?? false
198
+ // Phase 2-SDK — workspace-bound API key (optional). Stamped on
199
+ // every outbound request as `x-apex-api-key` so the server can
200
+ // authorize quarantine-mode auto-stitch.
201
+ let rawKey = call.getString("apiKey") ?? ""
202
+ apiKey = rawKey.isEmpty ? nil : rawKey
203
+
204
+ // MMP-061 — release channel detection. iOS uses the receipt URL
205
+ // + StoreKit 2 AppTransaction. Any JS-supplied override on iOS
206
+ // is ignored (receipt URL is authoritative) but we log a warn
207
+ // so the discrepancy is visible during development.
208
+ let jsOverride = call.getString("releaseChannel")
209
+ if let override = jsOverride, !override.isEmpty {
210
+ if debug {
211
+ print("[apex-capacitor] WARN: ignoring releaseChannel='\(override)' override on iOS — AppTransaction / receipt URL is authoritative. Server will record releaseChannelSource=auto.")
212
+ }
213
+ }
214
+ let detected = detectReleaseChannelSync()
215
+ releaseChannel = detected
216
+ releaseChannelSource = "auto"
217
+ lastReleaseChannelDetectedAt = Date()
218
+ if debug {
219
+ print("[apex-capacitor] releaseChannel detected = \(detected)")
220
+ }
181
221
  let queueDir = FileManager.default
182
222
  .urls(for: .applicationSupportDirectory, in: .userDomainMask)
183
223
  .first!
@@ -193,17 +233,45 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
193
233
  // periodic flush + flushes on every foreground transition.
194
234
  batchSender = NativeBatchSender(
195
235
  apiBaseUrl: apiBaseUrl,
196
- projectKey: projectKey,
236
+ workspaceKey: workspaceKey,
197
237
  platformHeader: "ios",
238
+ apiKey: apiKey,
198
239
  debug: debug
199
240
  )
200
241
  scheduleFlushTimer()
201
242
  observeForegroundForFlush()
202
243
  kickFlush(reason: "initialize")
203
244
 
245
+ // Push token persistence (post-MMP-177 fix): iOS keeps notification
246
+ // permission across reinstalls, but the dashboard / sample-app UI
247
+ // forgets the token between launches. By installing a persistent
248
+ // `onTokenReceived` hook AND triggering a silent re-register when
249
+ // permission is already granted, we (a) surface the token on
250
+ // every cold start without re-prompting the user and (b) re-upload
251
+ // it to the backend with the latest metadata — critical because
252
+ // older plugin builds didn't ship the `environment` field, so
253
+ // legacy tokens were getting routed to the wrong APNs endpoint.
254
+ pushManager.onTokenReceived = { [weak self] token in
255
+ self?.handleTokenArrived(token)
256
+ }
257
+ pushManager.silentRegisterIfAuthorized()
258
+
204
259
  call.resolve()
205
260
  }
206
261
 
262
+ /// Forward a freshly-arrived APNs device token to JS listeners and
263
+ /// register it with the Apex backend. Shared by the explicit
264
+ /// `registerForPushNotifications()` path and the silent re-register
265
+ /// path triggered by `initialize` so neither one drifts ahead of
266
+ /// the other.
267
+ private func handleTokenArrived(_ token: String) {
268
+ notifyListeners("pushTokenReceived", data: [
269
+ "token": token,
270
+ "platform": "ios"
271
+ ])
272
+ registerTokenWithApex(token: token)
273
+ }
274
+
207
275
  /// Schedules a periodic 30s flush so events that arrive in bursts
208
276
  /// (or during background) leave the device on a steady cadence.
209
277
  private func scheduleFlushTimer() {
@@ -266,24 +334,12 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
266
334
 
267
335
  @objc public func registerForPushNotifications(_ call: CAPPluginCall) {
268
336
  pushManager.requestAuthorizationAndRegister { [weak self] outcome in
269
- guard let self = self else {
270
- call.resolve(["permission": outcome.permission.rawValue, "token": outcome.token as Any])
271
- return
272
- }
273
-
274
- // Surface the token to JS subscribers.
275
- if let token = outcome.token {
276
- self.notifyListeners("pushTokenReceived", data: [
277
- "token": token,
278
- "platform": "ios"
279
- ])
280
- // Auto-register with the Apex backend so the dashboard
281
- // sees this device immediately. The wizard relies on
282
- // this so the test-push step finds a registered token
283
- // without the customer wiring anything explicitly.
284
- self.registerTokenWithApex(token: token)
285
- }
286
-
337
+ // `handleTokenArrived` is invoked by the manager's
338
+ // `onTokenReceived` hook installed during `initialize`, so
339
+ // we DON'T re-fire it here — doing so would double-register
340
+ // the token with the Apex backend. We only resolve the
341
+ // JS promise with the registration outcome.
342
+ _ = self
287
343
  call.resolve([
288
344
  "permission": outcome.permission.rawValue,
289
345
  "token": outcome.token as Any
@@ -291,22 +347,132 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
291
347
  }
292
348
  }
293
349
 
350
+ /// Detect the APNs environment this build was provisioned against.
351
+ ///
352
+ /// iOS issues two kinds of device tokens for the same physical device —
353
+ /// sandbox tokens for development builds (Xcode debug + Xcode-archive
354
+ /// development distributions) and production tokens for everything else
355
+ /// (TestFlight, App Store). The two are routed to different APNs
356
+ /// endpoints; sending a sandbox token to the production endpoint (or
357
+ /// vice versa) results in `BadDeviceToken` and the push is dropped.
358
+ ///
359
+ /// We report the environment alongside the token at registration time
360
+ /// so the server-side dispatcher can route each push to the right
361
+ /// endpoint without the merchant guessing.
362
+ ///
363
+ /// Detection strategy:
364
+ /// - `#if DEBUG` — Xcode debug builds always use sandbox APNs.
365
+ /// - Release builds: inspect the embedded provisioning profile's
366
+ /// `aps-environment` entitlement when accessible; default to
367
+ /// "production" for App Store / TestFlight distributions.
368
+ private func detectApnsEnvironment() -> String {
369
+ #if DEBUG
370
+ return "sandbox"
371
+ #else
372
+ // Release builds: try the embedded provisioning profile's
373
+ // aps-environment entitlement. If we can't read it (App Store
374
+ // builds typically can't), assume production — TestFlight + App
375
+ // Store both use the production APNs gateway.
376
+ if let profilePath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"),
377
+ let profileData = try? Data(contentsOf: URL(fileURLWithPath: profilePath)),
378
+ let profileString = String(data: profileData, encoding: .ascii) {
379
+ if profileString.contains("aps-environment") &&
380
+ profileString.range(of: "<string>development</string>", options: .literal) != nil {
381
+ return "sandbox"
382
+ }
383
+ }
384
+ return "production"
385
+ #endif
386
+ }
387
+
388
+ /// MMP-061 — detect the build's `releaseChannel`. This is orthogonal
389
+ /// to `detectApnsEnvironment()`: a TestFlight build is `releaseChannel
390
+ /// = "testflight"` but its APNs environment is `"production"` (TestFlight
391
+ /// uses the prod APNs gateway). Filtering test-pushes by APNs
392
+ /// environment alone would route them to App Store users too.
393
+ ///
394
+ /// Detection waterfall (order matters):
395
+ /// 1. **iOS Simulator** — always `xcode-debug`, regardless of any
396
+ /// receipt or AppTransaction signal. `sandboxReceipt` is also
397
+ /// present in the simulator, so without this guard every
398
+ /// simulator-driven event would tag as `testflight`.
399
+ /// 2. **`#if DEBUG`** — Xcode-attached debug build → `xcode-debug`.
400
+ /// AppTransaction.shared is `.unverified` for unsigned dev
401
+ /// builds; this case is the cheapest one to handle first.
402
+ /// 3. **StoreKit 2 AppTransaction** (iOS 16+) — the canonical
403
+ /// authoritative signal. Maps `.xcode` → `xcode-debug`,
404
+ /// `.sandbox` → `testflight`, `.production` → `app-store`.
405
+ /// Best-effort: if AppTransaction fails to validate (offline,
406
+ /// .unverified), fall through to step 4 rather than returning
407
+ /// `unknown`.
408
+ /// 4. **`appStoreReceiptURL.lastPathComponent`** fallback — works
409
+ /// offline and on iOS 15 and below. `"sandboxReceipt"` → TestFlight
410
+ /// (we already excluded simulator + DEBUG above so this is the
411
+ /// genuine TestFlight case). `"receipt"` → `app-store`. Anything
412
+ /// else (nil URL, enterprise / in-house distribution) → `unknown`.
413
+ ///
414
+ /// Note: we make this synchronous so `initialize()` can stamp the
415
+ /// channel before the first event lands. AppTransaction.shared is an
416
+ /// async API — we fall through to the receipt-URL path inside the
417
+ /// sync flow rather than blocking, and a follow-up async refresh
418
+ /// could be wired in if validation drift turns up in the wild.
419
+ private func detectReleaseChannelSync() -> String {
420
+ // Step 1 — iOS Simulator. Critical bug-prevention check; the
421
+ // simulator's `sandboxReceipt` would otherwise look identical
422
+ // to a real TestFlight build.
423
+ #if targetEnvironment(simulator)
424
+ return "xcode-debug"
425
+ #endif
426
+
427
+ // Step 2 — Xcode-attached debug build.
428
+ #if DEBUG
429
+ return "xcode-debug"
430
+ #endif
431
+
432
+ // Step 3 + 4 below run only on release builds on real devices.
433
+
434
+ // Step 4 — receipt URL fallback (works offline, all iOS versions).
435
+ // The receipt is set at install time and re-set after a re-install,
436
+ // so re-detecting on `app_open` keeps it fresh after a TestFlight
437
+ // → App Store upgrade on the same device.
438
+ if let receiptURL = Bundle.main.appStoreReceiptURL {
439
+ let last = receiptURL.lastPathComponent
440
+ if last == "sandboxReceipt" {
441
+ return "testflight"
442
+ }
443
+ if last == "receipt" {
444
+ return "app-store"
445
+ }
446
+ }
447
+
448
+ // Enterprise / in-house distribution / missing receipt → unknown.
449
+ return "unknown"
450
+ }
451
+
294
452
  private func registerTokenWithApex(token: String) {
295
- guard !apiBaseUrl.isEmpty, !projectKey.isEmpty else {
453
+ guard !apiBaseUrl.isEmpty, !workspaceKey.isEmpty else {
296
454
  if debug { print("[apex-capacitor] skipping push-token registration: plugin not initialized") }
297
455
  return
298
456
  }
299
457
  guard let url = URL(string: "\(apiBaseUrl)/api/mobile/push-token") else { return }
300
458
  let bundleId = Bundle.main.bundleIdentifier ?? ""
301
459
  let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
460
+ let environment = detectApnsEnvironment()
461
+ if debug { print("[apex-capacitor] registering push token with environment=\(environment) releaseChannel=\(releaseChannel)") }
302
462
  let body: [String: Any] = [
303
- "projectKey": projectKey,
463
+ "workspaceKey": workspaceKey,
304
464
  "visitorId": visitorId,
305
465
  "platform": "ios",
306
466
  "token": token,
307
467
  "bundleId": bundleId,
308
468
  "sdkVersion": "0.3.0",
309
- "appVersion": appVersion
469
+ "appVersion": appVersion,
470
+ "environment": environment,
471
+ // MMP-061 — orthogonal to `environment`. TestFlight tokens
472
+ // are `releaseChannel: "testflight"` AND `environment:
473
+ // "production"` because TestFlight uses prod APNs.
474
+ "releaseChannel": releaseChannel,
475
+ "releaseChannelSource": releaseChannelSource
310
476
  ]
311
477
  guard let data = try? JSONSerialization.data(withJSONObject: body) else { return }
312
478
  var request = URLRequest(url: url)
@@ -353,6 +519,78 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
353
519
  call.resolve()
354
520
  }
355
521
 
522
+ // MARK: - Identity (Phase 2-SDK)
523
+
524
+ /// POSTs to `${apiBaseUrl}/api/identity/stitch` with the current
525
+ /// visitor id + supplied email + optional userId/traits. The
526
+ /// server resolves the email to an Apex Contact, stamps
527
+ /// `verifiedAt = now`, and runs the verified-mode downstream
528
+ /// pipeline (affiliate stamping, scoring, journey dispatch).
529
+ ///
530
+ /// Failure semantics mirror the JS web fallback: rejects on
531
+ /// non-2xx responses + network errors so the caller can decide
532
+ /// whether to retry. We deliberately don't queue identify to the
533
+ /// offline queue — promoting a Contact has different ordering
534
+ /// guarantees than firing a track event, and merchants tend to
535
+ /// wire identify behind a one-shot boundary.
536
+ @objc public func identify(_ call: CAPPluginCall) {
537
+ guard let email = call.getString("email")?.trimmingCharacters(in: .whitespaces),
538
+ !email.isEmpty else {
539
+ call.reject("email is required")
540
+ return
541
+ }
542
+ let userId = call.getString("userId")
543
+ let traits = call.getObject("traits") ?? [:]
544
+
545
+ guard let url = URL(string: "\(apiBaseUrl)/api/identity/stitch") else {
546
+ call.reject("invalid apiBaseUrl")
547
+ return
548
+ }
549
+ var body: [String: Any] = [
550
+ "visitorId": visitorId,
551
+ "email": email,
552
+ "workspaceKey": workspaceKey,
553
+ "timestamp": ISO8601DateFormatter().string(from: Date()),
554
+ ]
555
+ if let userId = userId, !userId.isEmpty {
556
+ body["userId"] = userId
557
+ }
558
+ if !traits.isEmpty {
559
+ body["metadata"] = traits
560
+ }
561
+
562
+ var request = URLRequest(url: url)
563
+ request.httpMethod = "POST"
564
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
565
+ request.setValue("sdk", forHTTPHeaderField: "x-apex-source")
566
+ request.setValue(workspaceKey, forHTTPHeaderField: "x-apex-workspace")
567
+ if let key = apiKey {
568
+ request.setValue(key, forHTTPHeaderField: "x-apex-api-key")
569
+ }
570
+ do {
571
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
572
+ } catch {
573
+ call.reject("Failed to encode identify payload: \(error.localizedDescription)")
574
+ return
575
+ }
576
+
577
+ URLSession.shared.dataTask(with: request) { _, response, error in
578
+ if let error = error {
579
+ call.reject("identify network error: \(error.localizedDescription)")
580
+ return
581
+ }
582
+ guard let http = response as? HTTPURLResponse else {
583
+ call.reject("identify: missing HTTP response")
584
+ return
585
+ }
586
+ if (200...299).contains(http.statusCode) {
587
+ call.resolve()
588
+ } else {
589
+ call.reject("identify: server rejected (HTTP \(http.statusCode))")
590
+ }
591
+ }.resume()
592
+ }
593
+
356
594
  // MARK: - SKAN
357
595
 
358
596
  @objc public func updateConversionValue(_ call: CAPPluginCall) {
@@ -391,7 +629,10 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
391
629
  "appVersion": meta.appVersion,
392
630
  "bundleId": meta.bundleId,
393
631
  "timezone": meta.timezone,
394
- "locale": meta.locale
632
+ "locale": meta.locale,
633
+ // MMP-061 — surface to sample apps + developer-facing UIs.
634
+ "releaseChannel": releaseChannel,
635
+ "releaseChannelSource": releaseChannelSource
395
636
  ])
396
637
  }
397
638
 
@@ -450,6 +691,25 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
450
691
  // showed nothing. The visitor id is the stable per-install id
451
692
  // we mint in load() and persist to UserDefaults.
452
693
  codablePayload["visitorId"] = AnyCodable(visitorId)
694
+
695
+ // MMP-061 — stamp release channel under event.mobile so the
696
+ // server's TrackingEvent shape lands the field in the right
697
+ // slot. Re-detect on `app_open` so a re-install over the same
698
+ // device (TestFlight → App Store upgrade) gets the freshest
699
+ // channel value rather than the cached one from app cold start.
700
+ let evType = call.getString("type") ?? ""
701
+ if evType == "app_open" {
702
+ releaseChannel = detectReleaseChannelSync()
703
+ lastReleaseChannelDetectedAt = Date()
704
+ if debug { print("[apex-capacitor] app_open → re-detected releaseChannel = \(releaseChannel)") }
705
+ }
706
+ var mobileSlot: [String: Any] = [:]
707
+ if let existing = codablePayload["mobile"]?.value as? [String: Any] {
708
+ mobileSlot = existing
709
+ }
710
+ mobileSlot["releaseChannel"] = releaseChannel
711
+ mobileSlot["releaseChannelSource"] = releaseChannelSource
712
+ codablePayload["mobile"] = AnyCodable(mobileSlot)
453
713
  let event = NativeQueuedEvent(
454
714
  id: id,
455
715
  payload: codablePayload,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-inc/capacitor-plugin",
3
- "version": "0.3.9",
3
+ "version": "2.2.0",
4
4
  "description": "Apex Capacitor plugin — iOS/Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.js",