@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
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Apex Spec — canonical event name constants for the iOS 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 Swift:
11
+ *
12
+ * plugin.track(type: ApexEvents.addToCart, data: ["product_id": "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
+ import Foundation
20
+
21
+ public enum ApexEvents {
22
+ // MARK: Commerce
23
+ public static let addToCart = "add_to_cart"
24
+ public static let removeFromCart = "remove_from_cart"
25
+ public static let productView = "product_view"
26
+ public static let addToWishlist = "add_to_wishlist"
27
+ public static let checkoutStarted = "checkout_started"
28
+ public static let inAppPurchase = "in_app_purchase"
29
+ public static let purchaseRefunded = "purchase_refunded"
30
+ public static let subscriptionEvent = "subscription_event"
31
+ public static let orderPlaced = "order_placed"
32
+ public static let firstSaleCompleted = "first_sale_completed"
33
+
34
+ // MARK: Marketing (merchant-fired triggers)
35
+ public static let priceDropped = "price_dropped"
36
+ public static let restockDue = "restock_due"
37
+ public static let reviewRequestDue = "review_request_due"
38
+
39
+ // MARK: Identity
40
+ public static let userSignedUp = "user_signed_up"
41
+ public static let userSignedIn = "user_signed_in"
42
+ public static let userSignedOut = "user_signed_out"
43
+ public static let userIdentified = "user_identified"
44
+
45
+ // MARK: Lifecycle
46
+ public static let appInstall = "app_install"
47
+ public static let appOpen = "app_open"
48
+ public static let appBackground = "app_background"
49
+ public static let appUninstall = "app_uninstall"
50
+ public static let appReinstall = "app_reinstall"
51
+
52
+ // MARK: Session
53
+ public static let sessionStart = "session_start"
54
+ public static let sessionEnd = "session_end"
55
+
56
+ // MARK: Engagement
57
+ public static let pageView = "page_view"
58
+ public static let pageview = "pageview"
59
+ public static let screenView = "screen_view"
60
+ public static let search = "search"
61
+ public static let share = "share"
62
+ public static let contentView = "content_view"
63
+ public static let formSubmit = "form_submit"
64
+ public static let click = "click"
65
+ public static let engagement = "engagement"
66
+ public static let heartbeat = "heartbeat"
67
+ public static let rageClick = "rage_click"
68
+ public static let deadClick = "dead_click"
69
+ public static let goalConversion = "goal_conversion"
70
+ public static let onboardingStepCompleted = "onboarding_step_completed"
71
+
72
+ // MARK: Product (PLG lifecycle outcomes)
73
+ public static let onboardingCompleted = "onboarding_completed"
74
+ public static let activated = "activated"
75
+
76
+ // MARK: Communication
77
+ public static let pushReceived = "push_received"
78
+ public static let pushOpened = "push_opened"
79
+ public static let emailOpened = "email_opened"
80
+ public static let emailClicked = "email_clicked"
81
+ public static let inAppMessageSeen = "in_app_message_seen"
82
+ public static let inAppMessageClicked = "in_app_message_clicked"
83
+
84
+ // MARK: Deep link
85
+ public static let deepLinkOpen = "deep_link_open"
86
+
87
+ // MARK: Attribution
88
+ public static let reattribution = "reattribution"
89
+ public static let reengagement = "reengagement"
90
+
91
+ // MARK: Smart banner
92
+ public static let smartBannerImpression = "smart_banner_impression"
93
+ public static let smartBannerClick = "smart_banner_click"
94
+ public static let smartBannerDismiss = "smart_banner_dismiss"
95
+
96
+ // MARK: System
97
+ public static let custom = "custom"
98
+ public static let lifecycleTransition = "lifecycle_transition"
99
+ public static let jsError = "js_error"
100
+
101
+ /// Apex Spec semver this SDK implements. Bumped together with
102
+ /// `APEX_SPEC_VERSION` in the TypeScript registry.
103
+ public static let specVersion = "1.1.0"
104
+
105
+ /// Complete list of every canonical event name. Used internally for
106
+ /// validation; iterate this set if you need to enumerate the spec.
107
+ public static let all: [String] = [
108
+ addToCart, removeFromCart, productView, addToWishlist, checkoutStarted,
109
+ inAppPurchase, purchaseRefunded, subscriptionEvent,
110
+ userSignedUp, userSignedIn, userSignedOut, userIdentified,
111
+ appInstall, appOpen, appBackground, appUninstall, appReinstall,
112
+ sessionStart, sessionEnd,
113
+ pageView, pageview, screenView, search, share, contentView, formSubmit,
114
+ click, engagement, heartbeat, rageClick, deadClick, goalConversion,
115
+ onboardingStepCompleted,
116
+ onboardingCompleted, activated,
117
+ pushReceived, pushOpened, emailOpened, emailClicked,
118
+ inAppMessageSeen, inAppMessageClicked,
119
+ deepLinkOpen,
120
+ reattribution, reengagement,
121
+ smartBannerImpression, smartBannerClick, smartBannerDismiss,
122
+ custom, lifecycleTransition, jsError,
123
+ ]
124
+ }
@@ -30,8 +30,11 @@ public final class NativeBatchSender {
30
30
  }
31
31
 
32
32
  private let apiBaseUrl: String
33
- private let projectKey: String
33
+ private let workspaceKey: String
34
34
  private let platformHeader: String
35
+ // Phase 2-SDK — optional workspace-bound API key forwarded as
36
+ // `x-apex-api-key` on every batch.
37
+ private let apiKey: String?
35
38
  private let batchSize: Int
36
39
  private let maxRetries: Int
37
40
  private let baseBackoffMs: Int
@@ -43,8 +46,9 @@ public final class NativeBatchSender {
43
46
 
44
47
  public init(
45
48
  apiBaseUrl: String,
46
- projectKey: String,
49
+ workspaceKey: String,
47
50
  platformHeader: String = "ios",
51
+ apiKey: String? = nil,
48
52
  batchSize: Int = 50,
49
53
  maxRetries: Int = 3,
50
54
  baseBackoffMs: Int = 1000,
@@ -55,8 +59,9 @@ public final class NativeBatchSender {
55
59
  var trimmed = apiBaseUrl
56
60
  while trimmed.hasSuffix("/") { trimmed.removeLast() }
57
61
  self.apiBaseUrl = trimmed
58
- self.projectKey = projectKey
62
+ self.workspaceKey = workspaceKey
59
63
  self.platformHeader = platformHeader
64
+ self.apiKey = apiKey
60
65
  self.batchSize = max(1, batchSize)
61
66
  self.maxRetries = max(1, maxRetries)
62
67
  self.baseBackoffMs = max(0, baseBackoffMs)
@@ -149,15 +154,25 @@ public final class NativeBatchSender {
149
154
  return
150
155
  }
151
156
 
152
- // The server accepts `{ projectKey, events: [{ id, ...payload }] }`.
157
+ // The server accepts `{ workspaceKey, events: [{ id, ...payload }] }`.
153
158
  // We unwrap the queued envelope so the `id` lives at the top
154
159
  // level of each event object (matches what /api/events expects
155
160
  // and what the JS-side BatchSender produces).
161
+ //
162
+ // MMP-081 — stamp `clientType` at send time so events from the
163
+ // native iOS plugin land server-side as `native-ios`. We don't
164
+ // overwrite a value the caller already set on the event
165
+ // (preserves test fixtures and unusual workflows); the typical
166
+ // path is that the queued payload doesn't carry one and we
167
+ // fill it in here.
156
168
  let body: [String: Any] = [
157
- "projectKey": projectKey,
169
+ "workspaceKey": workspaceKey,
158
170
  "events": batch.map { ev -> [String: Any] in
159
171
  var flat = ev.payload.mapValues { $0.value }
160
172
  flat["id"] = ev.id
173
+ if flat["clientType"] == nil {
174
+ flat["clientType"] = "native-ios"
175
+ }
161
176
  return flat
162
177
  },
163
178
  ]
@@ -165,8 +180,11 @@ public final class NativeBatchSender {
165
180
  var request = URLRequest(url: url)
166
181
  request.httpMethod = "POST"
167
182
  request.setValue("application/json", forHTTPHeaderField: "Content-Type")
168
- request.setValue(projectKey, forHTTPHeaderField: "X-Apex-Project-Key")
183
+ request.setValue(workspaceKey, forHTTPHeaderField: "X-Apex-Workspace-Key")
169
184
  request.setValue(platformHeader, forHTTPHeaderField: "X-Apex-Platform")
185
+ if let key = apiKey {
186
+ request.setValue(key, forHTTPHeaderField: "x-apex-api-key")
187
+ }
170
188
  request.timeoutInterval = 20
171
189
 
172
190
  do {
@@ -165,6 +165,25 @@ public struct AnyCodable: Codable, Equatable {
165
165
  switch value {
166
166
  case is NSNull:
167
167
  try container.encodeNil()
168
+ case let n as NSNumber:
169
+ // 2026-05-16 — NSNumber discrimination. Capacitor's JS bridge
170
+ // hands us every JS number as NSNumber, and Objective-C's
171
+ // toll-free bridging means `NSNumber(value: 1)` and
172
+ // `NSNumber(value: true)` both satisfy `as Bool` AND `as Int`.
173
+ // The previous `case let v as Bool:` branch consumed integer
174
+ // 1 / 0 first, encoding `quantity: 1` from JS as `quantity:
175
+ // true` server-side. That broke a) commerce widgets reading
176
+ // quantity as a number, and b) any journey trigger that
177
+ // type-checks numeric fields. CFBooleanGetTypeID is the only
178
+ // reliable way to distinguish a true Bool NSNumber from an
179
+ // integer NSNumber on Apple platforms.
180
+ if CFGetTypeID(n) == CFBooleanGetTypeID() {
181
+ try container.encode(n.boolValue)
182
+ } else if CFNumberIsFloatType(n) {
183
+ try container.encode(n.doubleValue)
184
+ } else {
185
+ try container.encode(n.int64Value)
186
+ }
168
187
  case let v as Bool:
169
188
  try container.encode(v)
170
189
  case let v as Int:
@@ -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) {