@apex-inc/capacitor-plugin 2.5.0 → 2.6.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.
@@ -0,0 +1,50 @@
1
+ //
2
+ // FirstLaunchInstall.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // Whether this launch should send `app_install`. The plugin sends it once
6
+ // per install so every app counts installs the same way; the app's own
7
+ // `app_install` is dropped once one has gone.
8
+ //
9
+ // Someone who had the app before Apex was added is not a new install: the
10
+ // update that brings Apex must not report every existing user to the ad
11
+ // networks as one. A fresh install is one made in the last 7 days, or one
12
+ // never updated since (the version installed is the one running).
13
+ //
14
+
15
+ import Foundation
16
+
17
+ public enum FirstLaunchInstall {
18
+ public static let defaultsKey = "apex.installSent"
19
+ public static let freshWindow: TimeInterval = 7 * 24 * 3600
20
+ private static let sameVersionTolerance: TimeInterval = 5 * 60
21
+
22
+ public enum Decision: Equatable { case send, alreadySent, existingUser }
23
+
24
+ public static func decide(
25
+ alreadySent: Bool,
26
+ installedAt: Date?,
27
+ versionInstalledAt: Date?,
28
+ now: Date
29
+ ) -> Decision {
30
+ if alreadySent { return .alreadySent }
31
+ guard let installedAt = installedAt else { return .send }
32
+ if let versionInstalledAt = versionInstalledAt,
33
+ abs(versionInstalledAt.timeIntervalSince(installedAt)) <= sameVersionTolerance {
34
+ return .send
35
+ }
36
+ return now.timeIntervalSince(installedAt) <= freshWindow ? .send : .existingUser
37
+ }
38
+
39
+ /// When the app was first installed: the Documents folder is made then
40
+ /// and survives updates.
41
+ public static func installedAt() -> Date? {
42
+ guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil }
43
+ return (try? FileManager.default.attributesOfItem(atPath: docs.path))?[.creationDate] as? Date
44
+ }
45
+
46
+ /// When the running version was installed: the app bundle is replaced on every update.
47
+ public static func versionInstalledAt() -> Date? {
48
+ (try? FileManager.default.attributesOfItem(atPath: Bundle.main.bundlePath))?[.creationDate] as? Date
49
+ }
50
+ }
@@ -0,0 +1,49 @@
1
+ //
2
+ // MobileContext.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // The device details stamped under `event.mobile` on every event. Apex
6
+ // builds the ad networks' app-event device block from them (Meta's
7
+ // `app_data` + `extinfo`), so an event without them reaches Meta with a
8
+ // blank device and tracking marked off.
9
+ //
10
+ // The advertising id is the IDFA, and only when the person allowed
11
+ // tracking. The IDFV fallback is never sent: it is not an advertising id
12
+ // and Meta must not match on it.
13
+ //
14
+
15
+ import Foundation
16
+
17
+ public enum MobileContext {
18
+ public static func fields(
19
+ device: DeviceMetadata,
20
+ att: AttStatus,
21
+ adId: AdvertisingIdResult
22
+ ) -> [String: Any] {
23
+ var fields: [String: Any] = [
24
+ "bundleId": device.bundleId,
25
+ "appVersion": device.appVersion,
26
+ "osVersion": device.osVersion,
27
+ "deviceModel": device.model,
28
+ "timezone": device.timezone,
29
+ "locale": device.locale,
30
+ "attStatus": att.rawValue,
31
+ ]
32
+ if att == .authorized, adId.fallback == nil, let idfa = adId.id {
33
+ fields["idfa"] = idfa
34
+ }
35
+ return fields
36
+ }
37
+
38
+ /// Fill the fields the caller didn't set on the event.
39
+ public static func fill(
40
+ _ slot: inout [String: Any],
41
+ device: DeviceMetadata,
42
+ att: AttStatus,
43
+ adId: AdvertisingIdResult
44
+ ) {
45
+ for (key, value) in fields(device: device, att: att, adId: adId) where slot[key] == nil {
46
+ slot[key] = value
47
+ }
48
+ }
49
+ }
@@ -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
@@ -582,6 +639,9 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
582
639
  if !traits.isEmpty {
583
640
  body["metadata"] = traits
584
641
  }
642
+ if let userHash = call.getString("userHash"), !userHash.isEmpty {
643
+ body["userHash"] = userHash
644
+ }
585
645
 
586
646
  var request = URLRequest(url: url)
587
647
  request.httpMethod = "POST"
@@ -927,10 +987,11 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
927
987
  payload["platform"] = AnyCodable("ios")
928
988
  payload["timestamp"] = AnyCodable(ISO8601DateFormatter().string(from: Date()))
