@apex-inc/capacitor-plugin 0.3.8 → 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.
- package/android/build.gradle +4 -2
- package/android/src/main/AndroidManifest.xml +35 -3
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +403 -6
- package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +122 -0
- package/android/src/main/java/inc/apex/capacitor/ApexFirebaseMessagingService.kt +99 -0
- package/android/src/main/java/inc/apex/capacitor/NativeBatchSender.kt +260 -0
- package/dist/batch-sender.d.ts +12 -0
- package/dist/batch-sender.d.ts.map +1 -1
- package/dist/batch-sender.js +25 -0
- package/dist/batch-sender.js.map +1 -1
- package/dist/cart-helpers.d.ts +63 -0
- package/dist/cart-helpers.d.ts.map +1 -0
- package/dist/cart-helpers.js +50 -0
- package/dist/cart-helpers.js.map +1 -0
- package/dist/definitions.d.ts +166 -2
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/batch-sender.d.ts +12 -0
- package/dist/esm/batch-sender.d.ts.map +1 -1
- package/dist/esm/batch-sender.js +25 -0
- package/dist/esm/batch-sender.js.map +1 -1
- package/dist/esm/cart-helpers.d.ts +63 -0
- package/dist/esm/cart-helpers.d.ts.map +1 -0
- package/dist/esm/cart-helpers.js +44 -0
- package/dist/esm/cart-helpers.js.map +1 -0
- package/dist/esm/definitions.d.ts +166 -2
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/events.d.ts +85 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +96 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +4 -5
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +19 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/offline-queue.d.ts +15 -0
- package/dist/esm/offline-queue.d.ts.map +1 -1
- package/dist/esm/offline-queue.js +35 -0
- package/dist/esm/offline-queue.js.map +1 -1
- package/dist/esm/screen-view.d.ts +18 -0
- package/dist/esm/screen-view.d.ts.map +1 -0
- package/dist/esm/screen-view.js +28 -0
- package/dist/esm/screen-view.js.map +1 -0
- package/dist/esm/web.d.ts +29 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +182 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/events.d.ts +85 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +99 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -2
- package/dist/index.js.map +1 -1
- package/dist/offline-queue.d.ts +15 -0
- package/dist/offline-queue.d.ts.map +1 -1
- package/dist/offline-queue.js +35 -0
- package/dist/offline-queue.js.map +1 -1
- package/dist/screen-view.d.ts +18 -0
- package/dist/screen-view.d.ts.map +1 -0
- package/dist/screen-view.js +31 -0
- package/dist/screen-view.js.map +1 -0
- package/dist/web.d.ts +29 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +182 -3
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +124 -0
- package/ios/Sources/ApexCapacitorPlugin/BatchSender.swift +18 -0
- package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +19 -0
- package/ios/Sources/ApexCapacitorPlugin/PushNotificationManager.swift +47 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +280 -20
- 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
|
+
}
|
|
@@ -32,6 +32,9 @@ public final class NativeBatchSender {
|
|
|
32
32
|
private let apiBaseUrl: String
|
|
33
33
|
private let projectKey: 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
|
|
@@ -45,6 +48,7 @@ public final class NativeBatchSender {
|
|
|
45
48
|
apiBaseUrl: String,
|
|
46
49
|
projectKey: 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,
|
|
@@ -57,6 +61,7 @@ public final class NativeBatchSender {
|
|
|
57
61
|
self.apiBaseUrl = trimmed
|
|
58
62
|
self.projectKey = projectKey
|
|
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)
|
|
@@ -153,11 +158,21 @@ public final class NativeBatchSender {
|
|
|
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
169
|
"projectKey": projectKey,
|
|
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
|
]
|
|
@@ -167,6 +182,9 @@ public final class NativeBatchSender {
|
|
|
167
182
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
168
183
|
request.setValue(projectKey, forHTTPHeaderField: "X-Apex-Project-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) {
|
|
@@ -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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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,
|