@apex-inc/capacitor-plugin 0.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.
Files changed (83) hide show
  1. package/ApexCapacitorPlugin.podspec +17 -0
  2. package/LICENSE +17 -0
  3. package/README.md +136 -0
  4. package/android/build.gradle +68 -0
  5. package/android/src/main/AndroidManifest.xml +8 -0
  6. package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +325 -0
  7. package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +47 -0
  8. package/android/src/main/java/inc/apex/capacitor/InstallReferrerParser.kt +123 -0
  9. package/android/src/main/java/inc/apex/capacitor/OfflineQueue.kt +150 -0
  10. package/android/src/main/java/inc/apex/capacitor/SessionManager.kt +108 -0
  11. package/dist/batch-sender.d.ts +60 -0
  12. package/dist/batch-sender.d.ts.map +1 -0
  13. package/dist/batch-sender.js +115 -0
  14. package/dist/batch-sender.js.map +1 -0
  15. package/dist/definitions.d.ts +224 -0
  16. package/dist/definitions.d.ts.map +1 -0
  17. package/dist/definitions.js +14 -0
  18. package/dist/definitions.js.map +1 -0
  19. package/dist/esm/batch-sender.d.ts +60 -0
  20. package/dist/esm/batch-sender.d.ts.map +1 -0
  21. package/dist/esm/batch-sender.js +111 -0
  22. package/dist/esm/batch-sender.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +224 -0
  24. package/dist/esm/definitions.d.ts.map +1 -0
  25. package/dist/esm/definitions.js +13 -0
  26. package/dist/esm/definitions.js.map +1 -0
  27. package/dist/esm/event-id.d.ts +17 -0
  28. package/dist/esm/event-id.d.ts.map +1 -0
  29. package/dist/esm/event-id.js +57 -0
  30. package/dist/esm/event-id.js.map +1 -0
  31. package/dist/esm/index.d.ts +29 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +30 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/offline-queue.d.ts +111 -0
  36. package/dist/esm/offline-queue.d.ts.map +1 -0
  37. package/dist/esm/offline-queue.js +240 -0
  38. package/dist/esm/offline-queue.js.map +1 -0
  39. package/dist/esm/session-manager.d.ts +63 -0
  40. package/dist/esm/session-manager.d.ts.map +1 -0
  41. package/dist/esm/session-manager.js +100 -0
  42. package/dist/esm/session-manager.js.map +1 -0
  43. package/dist/esm/web.d.ts +65 -0
  44. package/dist/esm/web.d.ts.map +1 -0
  45. package/dist/esm/web.js +203 -0
  46. package/dist/esm/web.js.map +1 -0
  47. package/dist/event-id.d.ts +17 -0
  48. package/dist/event-id.d.ts.map +1 -0
  49. package/dist/event-id.js +61 -0
  50. package/dist/event-id.js.map +1 -0
  51. package/dist/index.d.ts +29 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +76 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/offline-queue.d.ts +111 -0
  56. package/dist/offline-queue.d.ts.map +1 -0
  57. package/dist/offline-queue.js +246 -0
  58. package/dist/offline-queue.js.map +1 -0
  59. package/dist/session-manager.d.ts +63 -0
  60. package/dist/session-manager.d.ts.map +1 -0
  61. package/dist/session-manager.js +104 -0
  62. package/dist/session-manager.js.map +1 -0
  63. package/dist/web.d.ts +65 -0
  64. package/dist/web.d.ts.map +1 -0
  65. package/dist/web.js +207 -0
  66. package/dist/web.js.map +1 -0
  67. package/ios/Package.swift +34 -0
  68. package/ios/Sources/ApexCapacitorPlugin/AdvertisingIdProvider.swift +66 -0
  69. package/ios/Sources/ApexCapacitorPlugin/AttManager.swift +82 -0
  70. package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +64 -0
  71. package/ios/Sources/ApexCapacitorPlugin/DeviceInfo.swift +107 -0
  72. package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +191 -0
  73. package/ios/Sources/ApexCapacitorPlugin/SessionManager.swift +113 -0
  74. package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +95 -0
  75. package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +269 -0
  76. package/ios/Tests/ApexCapacitorPluginTests/AdvertisingIdProviderTests.swift +74 -0
  77. package/ios/Tests/ApexCapacitorPluginTests/AttManagerTests.swift +82 -0
  78. package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +69 -0
  79. package/ios/Tests/ApexCapacitorPluginTests/DeviceInfoTests.swift +52 -0
  80. package/ios/Tests/ApexCapacitorPluginTests/OfflineQueueTests.swift +134 -0
  81. package/ios/Tests/ApexCapacitorPluginTests/SessionManagerTests.swift +98 -0
  82. package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +91 -0
  83. package/package.json +82 -0
