@apex-inc/capacitor-plugin 2.5.1 → 2.6.1

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.
@@ -55,6 +55,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
55
55
  private var attManager = AttManager()
56
56
  private var skanManager = SkanManager()
57
57
  private var deviceInfo = DeviceInfo()
58
+ private lazy var deviceMeta: DeviceMetadata = deviceInfo.collect()
58
59
  private var deepLinks = DeepLinkManager()
59
60
  private var adIds = AdvertisingIdProvider()
60
61
  private lazy var pushManager = PushNotificationManager()
@@ -101,6 +102,9 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
101
102
  // Phase 4a — consent gate. Defaults granted; set false by
102
103
  // initialize({ requireConsent: true }) until setConsent({ granted:true }).
103
104
  private var consentGranted = true
105
+ // The install is sent once per install, by the plugin or the app, never both.
106
+ private var autoTrackInstall = true
107
+ private let installLock = NSLock()
104
108
  // Phase 3 — per-launch dedup so each (experiment, variant) exposure
105
109
  // fires once, matching the JS/web fallback.
106
110
  private var firedExposures = Set<String>()
@@ -216,6 +220,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
216
220
  apiKey = rawKey.isEmpty ? nil : rawKey
217
221
  // Phase 4a — consent gate.
218
222
  consentGranted = !(call.getBool("requireConsent") ?? false)
223
+ autoTrackInstall = call.getBool("autoTrackInstall") ?? true
219
224
 
220
225
  // MMP-061 — release channel detection. iOS uses the receipt URL
221
226
  // + StoreKit 2 AppTransaction. Any JS-supplied override on iOS
@@ -271,10 +276,62 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
271
276
  self?.handleTokenArrived(token)
272
277
  }
273
278
  pushManager.silentRegisterIfAuthorized()
279
+ sendInstallIfFirstLaunch()
274
280
 
275
281
  call.resolve()
276
282
  }
277
283
 
284
+ /// Send `app_install` on the first launch of a fresh install. Marks the
285
+ /// install as handled for someone who had the app before Apex, so the
286
+ /// update that adds Apex reports no one as new.
287
+ private func sendInstallIfFirstLaunch() {
288
+ guard autoTrackInstall, consentGranted, let queue = offlineQueue else { return }
289
+ let defaults = UserDefaults.standard
290
+ installLock.lock()
291
+ let decision = FirstLaunchInstall.decide(
292
+ alreadySent: defaults.bool(forKey: FirstLaunchInstall.defaultsKey),
293
+ installedAt: FirstLaunchInstall.installedAt(),
294
+ versionInstalledAt: FirstLaunchInstall.versionInstalledAt(),
295
+ now: Date()
296
+ )
297
+ if decision != .alreadySent { defaults.set(true, forKey: FirstLaunchInstall.defaultsKey) }
298
+ installLock.unlock()
299
+ if debug { print("[apex-capacitor] first-launch install: \(decision)") }
300
+ guard decision == .send else { return }
301
+
302
+ var payload: [String: AnyCodable] = [:]
303
+ payload["type"] = AnyCodable("app_install")
304
+ payload["data"] = AnyCodable(["source": "apex_first_launch"])
305
+ if testMode { payload["testMode"] = AnyCodable(true) }
306
+ payload["platform"] = AnyCodable("ios")
307
+ payload["timestamp"] = AnyCodable(ISO8601DateFormatter().string(from: Date()))
308
+ payload["visitorId"] = AnyCodable(visitorId)
309
+ var mobileSlot: [String: Any] = [
310
+ "releaseChannel": releaseChannel,
311
+ "releaseChannelSource": releaseChannelSource,
312
+ ]
313
+ stampDevice(&mobileSlot)
314
+ payload["mobile"] = AnyCodable(mobileSlot)
315
+ let event = NativeQueuedEvent(
316
+ id: UUID().uuidString.lowercased(),
317
+ payload: payload,
318
+ attempts: 0,
319
+ enqueuedAt: Date()
320
+ )
321
+ try? queue.enqueue(event)
322
+ kickFlush(reason: "track")
323
+ }
324
+
325
+ /// True the first time an install is claimed; the app's own `app_install` after that is dropped.
326
+ private func claimInstall() -> Bool {
327
+ installLock.lock()
328
+ defer { installLock.unlock() }
329
+ let defaults = UserDefaults.standard
330
+ if defaults.bool(forKey: FirstLaunchInstall.defaultsKey) { return false }
331
+ defaults.set(true, forKey: FirstLaunchInstall.defaultsKey)
332
+ return true
333
+ }
334
+
278
335
  /// Forward a freshly-arrived APNs device token to JS listeners and
