@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.
- package/ApexIncCapacitorPlugin.podspec +1 -0
- package/README.md +77 -11
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +255 -20
- package/android/src/main/java/inc/apex/capacitor/FirstLaunchInstall.kt +28 -0
- package/android/src/main/java/inc/apex/capacitor/MetaInstallReferrer.kt +75 -0
- package/android/src/main/java/inc/apex/capacitor/MobileContext.kt +43 -0
- package/android/src/main/java/inc/apex/capacitor/TrackStamps.kt +26 -0
- package/dist/definitions.d.ts +45 -19
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/definitions.d.ts +45 -19
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +1 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +1 -0
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/FirstLaunchInstall.swift +50 -0
- package/ios/Sources/ApexCapacitorPlugin/MobileContext.swift +49 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +80 -1
- package/ios/Sources/PrivacyInfo.xcprivacy +72 -0
- package/ios/Tests/ApexCapacitorPluginTests/FirstLaunchInstallTests.swift +66 -0
- package/ios/Tests/ApexCapacitorPluginTests/MobileContextTests.swift +56 -0
- package/package.json +5 -2
- package/ios/Package.swift +0 -34
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
)
|