@apex-inc/capacitor-plugin 2.2.7 → 2.4.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.
@@ -41,10 +41,13 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
41
41
  CAPPluginMethod(name: "endSession", returnType: CAPPluginReturnPromise),
42
42
  CAPPluginMethod(name: "getCurrentSession", returnType: CAPPluginReturnPromise),
43
43
  CAPPluginMethod(name: "captureVariantScreenshot", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "getVariant", returnType: CAPPluginReturnPromise),
44
45
  CAPPluginMethod(name: "track", returnType: CAPPluginReturnPromise),
45
46
  CAPPluginMethod(name: "getQueueSize", returnType: CAPPluginReturnPromise),
46
47
  CAPPluginMethod(name: "flushQueue", returnType: CAPPluginReturnPromise),
47
48
  CAPPluginMethod(name: "setTestMode", returnType: CAPPluginReturnPromise),
49
+ CAPPluginMethod(name: "setConsent", returnType: CAPPluginReturnPromise),
50
+ CAPPluginMethod(name: "getConsent", returnType: CAPPluginReturnPromise),
48
51
  ]
49
52
 
50
53
  // MARK: - Dependencies (lazy; constructed after initialize())
@@ -94,6 +97,13 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
94
97
  private var releaseChannel: String = "unknown"
95
98
  private var releaseChannelSource: String = "auto"
96
99
  private var lastReleaseChannelDetectedAt: Date = Date(timeIntervalSince1970: 0)
100
+
101
+ // Phase 4a — consent gate. Defaults granted; set false by
102
+ // initialize({ requireConsent: true }) until setConsent({ granted:true }).
103
+ private var consentGranted = true
104
+ // Phase 3 — per-launch dedup so each (experiment, variant) exposure
105
+ // fires once, matching the JS/web fallback.
106
+ private var firedExposures = Set<String>()
97
107
  // MMP-207 — debounced flush. Without this, every `track()` call
98
108
  // kicked a POST, so 8 quick taps = 8 separate single-event HTTP
99
109
  // requests. With debounce, rapid-fire events coalesce into one
@@ -201,6 +211,8 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
201
211
  // authorize quarantine-mode auto-stitch.
202
212
  let rawKey = call.getString("apiKey") ?? ""
203
213
  apiKey = rawKey.isEmpty ? nil : rawKey
214
+ // Phase 4a — consent gate.
215
+ consentGranted = !(call.getBool("requireConsent") ?? false)
204
216
 
205
217
  // MMP-061 — release channel detection. iOS uses the receipt URL
206
218
  // + StoreKit 2 AppTransaction. Any JS-supplied override on iOS
@@ -535,6 +547,12 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
535
547
  /// guarantees than firing a track event, and merchants tend to
536
548
  /// wire identify behind a one-shot boundary.
537
549
  @objc public func identify(_ call: CAPPluginCall) {
550
+ // Phase 4a — identify sends PII; collect nothing until consent given.
551
+ if !consentGranted {
552
+ if debug { print("[apex-capacitor] consent not granted; skipping identify") }
553
+ call.resolve()
554
+ return
555
+ }
538
556
  guard let email = call.getString("email")?.trimmingCharacters(in: .whitespaces),
539
557
  !email.isEmpty else {
540
558
  call.reject("email is required")
@@ -795,6 +813,119 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
795
813
  }.resume()
796
814
  }
797
815
 