929
989
  payload["visitorId"] = AnyCodable(visitorId)
930
- let mobileSlot: [String: Any] = [
990
+ var mobileSlot: [String: Any] = [
931
991
  "releaseChannel": releaseChannel,
932
992
  "releaseChannelSource": releaseChannelSource,
933
993
  ]
994
+ stampDevice(&mobileSlot)
934
995
  payload["mobile"] = AnyCodable(mobileSlot)
935
996
  let event = NativeQueuedEvent(
936
997
  id: UUID().uuidString.lowercased(),
@@ -944,6 +1005,17 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
944
1005
 
945
1006
  // MARK: - Events & Queue
946
1007
 
1008
+ /// The device, the tracking answer, and the IDFA when the person allowed
1009
+ /// it. Read per event: the answer can change in Settings mid-session.
1010
+ private func stampDevice(_ slot: inout [String: Any]) {
1011
+ MobileContext.fill(
1012
+ &slot,
1013
+ device: deviceMeta,
1014
+ att: attManager.currentStatus(),
1015
+ adId: adIds.current()
1016
+ )
1017
+ }
1018
+
947
1019
  @objc public func track(_ call: CAPPluginCall) {
948
1020
  guard let queue = offlineQueue else {
949
1021
  call.reject("Plugin not initialized — call initialize() first")
@@ -955,6 +1027,11 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
955
1027
  call.resolve()
956
1028
  return
957
1029
  }
1030
+ if call.getString("type") == "app_install" && !claimInstall() {
1031
+ if debug { print("[apex-capacitor] app_install already sent for this install; dropping") }
1032
+ call.resolve()
1033
+ return
1034
+ }
958
1035
 
959
1036
  let id = call.getString("id") ?? UUID().uuidString.lowercased()
960
1037
  let payloadDict = call.options.compactMapValues { $0 }
@@ -1001,6 +1078,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
1001
1078
  }
1002
1079
  mobileSlot["releaseChannel"] = releaseChannel
1003
1080
  mobileSlot["releaseChannelSource"] = releaseChannelSource
1081
+ stampDevice(&mobileSlot)
1004
1082
  codablePayload["mobile"] = AnyCodable(mobileSlot)
1005
1083
  let event = NativeQueuedEvent(
1006
1084
  id: id,
@@ -1063,6 +1141,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
1063
1141
  if granted {
1064
1142
  // Resume: flush anything captured before this point.
1065
1143
  kickFlush(reason: "consent")
1144
+ sendInstallIfFirstLaunch()
1066
1145
  } else {
1067
1146
  // Revoke: drop anything queued but not yet sent.
1068
1147
  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.0",
3
+ "version": "2.6.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",
@@ -54,7 +54,10 @@
54
54
  "clean": "rimraf ./dist",
55
55
  "watch": "tsc --watch",
56
56
  "test": "vitest run",
57
- "test:watch": "vitest"
57
+ "test:watch": "vitest",
58
+ "prepublishOnly": "node ../../scripts/product-update/assert.mjs && npm run build",
59
+ "postpublish": "node ../../scripts/product-update/notify.mjs",
60
+ "notify-product-update": "node ../../scripts/product-update/notify.mjs --send"
58
61
  },
59
62
  "devDependencies": {
60
63
  "@capacitor/core": "^7.0.0",
package/ios/Package.swift DELETED
@@ -1,34 +0,0 @@
1
- // swift-tools-version: 5.9
2
- //
3
- // This manifest builds the pure-logic portion of @apex-inc/capacitor-plugin
4
- // for standalone test runs via `swift test`. The Capacitor-dependent plugin
5
- // entry point (ApexCapacitorPlugin.swift) lives in a sibling folder and is
6
- // compiled only through the CocoaPods build path (see .podspec) because it
7
- // imports the Capacitor framework.
8
- //
9
- // Consumers installing the plugin in a Capacitor app get BOTH sources via
10
- // `npx cap sync` + CocoaPods — this split is purely a testability concern.
11
- //
12
- import PackageDescription
13
-
14
- let package = Package(
15
- name: "ApexCapacitorPlugin",
16
- platforms: [.iOS(.v14)],
17
- products: [
18
- .library(
19
- name: "ApexCapacitorPlugin",
20
- targets: ["ApexCapacitorPlugin"]
21
- ),
22
- ],
23
- targets: [
24
- .target(
25
- name: "ApexCapacitorPlugin",
26
- path: "Sources/ApexCapacitorPlugin"
27
- ),
28
- .testTarget(
29
- name: "ApexCapacitorPluginTests",
30
- dependencies: ["ApexCapacitorPlugin"],
31
- path: "Tests/ApexCapacitorPluginTests"
32
- ),
33
- ]
34
- )