@@ -0,0 +1,74 @@
1
+ //
2
+ // AdvertisingIdProviderTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class AdvertisingIdProviderTests: XCTestCase {
9
+
10
+ private func makeProvider(
11
+ attStatus: AttStatus,
12
+ idfa: String?,
13
+ idfv: String?
14
+ ) -> AdvertisingIdProvider {
15
+ let tracker = FakeAttTracker(nextStatus: attStatus)
16
+ let attManager = AttManager(tracker: tracker)
17
+ return AdvertisingIdProvider(
18
+ attManager: attManager,
19
+ idfaProvider: { idfa },
20
+ idfvProvider: { idfv }
21
+ )
22
+ }
23
+
24
+ func testReturnsIdfaWhenAuthorizedAndNotZero() {
25
+ let provider = makeProvider(
26
+ attStatus: .authorized,
27
+ idfa: "12345678-1234-4567-8901-123456789012",
28
+ idfv: "vendor-id-unused"
29
+ )
30
+ let result = provider.current()
31
+ XCTAssertEqual(result.id, "12345678-1234-4567-8901-123456789012")
32
+ XCTAssertNil(result.fallback)
33
+ }
34
+
35
+ func testFallsBackToIdfvWhenDenied() {
36
+ let provider = makeProvider(
37
+ attStatus: .denied,
38
+ idfa: "should-not-be-used",
39
+ idfv: "vendor-abc-def"
40
+ )
41
+ let result = provider.current()
42
+ XCTAssertEqual(result.id, "vendor-abc-def")
43
+ XCTAssertEqual(result.fallback, "idfv")
44
+ }
45
+
46
+ func testFallsBackToIdfvWhenIdfaIsZero() {
47
+ let provider = makeProvider(
48
+ attStatus: .authorized,
49
+ idfa: "00000000-0000-0000-0000-000000000000",
50
+ idfv: "vendor-xyz"
51
+ )
52
+ let result = provider.current()
53
+ XCTAssertEqual(result.id, "vendor-xyz")
54
+ XCTAssertEqual(result.fallback, "idfv")
55
+ }
56
+
57
+ func testFallsBackToIdfvWhenNotDetermined() {
58
+ let provider = makeProvider(
59
+ attStatus: .notDetermined,
60
+ idfa: "12345678-1234-4567-8901-123456789012",
61
+ idfv: "vendor-not-yet"
62
+ )
63
+ let result = provider.current()
64
+ XCTAssertEqual(result.id, "vendor-not-yet")
65
+ XCTAssertEqual(result.fallback, "idfv")
66
+ }
67
+
68
+ func testReturnsNilWhenBothUnavailable() {
69
+ let provider = makeProvider(attStatus: .denied, idfa: nil, idfv: nil)
70
+ let result = provider.current()
71
+ XCTAssertNil(result.id)
72
+ XCTAssertNil(result.fallback)
73
+ }
74
+ }
@@ -0,0 +1,82 @@
1
+ //
2
+ // AttManagerTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class FakeAttTracker: AttTrackerProtocol {
9
+ var nextStatus: AttStatus
10
+ var requestedCount = 0
11
+
12
+ init(nextStatus: AttStatus) {
13
+ self.nextStatus = nextStatus
14
+ }
15
+
16
+ func currentStatus() -> AttStatus {
17
+ return nextStatus
18
+ }
19
+
20
+ func requestAuthorization(completion: @escaping (UInt) -> Void) {
21
+ requestedCount += 1
22
+ let raw: UInt = {
23
+ switch nextStatus {
24
+ case .notDetermined: return 0
25
+ case .restricted: return 1
26
+ case .denied: return 2
27
+ case .authorized: return 3
28
+ }
29
+ }()
30
+ completion(raw)
31
+ }
32
+ }
33
+
34
+ final class AttManagerTests: XCTestCase {
35
+
36
+ func testMapsAllAuthorizationStatuses() {
37
+ XCTAssertEqual(AttStatus.from(authorizationRawValue: 0), .notDetermined)
38
+ XCTAssertEqual(AttStatus.from(authorizationRawValue: 1), .restricted)
39
+ XCTAssertEqual(AttStatus.from(authorizationRawValue: 2), .denied)
40
+ XCTAssertEqual(AttStatus.from(authorizationRawValue: 3), .authorized)
41
+ }
42
+
43
+ func testMapsUnknownStatusToNotDetermined() {
44
+ XCTAssertEqual(AttStatus.from(authorizationRawValue: 99), .notDetermined)
45
+ }
46
+
47
+ func testCurrentStatusReturnsTrackerValue() {
48
+ let tracker = FakeAttTracker(nextStatus: .denied)
49
+ let manager = AttManager(tracker: tracker)
50
+ XCTAssertEqual(manager.currentStatus(), .denied)
51
+ }
52
+
53
+ func testRequestReturnsAuthorized() {
54
+ let tracker = FakeAttTracker(nextStatus: .authorized)
55
+ let manager = AttManager(tracker: tracker)
56
+ let expectation = XCTestExpectation(description: "request completes")
57
+ manager.request { status in
58
+ XCTAssertEqual(status, .authorized)
59
+ expectation.fulfill()
60
+ }
61
+ wait(for: [expectation], timeout: 0.5)
62
+ XCTAssertEqual(tracker.requestedCount, 1)
63
+ }
64
+
65
+ func testRequestReturnsDenied() {
66
+ let tracker = FakeAttTracker(nextStatus: .denied)
67
+ let manager = AttManager(tracker: tracker)
68
+ let expectation = XCTestExpectation(description: "request completes")
69
+ manager.request { status in
70
+ XCTAssertEqual(status, .denied)
71
+ expectation.fulfill()
72
+ }
73
+ wait(for: [expectation], timeout: 0.5)
74
+ }
75
+
76
+ func testAttStatusRawValueStrings() {
77
+ XCTAssertEqual(AttStatus.authorized.rawValue, "authorized")
78
+ XCTAssertEqual(AttStatus.denied.rawValue, "denied")
79
+ XCTAssertEqual(AttStatus.restricted.rawValue, "restricted")
80
+ XCTAssertEqual(AttStatus.notDetermined.rawValue, "not-determined")
81
+ }
82
+ }
@@ -0,0 +1,69 @@
1
+ //
2
+ // DeepLinkManagerTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class DeepLinkManagerTests: XCTestCase {
9
+
10
+ func testConsumeInitialUrlReturnsNilWhenNotSet() {
11
+ let manager = DeepLinkManager()
12
+ XCTAssertNil(manager.consumeInitialUrl())
13
+ }
14
+
15
+ func testConsumeInitialUrlReturnsStoredUrl() {
16
+ let manager = DeepLinkManager()
17
+ let url = URL(string: "https://links.apex.inc/abc123")!
18
+ manager.setInitialUrl(url)
19
+ XCTAssertEqual(manager.consumeInitialUrl(), url)
20
+ }
21
+
22
+ func testConsumeInitialUrlIsOneShot() {
23
+ let manager = DeepLinkManager()
24
+ manager.setInitialUrl(URL(string: "apexapp://home")!)
25
+ _ = manager.consumeInitialUrl()
26
+ XCTAssertNil(manager.consumeInitialUrl())
27
+ }
28
+
29
+ func testSetInitialUrlDoesNotOverwriteExisting() {
30
+ let manager = DeepLinkManager()
31
+ manager.setInitialUrl(URL(string: "apexapp://first")!)
32
+ manager.setInitialUrl(URL(string: "apexapp://second")!)
33
+ XCTAssertEqual(manager.consumeInitialUrl()?.absoluteString, "apexapp://first")
34
+ }
35
+
36
+ func testWarmLinkDispatchesToHandler() {
37
+ var received: URL?
38
+ let manager = DeepLinkManager()
39
+ manager.setWarmLinkHandler { url in
40
+ received = url
41
+ }
42
+ let url = URL(string: "https://links.apex.inc/xyz")!
43
+ manager.deliverWarmLink(url)
44
+ XCTAssertEqual(received, url)
45
+ }
46
+
47
+ func testWarmLinkReplacesHandler() {
48
+ var first = 0
49
+ var second = 0
50
+ let manager = DeepLinkManager()
51
+ manager.setWarmLinkHandler { _ in first += 1 }
52
+ manager.setWarmLinkHandler { _ in second += 1 }
53
+ manager.deliverWarmLink(URL(string: "apexapp://home")!)
54
+ XCTAssertEqual(first, 0)
55
+ XCTAssertEqual(second, 1)
56
+ }
57
+
58
+ func testIsValidDeepLinkAcceptsHttps() {
59
+ XCTAssertTrue(DeepLinkManager.isValidDeepLink(URL(string: "https://links.apex.inc/abc")!))
60
+ }
61
+
62
+ func testIsValidDeepLinkAcceptsCustomScheme() {
63
+ XCTAssertTrue(DeepLinkManager.isValidDeepLink(URL(string: "apexapp://home")!))
64
+ }
65
+
66
+ func testIsValidDeepLinkRejectsFileUrl() {
67
+ XCTAssertFalse(DeepLinkManager.isValidDeepLink(URL(string: "file:///tmp/test")!))
68
+ }
69
+ }
@@ -0,0 +1,52 @@
1
+ //
2
+ // DeviceInfoTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class DeviceInfoTests: XCTestCase {
9
+
10
+ func testCollectReportsPlatformIos() {
11
+ let info = DeviceInfo()
12
+ let meta = info.collect()
13
+ XCTAssertEqual(meta.platform, "ios")
14
+ }
15
+
16
+ func testCollectUsesInjectedTimezoneAndLocale() {
17
+ let info = DeviceInfo(
18
+ bundle: .main,
19
+ tzProvider: { "America/New_York" },
20
+ localeProvider: { "en-US" },
21
+ modelProvider: { "iPhone15,2" },
22
+ osVersionProvider: { "17.4" }
23
+ )
24
+ let meta = info.collect()
25
+ XCTAssertEqual(meta.timezone, "America/New_York")
26
+ XCTAssertEqual(meta.locale, "en-US")
27
+ XCTAssertEqual(meta.model, "iPhone15,2")
28
+ XCTAssertEqual(meta.osVersion, "17.4")
29
+ }
30
+
31
+ func testDeviceMetadataEquatable() {
32
+ let a = DeviceMetadata(
33
+ platform: "ios",
34
+ osVersion: "17.4",
35
+ model: "iPhone15,2",
36
+ appVersion: "1.0.0",
37
+ bundleId: "com.example.app",
38
+ timezone: "UTC",
39
+ locale: "en-US"
40
+ )
41
+ let b = DeviceMetadata(
42
+ platform: "ios",
43
+ osVersion: "17.4",
44
+ model: "iPhone15,2",
45
+ appVersion: "1.0.0",
46
+ bundleId: "com.example.app",
47
+ timezone: "UTC",
48
+ locale: "en-US"
49
+ )
50
+ XCTAssertEqual(a, b)
51
+ }
52
+ }
@@ -0,0 +1,134 @@
1
+ //
2
+ // OfflineQueueTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class OfflineQueueTests: XCTestCase {
9
+
10
+ private func makeQueue(maxSize: Int = 100) -> (NativeOfflineQueue, URL) {
11
+ let tempDir = FileManager.default.temporaryDirectory
12
+ .appendingPathComponent("apex-queue-tests")
13
+ .appendingPathComponent(UUID().uuidString)
14
+ try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
15
+ let fileURL = tempDir.appendingPathComponent("events.json")
16
+ let queue = NativeOfflineQueue(fileURL: fileURL, maxSize: maxSize)
17
+ return (queue, fileURL)
18
+ }
19
+
20
+ private func makeEvent(id: String, type: String = "app_open") -> NativeQueuedEvent {
21
+ return NativeQueuedEvent(
22
+ id: id,
23
+ payload: ["type": AnyCodable(type)],
24
+ attempts: 0,
25
+ enqueuedAt: Date()
26
+ )
27
+ }
28
+
29
+ override func tearDownWithError() throws {
30
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("apex-queue-tests")
31
+ try? FileManager.default.removeItem(at: root)
32
+ }
33
+
34
+ func testEnqueueAndSize() throws {
35
+ let (queue, _) = makeQueue()
36
+ try queue.enqueue(makeEvent(id: "1"))
37
+ XCTAssertEqual(try queue.size(), 1)
38
+
39
+ try queue.enqueue(makeEvent(id: "2"))
40
+ XCTAssertEqual(try queue.size(), 2)
41
+ }
42
+
43
+ func testPeekReturnsInOrder() throws {
44
+ let (queue, _) = makeQueue()
45
+ try queue.enqueue(makeEvent(id: "a"))
46
+ try queue.enqueue(makeEvent(id: "b"))
47
+ try queue.enqueue(makeEvent(id: "c"))
48
+
49
+ let peeked = try queue.peek(batchSize: 10)
50
+ XCTAssertEqual(peeked.map { $0.id }, ["a", "b", "c"])
51
+ }
52
+
53
+ func testMarkSentRemovesById() throws {
54
+ let (queue, _) = makeQueue()
55
+ try queue.enqueue(makeEvent(id: "a"))
56
+ try queue.enqueue(makeEvent(id: "b"))
57
+ try queue.enqueue(makeEvent(id: "c"))
58
+
59
+ try queue.markSent(ids: ["b"])
60
+ let remaining = try queue.peek(batchSize: 10)
61
+ XCTAssertEqual(remaining.map { $0.id }, ["a", "c"])
62
+ }
63
+
64
+ func testMarkFailedIncrementsAttempts() throws {
65
+ let (queue, _) = makeQueue()
66
+ try queue.enqueue(makeEvent(id: "a"))
67
+ try queue.enqueue(makeEvent(id: "b"))
68
+
69
+ try queue.markFailed(ids: ["a"])
70
+ let peeked = try queue.peek(batchSize: 10)
71
+ let a = peeked.first { $0.id == "a" }!
72
+ let b = peeked.first { $0.id == "b" }!
73
+ XCTAssertEqual(a.attempts, 1)
74
+ XCTAssertEqual(b.attempts, 0)
75
+ }
76
+
77
+ func testMaxSizeEvictsOldest() throws {
78
+ let (queue, _) = makeQueue(maxSize: 3)
79
+ for id in ["a", "b", "c", "d", "e"] {
80
+ try queue.enqueue(makeEvent(id: id))
81
+ }
82
+ let peeked = try queue.peek(batchSize: 10)
83
+ XCTAssertEqual(peeked.map { $0.id }, ["c", "d", "e"])
84
+ }
85
+
86
+ func testOldestEventAtNilWhenEmpty() throws {
87
+ let (queue, _) = makeQueue()
88
+ XCTAssertNil(try queue.oldestEventAt())
89
+ }
90
+
91
+ func testOldestEventAtReturnsFirstEnqueued() throws {
92
+ let (queue, _) = makeQueue()
93
+ let event = makeEvent(id: "a")
94
+ try queue.enqueue(event)
95
+ let oldest = try queue.oldestEventAt()
96
+ XCTAssertNotNil(oldest)
97
+ XCTAssertEqual(oldest!.timeIntervalSince1970,
98
+ event.enqueuedAt.timeIntervalSince1970,
99
+ accuracy: 1.0)
100
+ }
101
+
102
+ func testClearEmpties() throws {
103
+ let (queue, _) = makeQueue()
104
+ try queue.enqueue(makeEvent(id: "a"))
105
+ try queue.enqueue(makeEvent(id: "b"))
106
+ XCTAssertEqual(try queue.size(), 2)
107
+ try queue.clear()
108
+ XCTAssertEqual(try queue.size(), 0)
109
+ }
110
+
111
+ func testPersistsAcrossReopens() throws {
112
+ let (queue1, fileURL) = makeQueue()
113
+ try queue1.enqueue(makeEvent(id: "a"))
114
+ try queue1.enqueue(makeEvent(id: "b"))
115
+ XCTAssertEqual(try queue1.size(), 2)
116
+
117
+ // Reopen the same file with a fresh queue instance.
118
+ let queue2 = NativeOfflineQueue(fileURL: fileURL)
119
+ XCTAssertEqual(try queue2.size(), 2)
120
+ let peeked = try queue2.peek(batchSize: 10)
121
+ XCTAssertEqual(peeked.map { $0.id }, ["a", "b"])
122
+ }
123
+
124
+ func testEnqueueWithEmptyIdThrows() {
125
+ let (queue, _) = makeQueue()
126
+ let event = NativeQueuedEvent(
127
+ id: "",
128
+ payload: [:],
129
+ attempts: 0,
130
+ enqueuedAt: Date()
131
+ )
132
+ XCTAssertThrowsError(try queue.enqueue(event))
133
+ }
134
+ }
@@ -0,0 +1,98 @@
1
+ //
2
+ // SessionManagerTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class SessionManagerTests: XCTestCase {
9
+
10
+ func testEmitsSessionStartOnFirstActivity() {
11
+ var starts: [String] = []
12
+ let manager = NativeSessionManager(
13
+ timeoutMinutes: 30,
14
+ onStart: { snapshot in starts.append(snapshot.sessionId) }
15
+ )
16
+ _ = manager.recordActivity()
17
+ XCTAssertEqual(starts.count, 1)
18
+ }
19
+
20
+ func testDoesNotEmitOnSubsequentActivity() {
21
+ var starts = 0
22
+ let manager = NativeSessionManager(
23
+ timeoutMinutes: 30,
24
+ onStart: { _ in starts += 1 }
25
+ )
26
+ _ = manager.recordActivity()
27
+ _ = manager.recordActivity()
28
+ _ = manager.recordActivity()
29
+ XCTAssertEqual(starts, 1)
30
+ }
31
+
32
+ func testPreservesSessionId() {
33
+ let manager = NativeSessionManager(timeoutMinutes: 30)
34
+ let s1 = manager.recordActivity()
35
+ let s2 = manager.recordActivity()
36
+ XCTAssertEqual(s1.sessionId, s2.sessionId)
37
+ }
38
+
39
+ func testIncrementsEventCount() {
40
+ let manager = NativeSessionManager(timeoutMinutes: 30)
41
+ _ = manager.recordActivity()
42
+ _ = manager.recordActivity()
43
+ _ = manager.recordActivity()
44
+ XCTAssertEqual(manager.getCurrent()?.eventCount, 3)
45
+ }
46
+
47
+ func testEndSessionClearsCurrent() {
48
+ let manager = NativeSessionManager(timeoutMinutes: 30)
49
+ _ = manager.recordActivity()
50
+ XCTAssertNotNil(manager.getCurrent())
51
+ manager.endSession()
52
+ XCTAssertNil(manager.getCurrent())
53
+ }
54
+
55
+ func testEndSessionComputesDuration() {
56
+ var ended: NativeSessionSnapshot?
57
+ var mockClock: Date = Date()
58
+ let manager = NativeSessionManager(
59
+ timeoutMinutes: 30,
60
+ clock: { mockClock },
61
+ onEnd: { snapshot in ended = snapshot }
62
+ )
63
+ _ = manager.recordActivity()
64
+ mockClock = mockClock.addingTimeInterval(15 * 60)
65
+ manager.endSession()
66
+
67
+ XCTAssertNotNil(ended)
68
+ XCTAssertEqual(ended?.durationSeconds, 15 * 60)
69
+ }
70
+
71
+ func testForceStartEndsPrevious() {
72
+ var startIds: [String] = []
73
+ var endIds: [String] = []
74
+ let manager = NativeSessionManager(
75
+ timeoutMinutes: 30,
76
+ onStart: { s in startIds.append(s.sessionId) },
77
+ onEnd: { s in endIds.append(s.sessionId) }
78
+ )
79
+
80
+ _ = manager.recordActivity()
81
+ let firstId = startIds.first!
82
+ _ = manager.forceStart()
83
+
84
+ XCTAssertEqual(endIds, [firstId])
85
+ XCTAssertEqual(startIds.count, 2)
86
+ XCTAssertNotEqual(startIds[0], startIds[1])
87
+ }
88
+
89
+ func testEndSessionNoOpWhenIdle() {
90
+ var endCount = 0
91
+ let manager = NativeSessionManager(
92
+ timeoutMinutes: 30,
93
+ onEnd: { _ in endCount += 1 }
94
+ )
95
+ manager.endSession()
96
+ XCTAssertEqual(endCount, 0)
97
+ }
98
+ }
@@ -0,0 +1,91 @@
1
+ //
2
+ // SkanManagerTests.swift
3
+ //
4
+
5
+ import XCTest
6
+ @testable import ApexCapacitorPlugin
7
+
8
+ final class RecordingSkanUpdater: SkanUpdaterProtocol {
9
+ struct Call: Equatable {
10
+ let fineValue: Int
11
+ let coarseValue: SkanCoarseValue?
12
+ }
13
+ var calls: [Call] = []
14
+ var nextError: Error?
15
+
16
+ func updateConversionValue(
17
+ fineValue: Int,
18
+ coarseValue: SkanCoarseValue?,
19
+ completion: @escaping (Error?) -> Void
20
+ ) {
21
+ calls.append(Call(fineValue: fineValue, coarseValue: coarseValue))
22
+ completion(nextError)
23
+ }
24
+ }
25
+
26
+ final class SkanManagerTests: XCTestCase {
27
+ func testValidateAcceptsZeroToSixtyThree() throws {
28
+ XCTAssertNoThrow(try SkanManager.validateFineValue(0))
29
+ XCTAssertNoThrow(try SkanManager.validateFineValue(1))
30
+ XCTAssertNoThrow(try SkanManager.validateFineValue(32))
31
+ XCTAssertNoThrow(try SkanManager.validateFineValue(63))
32
+ }
33
+
34
+ func testValidateRejectsOutOfRange() {
35
+ XCTAssertThrowsError(try SkanManager.validateFineValue(-1))
36
+ XCTAssertThrowsError(try SkanManager.validateFineValue(64))
37
+ XCTAssertThrowsError(try SkanManager.validateFineValue(100))
38
+ }
39
+
40
+ func testUpdatePassesFineValueThrough() {
41
+ let updater = RecordingSkanUpdater()
42
+ let manager = SkanManager(updater: updater)
43
+
44
+ let exp = expectation(description: "update completes")
45
+ manager.update(fineValue: 42, coarseValue: nil) { error in
46
+ XCTAssertNil(error)
47
+ exp.fulfill()
48
+ }
49
+ wait(for: [exp], timeout: 0.5)
50
+
51
+ XCTAssertEqual(updater.calls.count, 1)
52
+ XCTAssertEqual(updater.calls[0].fineValue, 42)
53
+ XCTAssertNil(updater.calls[0].coarseValue)
54
+ }
55
+
56
+ func testUpdateSupportsCoarseValue() {
57
+ let updater = RecordingSkanUpdater()
58
+ let manager = SkanManager(updater: updater)
59
+
60
+ let exp = expectation(description: "update completes")
61
+ manager.update(fineValue: 10, coarseValue: .medium) { _ in exp.fulfill() }
62
+ wait(for: [exp], timeout: 0.5)
63
+
64
+ XCTAssertEqual(updater.calls[0].coarseValue, .medium)
65
+ }
66
+
67
+ func testUpdateRejectsOutOfRangeFineValue() {
68
+ let updater = RecordingSkanUpdater()
69
+ let manager = SkanManager(updater: updater)
70
+
71
+ let exp = expectation(description: "update rejected")
72
+ manager.update(fineValue: 200) { error in
73
+ XCTAssertNotNil(error)
74
+ if let skanError = error as? SkanError {
75
+ XCTAssertEqual(skanError, .fineValueOutOfRange(200))
76
+ } else {
77
+ XCTFail("Expected SkanError")
78
+ }
79
+ exp.fulfill()
80
+ }
81
+ wait(for: [exp], timeout: 0.5)
82
+
83
+ XCTAssertTrue(updater.calls.isEmpty, "Updater should not be called when validation fails")
84
+ }
85
+
86
+ func testCoarseValueRawStrings() {
87
+ XCTAssertEqual(SkanCoarseValue.low.rawValue, "low")
88
+ XCTAssertEqual(SkanCoarseValue.medium.rawValue, "medium")
89
+ XCTAssertEqual(SkanCoarseValue.high.rawValue, "high")
90
+ }
91
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@apex-inc/capacitor-plugin",
3
+ "version": "0.1.0",
4
+ "description": "Apex Capacitor plugin — iOS/Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/esm/index.js",
11
+ "require": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "android/src/main/",
17
+ "android/build.gradle",
18
+ "dist/",
19
+ "ios/Sources/",
20
+ "ios/Tests/",
21
+ "Package.swift",
22
+ "ApexCapacitorPlugin.podspec"
23
+ ],
24
+ "author": "Apex Inc.",
25
+ "license": "Apache-2.0",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/apex-incorporated/capacitor-plugin"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/apex-incorporated/capacitor-plugin/issues"
32
+ },
33
+ "homepage": "https://apex.inc/products/mobile-measurement",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "keywords": [
38
+ "capacitor",
39
+ "plugin",
40
+ "ios",
41
+ "android",
42
+ "apex",
43
+ "attribution",
44
+ "analytics",
45
+ "mmp",
46
+ "mobile-measurement",
47
+ "skadnetwork",
48
+ "deep-linking"
49
+ ],
50
+ "scripts": {
51
+ "verify": "npm run verify:typecheck && npm run test && npm run build",
52
+ "verify:typecheck": "tsc --noEmit",
53
+ "build": "npm run clean && tsc && tsc -p tsconfig.esm.json",
54
+ "clean": "rimraf ./dist",
55
+ "watch": "tsc --watch",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest"
58
+ },
59
+ "devDependencies": {
60
+ "@capacitor/core": "^7.0.0",
61
+ "@types/node": "^22.0.0",
62
+ "@vitest/coverage-v8": "^3.2.0",
63
+ "fake-indexeddb": "^6.0.0",
64
+ "rimraf": "^6.0.0",
65
+ "typescript": "^5.7.0",
66
+ "vitest": "^3.2.0"
67
+ },
68
+ "peerDependencies": {
69
+ "@capacitor/core": ">=6.0.0"
70
+ },
71
+ "capacitor": {
72
+ "ios": {
73
+ "src": "ios"
74
+ },
75
+ "android": {
76
+ "src": "android"
77
+ }
78
+ },
79
+ "engines": {
80
+ "node": ">=18"
81
+ }
82
+ }