816
+ // MARK: - Experiments (Phase 3 — native getVariant)
817
+
818
+ /// Resolve an experiment variant for this device against
819
+ /// `${apiBaseUrl}/api/experiments/:id/assign?unified=true&source=sdk`,
820
+ /// passing the stable visitor id so assignment buckets on the SAME id the
821
+ /// exposure event carries. Fires `experiment_exposure` once per
822
+ /// (experiment, variant). Falls back to control on any error.
823
+ @objc public func getVariant(_ call: CAPPluginCall) {
824
+ guard let experimentId = call.getString("experimentId"), !experimentId.isEmpty else {
825
+ call.reject("experimentId is required")
826
+ return
827
+ }
828
+ // Phase 4a — without consent, resolve control and fire no exposure.
829
+ if !consentGranted {
830
+ call.resolve([
831
+ "experimentId": experimentId,
832
+ "variant": "control",
833
+ "payload": [String: Any](),
834
+ "eligible": false,
835
+ "reason": "no_consent",
836
+ ])
837
+ return
838
+ }
839
+ let fallback: [String: Any] = [
840
+ "experimentId": experimentId,
841
+ "variant": "control",
842
+ "payload": [String: Any](),
843
+ "eligible": false,
844
+ "reason": "not_found",
845
+ ]
846
+ guard let encodedId = experimentId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
847
+ let url = URL(string: "\(apiBaseUrl)/api/experiments/\(encodedId)/assign?unified=true&source=sdk") else {
848
+ call.resolve(fallback)
849
+ return
850
+ }
851
+ var request = URLRequest(url: url)
852
+ request.httpMethod = "GET"
853
+ request.setValue("sdk", forHTTPHeaderField: "x-apex-source")
854
+ request.setValue(visitorId, forHTTPHeaderField: "x-apex-visitor-id")
855
+ request.setValue(workspaceKey, forHTTPHeaderField: "x-apex-workspace")
856
+ if let key = apiKey {
857
+ request.setValue(key, forHTTPHeaderField: "x-apex-api-key")
858
+ }
859
+ URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
860
+ guard let self = self else { return }
861
+ if error != nil {
862
+ call.resolve(fallback)
863
+ return
864
+ }
865
+ guard let http = response as? HTTPURLResponse,
866
+ (200...299).contains(http.statusCode),
867
+ let data = data,
868
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
869
+ call.resolve(fallback)
870
+ return
871
+ }
872
+ let variant = (json["variant"] as? String) ?? "control"
873
+ let eligible = (json["eligible"] as? Bool) ?? false
874
+ let payload = (json["payload"] as? [String: Any]) ?? [:]
875
+ var result: [String: Any] = [
876
+ "experimentId": experimentId,
877
+ "variant": variant,
878
+ "payload": payload,
879
+ "eligible": eligible,
880
+ ]
881
+ if let reason = json["reason"] as? String {
882
+ result["reason"] = reason
883
+ }
884
+ // Fire one exposure per (experiment, variant) on a real assignment.
885
+ if eligible {
886
+ let key = "\(experimentId):\(variant)"
887
+ if !self.firedExposures.contains(key) {
888
+ self.firedExposures.insert(key)
889
+ self.enqueueExposure(experimentId: experimentId, variant: variant)
890
+ }
891
+ }
892
+ call.resolve(result)
893
+ }.resume()
894
+ }
895
+
896
+ /// Enqueue a canonical `experiment_exposure` event, stamped the same way
897
+ /// `track(_:)` stamps every event (platform / timestamp / visitorId /
898
+ /// mobile slot / testMode). Batched through the offline queue.
899
+ private func enqueueExposure(experimentId: String, variant: String) {
900
+ guard let queue = offlineQueue else { return }
901
+ var payload: [String: AnyCodable] = [:]
902
+ payload["type"] = AnyCodable("experiment_exposure")
903
+ payload["data"] = AnyCodable([
904
+ "experiment_id": experimentId,
905
+ "variant_key": variant,
906
+ "surface": "mobile",
907
+ ])
908
+ if testMode {
909
+ payload["testMode"] = AnyCodable(true)
910
+ }
911
+ payload["platform"] = AnyCodable("ios")
912
+ payload["timestamp"] = AnyCodable(ISO8601DateFormatter().string(from: Date()))
913
+ payload["visitorId"] = AnyCodable(visitorId)
914
+ let mobileSlot: [String: Any] = [
915
+ "releaseChannel": releaseChannel,
916
+ "releaseChannelSource": releaseChannelSource,
917
+ ]
918
+ payload["mobile"] = AnyCodable(mobileSlot)
919
+ let event = NativeQueuedEvent(
920
+ id: UUID().uuidString.lowercased(),
921
+ payload: payload,
922
+ attempts: 0,
923
+ enqueuedAt: Date()
924
+ )
925
+ try? queue.enqueue(event)
926
+ kickFlush(reason: "track")
927
+ }
928
+
798
929
  // MARK: - Events & Queue
799
930
 
800
931
  @objc public func track(_ call: CAPPluginCall) {
@@ -802,6 +933,12 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
802
933
  call.reject("Plugin not initialized — call initialize() first")
803
934
  return
804
935
  }
936
+ // Phase 4a — collect nothing until consent is granted.
937
+ if !consentGranted {
938
+ if debug { print("[apex-capacitor] consent not granted; dropping event") }
939
+ call.resolve()
940
+ return
941
+ }
805
942
 
806
943
  let id = call.getString("id") ?? UUID().uuidString.lowercased()
807
944
  let payloadDict = call.options.compactMapValues { $0 }
@@ -901,6 +1038,25 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
901
1038
  testMode = call.getBool("enabled") ?? false
902
1039
  call.resolve()
903
1040
  }
1041
+
1042
+ // MARK: - Consent (Phase 4a)
1043
+
1044
+ @objc public func setConsent(_ call: CAPPluginCall) {
1045
+ let granted = call.getBool("granted") ?? false
1046
+ consentGranted = granted
1047
+ if granted {
1048
+ // Resume: flush anything captured before this point.
1049
+ kickFlush(reason: "consent")
1050
+ } else {
1051
+ // Revoke: drop anything queued but not yet sent.
1052
+ try? offlineQueue?.clear()
1053
+ }
1054
+ call.resolve()
1055
+ }
1056
+
1057
+ @objc public func getConsent(_ call: CAPPluginCall) {
1058
+ call.resolve(["granted": consentGranted])
1059
+ }
904
1060
  }
905
1061
 
906
1062
  // MARK: - Date ISO 8601 helper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-inc/capacitor-plugin",
3
- "version": "2.2.7",
3
+ "version": "2.4.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",