@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
@@ -60,6 +60,18 @@ public final class PushNotificationManager: NSObject {
60
60
  private let notificationCenter: NotificationCenter
61
61
  private var pendingCompletion: Completion?
62
62
 
63
+ /// Fired on EVERY token arrival, regardless of whether the
64
+ /// registration was kicked off by an explicit
65
+ /// `requestAuthorizationAndRegister` call or by `silentRegisterIfAuthorized`.
66
+ ///
67
+ /// The plugin uses this to forward the token to JS listeners and to
68
+ /// re-register the token with the Apex backend. Setting this from
69
+ /// `initialize` lets us silently refresh the token on every cold
70
+ /// start when permission is already granted — without this hook the
71
+ /// merchant would have to manually tap "Enable push" after every
72
+ /// fresh Xcode install just to surface the token in the dashboard.
73
+ public var onTokenReceived: ((String) -> Void)?
74
+
63
75
  public init(
64
76
  center: UNUserNotificationCenter = .current(),
65
77
  application: UIApplication = .shared,
@@ -113,6 +125,36 @@ public final class PushNotificationManager: NSObject {
113
125
  }
114
126
  }
115
127
 
128
+ /// Re-register for remote notifications WITHOUT prompting when
129
+ /// permission has already been granted. iOS's permission survives
130
+ /// app reinstalls (including Xcode debug rebuilds) but the sample
131
+ /// app's in-memory "have we registered yet?" state does not — so
132
+ /// merchants kept telling us push permission was "resetting on every
133
+ /// build". The OS hadn't actually reset anything; we just weren't
134
+ /// surfacing the fact that permission was still granted.
135
+ ///
136
+ /// Calling this on `initialize` re-fires `didRegisterForRemoteNotifications`
137
+ /// with the device's current token, which flows through `onTokenReceived`
138
+ /// and refreshes the dashboard registration with the latest plugin
139
+ /// metadata (including the APNs environment field that older builds
140
+ /// didn't report).
141
+ ///
142
+ /// No-op when permission is denied or hasn't been requested yet —
143
+ /// the user has to take an explicit action in those cases.
144
+ public func silentRegisterIfAuthorized() {
145
+ center.getNotificationSettings { [weak self] settings in
146
+ guard let self = self else { return }
147
+ switch settings.authorizationStatus {
148
+ case .authorized, .provisional, .ephemeral:
149
+ DispatchQueue.main.async {
150
+ self.application.registerForRemoteNotifications()
151
+ }
152
+ default:
153
+ break
154
+ }
155
+ }
156
+ }
157
+
116
158
  // MARK: - AppDelegate bridge
117
159
 
118
160
  private func observeAppDelegateBridge() {
@@ -137,6 +179,11 @@ public final class PushNotificationManager: NSObject {
137
179
  }
138
180
  let hex = PushTokenHexFormatter.format(data)
139
181
  resolvePending(.init(permission: .granted, token: hex))
182
+ // Fire after resolvePending so the JS Promise for an explicit
183
+ // `Apex.registerForPushNotifications()` resolves first, then
184
+ // the plugin's persistent `onTokenReceived` hook runs to forward
185
+ // to listeners + the Apex backend.
186
+ onTokenReceived?(hex)
140
187
  }
141
188
 
142
189
  @objc private func handleDidFail(_: Notification) {
@@ -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),
@@ -70,12 +74,25 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
70
74
  private var debug = false
71
75
  private var apiBaseUrl: String = ""
72
76
  private var projectKey: 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
@@ -178,6 +195,29 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
178
195
  apiBaseUrl = call.getString("apiUrl") ?? "https://api.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!
@@ -195,15 +235,43 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
195
235
  apiBaseUrl: apiBaseUrl,
196
236
  projectKey: projectKey,
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,6 +347,108 @@ 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
453
  guard !apiBaseUrl.isEmpty, !projectKey.isEmpty else {
296
454
  if debug { print("[apex-capacitor] skipping push-token registration: plugin not initialized") }
@@ -299,6 +457,8 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
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
463
  "projectKey": projectKey,
304
464
  "visitorId": visitorId,
@@ -306,7 +466,13 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
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
+ "projectKey": projectKey,
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(projectKey, forHTTPHeaderField: "x-apex-project")
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.1.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",