279
336
  /// register it with the Apex backend. Shared by the explicit
280
337
  /// `registerForPushNotifications()` path and the silent re-register
@@ -552,12 +609,6 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
552
609
  /// guarantees than firing a track event, and merchants tend to
553
610
  /// wire identify behind a one-shot boundary.
554
611
  @objc public func identify(_ call: CAPPluginCall) {
555
- // Phase 4a — identify sends PII; collect nothing until consent given.
556
- if !consentGranted {
557
- if debug { print("[apex-capacitor] consent not granted; skipping identify") }
558
- call.resolve()
559
- return
560
- }
561
612
  guard let email = call.getString("email")?.trimmingCharacters(in: .whitespaces),
562
613
  !email.isEmpty else {
563
614
  call.reject("email is required")
@@ -571,11 +622,19 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
571
622
  return
572
623
  }
573
624
  var body: [String: Any] = [
574
- "visitorId": visitorId,
575
625
  "email": email,
576
626
  "workspaceKey": workspaceKey,
577
627
  "timestamp": ISO8601DateFormatter().string(from: Date()),
578
628
  ]
629
+ // Signing up is its own reason to know the person. Without consent
630
+ // only the identify goes: no device id, and Apex records advertising
631
+ // as denied so no ad platform is sent them.
632
+ if consentGranted {
633
+ body["visitorId"] = visitorId
634
+ } else {
635
+ if debug { print("[apex-capacitor] consent not granted; identifying without a device id") }
636
+ body["noBrowserConsent"] = true
637
+ }
579
638
  if let userId = userId, !userId.isEmpty {
580
639
  body["userId"] = userId
581
640
  }
@@ -930,10 +989,11 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
930
989
  payload["platform"] = AnyCodable("ios")
931
990
  payload["timestamp"] = AnyCodable(ISO8601DateFormatter().string(from: Date()))
932
991
  payload["visitorId"] = AnyCodable(visitorId)
933
- let mobileSlot: [String: Any] = [
992
+ var mobileSlot: [String: Any] = [
934
993
  "releaseChannel": releaseChannel,
935
994
  "releaseChannelSource": releaseChannelSource,
936
995
  ]
996
+ stampDevice(&mobileSlot)
937
997
  payload["mobile"] = AnyCodable(mobileSlot)
938
998
  let event = NativeQueuedEvent(
939
999
  id: UUID().uuidString.lowercased(),
@@ -947,6 +1007,17 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
947
1007
 
948
1008
  // MARK: - Events & Queue
949
1009
 
1010
+ /// The device, the tracking answer, and the IDFA when the person allowed
1011
+ /// it. Read per event: the answer can change in Settings mid-session.
1012
+ private func stampDevice(_ slot: inout [String: Any]) {
1013
+ MobileContext.fill(
1014
+ &slot,
1015
+ device: deviceMeta,
1016
+ att: attManager.currentStatus(),
1017
+ adId: adIds.current()
1018
+ )
1019
+ }
1020
+
950
1021
  @objc public func track(_ call: CAPPluginCall) {
951
1022
  guard let queue = offlineQueue else {
952
1023
  call.reject("Plugin not initialized — call initialize() first")
@@ -958,6 +1029,11 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
958
1029
  call.resolve()
959
1030
  return
960
1031
  }
1032
+ if call.getString("type") == "app_install" && !claimInstall() {
1033
+ if debug { print("[apex-capacitor] app_install already sent for this install; dropping") }
1034
+ call.resolve()
1035
+ return
1036
+ }
961
1037
 
962
1038
  let id = call.getString("id") ?? UUID().uuidString.lowercased()
963
1039
  let payloadDict = call.options.compactMapValues { $0 }
@@ -1004,6 +1080,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
1004
1080
  }
1005
1081
  mobileSlot["releaseChannel"] = releaseChannel
1006
1082
  mobileSlot["releaseChannelSource"] = releaseChannelSource
1083
+ stampDevice(&mobileSlot)
1007
1084
  codablePayload["mobile"] = AnyCodable(mobileSlot)
1008
1085
  let event = NativeQueuedEvent(
1009
1086
  id: id,
@@ -1066,6 +1143,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
1066
1143
  if granted {
1067
1144
  // Resume: flush anything captured before this point.
1068
1145
  kickFlush(reason: "consent")
1146
+ sendInstallIfFirstLaunch()
1069
1147
  } else {
1070
1148
  // Revoke: drop anything queued but not yet sent.
1071
1149
  try? offlineQueue?.clear()
@@ -0,0 +1,72 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ What @apex-inc/capacitor-plugin collects, merged by Xcode into the app's
5
+ privacy report. Installs and purchases count as tracking because Apex
6
+ sends them to the ad networks the app connects to measure ads. The IDFA
7
+ is read only after the person allows tracking in the ATT prompt. No
8
+ tracking domains are listed: iOS blocks listed domains
9
+ outright when tracking is denied, and Apex's own endpoint also serves
10
+ measurement that needs no permission.
11
+ -->
12
+ <plist version="1.0">
13
+ <dict>
14
+ <key>NSPrivacyTracking</key>
15
+ <true/>
16
+ <key>NSPrivacyTrackingDomains</key>
17
+ <array/>
18
+ <key>NSPrivacyCollectedDataTypes</key>
19
+ <array>
20
+ <dict>
21
+ <key>NSPrivacyCollectedDataType</key>
22
+ <string>NSPrivacyCollectedDataTypeDeviceID</string>
23
+ <key>NSPrivacyCollectedDataTypeLinked</key>
24
+ <true/>
25
+ <key>NSPrivacyCollectedDataTypeTracking</key>
26
+ <true/>
27
+ <key>NSPrivacyCollectedDataTypePurposes</key>
28
+ <array>
29
+ <string>NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising</string>
30
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
31
+ </array>
32
+ </dict>
33
+ <dict>
34
+ <key>NSPrivacyCollectedDataType</key>
35
+ <string>NSPrivacyCollectedDataTypeProductInteraction</string>
36
+ <key>NSPrivacyCollectedDataTypeLinked</key>
37
+ <true/>
38
+ <key>NSPrivacyCollectedDataTypeTracking</key>
39
+ <true/>
40
+ <key>NSPrivacyCollectedDataTypePurposes</key>
41
+ <array>
42
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
43
+ <string>NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising</string>
44
+ </array>
45
+ </dict>
46
+ <dict>
47
+ <key>NSPrivacyCollectedDataType</key>
48
+ <string>NSPrivacyCollectedDataTypePurchaseHistory</string>
49
+ <key>NSPrivacyCollectedDataTypeLinked</key>
50
+ <true/>
51
+ <key>NSPrivacyCollectedDataTypeTracking</key>
52
+ <true/>
53
+ <key>NSPrivacyCollectedDataTypePurposes</key>
54
+ <array>
55
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
56
+ <string>NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising</string>
57
+ </array>
58
+ </dict>
59
+ </array>
60
+ <key>NSPrivacyAccessedAPITypes</key>
61
+ <array>
62
+ <dict>
63
+ <key>NSPrivacyAccessedAPIType</key>
64
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
65
+ <key>NSPrivacyAccessedAPITypeReasons</key>
66
+ <array>
67
+ <string>CA92.1</string>
68
+ </array>
69
+ </dict>
70
+ </array>
71
+ </dict>
72
+ </plist>
@@ -0,0 +1,66 @@
1
+ //
2
+ // FirstLaunchInstallTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class FirstLaunchInstallTests: XCTestCase {
9
+
10
+ private let now = Date(timeIntervalSince1970: 1_790_000_000)
11
+ private let day: TimeInterval = 24 * 3600
12
+
13
+ func testSendsOnTheFirstLaunchOfAFreshInstall() {
14
+ let installed = now.addingTimeInterval(-60)
15
+ XCTAssertEqual(
16
+ FirstLaunchInstall.decide(alreadySent: false, installedAt: installed, versionInstalledAt: installed, now: now),
17
+ .send
18
+ )
19
+ }
20
+
21
+ func testSendsOnceOnly() {
22
+ XCTAssertEqual(
23
+ FirstLaunchInstall.decide(alreadySent: true, installedAt: now, versionInstalledAt: now, now: now),
24
+ .alreadySent
25
+ )
26
+ }
27
+
28
+ func testAnUpdateThatAddsApexIsNotAnInstall() {
29
+ XCTAssertEqual(
30
+ FirstLaunchInstall.decide(
31
+ alreadySent: false,
32
+ installedAt: now.addingTimeInterval(-90 * day),
33
+ versionInstalledAt: now.addingTimeInterval(-day),
34
+ now: now
35
+ ),
36
+ .existingUser
37
+ )
38
+ }
39
+
40
+ func testARecentInstallStillCountsAfterAQuickUpdate() {
41
+ XCTAssertEqual(
42
+ FirstLaunchInstall.decide(
43
+ alreadySent: false,
44
+ installedAt: now.addingTimeInterval(-3 * day),
45
+ versionInstalledAt: now.addingTimeInterval(-day),
46
+ now: now
47
+ ),
48
+ .send
49
+ )
50
+ }
51
+
52
+ func testALateFirstOpenOfANeverUpdatedInstallCounts() {
53
+ let installed = now.addingTimeInterval(-20 * day)
54
+ XCTAssertEqual(
55
+ FirstLaunchInstall.decide(alreadySent: false, installedAt: installed, versionInstalledAt: installed, now: now),
56
+ .send
57
+ )
58
+ }
59
+
60
+ func testSendsWhenTheInstallTimeIsUnknown() {
61
+ XCTAssertEqual(
62
+ FirstLaunchInstall.decide(alreadySent: false, installedAt: nil, versionInstalledAt: nil, now: now),
63
+ .send
64
+ )
65
+ }
66
+ }
@@ -0,0 +1,56 @@
1
+ //
2
+ // MobileContextTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class MobileContextTests: XCTestCase {
9
+
10
+ private let device = DeviceMetadata(
11
+ platform: "ios",
12
+ osVersion: "18.1",
13
+ model: "iPhone16,1",
14
+ appVersion: "3.1.0",
15
+ bundleId: "com.skillshot.app",
16
+ timezone: "America/Chicago",
17
+ locale: "en-US"
18
+ )
19
+
20
+ func testStampsTheDeviceAndTheIdfaWhenTrackingIsAllowed() {
21
+ let fields = MobileContext.fields(
22
+ device: device,
23
+ att: .authorized,
24
+ adId: AdvertisingIdResult(id: "6d92078a-8246-4ba4-ae5b-76104861e7dc", fallback: nil)
25
+ )
26
+ XCTAssertEqual(fields["bundleId"] as? String, "com.skillshot.app")
27
+ XCTAssertEqual(fields["deviceModel"] as? String, "iPhone16,1")
28
+ XCTAssertEqual(fields["attStatus"] as? String, "authorized")
29
+ XCTAssertEqual(fields["idfa"] as? String, "6d92078a-8246-4ba4-ae5b-76104861e7dc")
30
+ }
31
+
32
+ func testNeverSendsTheIdfvAsTheAdvertisingId() {
33
+ let fields = MobileContext.fields(
34
+ device: device,
35
+ att: .denied,
36
+ adId: AdvertisingIdResult(id: "idfv-value", fallback: "idfv")
37
+ )
38
+ XCTAssertNil(fields["idfa"])
39
+ XCTAssertNil(fields["idfv"])
40
+ XCTAssertEqual(fields["attStatus"] as? String, "denied")
41
+ }
42
+
43
+ func testKeepsWhatTheAppSet() {
44
+ var slot: [String: Any] = ["releaseChannel": "app-store", "locale": "fr-FR"]
45
+ MobileContext.fill(
46
+ &slot,
47
+ device: device,
48
+ att: .notDetermined,
49
+ adId: AdvertisingIdResult(id: nil, fallback: nil)
50
+ )
51
+ XCTAssertEqual(slot["locale"] as? String, "fr-FR")
52
+ XCTAssertEqual(slot["releaseChannel"] as? String, "app-store")
53
+ XCTAssertEqual(slot["appVersion"] as? String, "3.1.0")
54
+ XCTAssertEqual(slot["attStatus"] as? String, "not-determined")
55
+ }
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-inc/capacitor-plugin",
3
- "version": "2.5.1",
3
+ "version": "2.6.1",
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",