@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,191 @@
1
+ //
2
+ // OfflineQueue.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // Durable event queue backed by a file inside the app container. Using
6
+ // an append-only file on disk keeps things simple — events are small
7
+ // JSON blobs, we never query by index, and Core Data overhead isn't
8
+ // justified at the volumes we expect (single-digit events per session
9
+ // typical, capped at `maxSize`).
10
+ //
11
+ // Thread-safe via a serial dispatch queue. The JS-side queue is the
12
+ // source of truth; this native queue exists so events survive
13
+ // app termination on iOS 14+ where the JS queue would be lost.
14
+ //
15
+
16
+ import Foundation
17
+
18
+ public struct NativeQueuedEvent: Codable, Equatable {
19
+ public let id: String
20
+ public let payload: [String: AnyCodable]
21
+ public var attempts: Int
22
+ public let enqueuedAt: Date
23
+ }
24
+
25
+ public enum OfflineQueueError: Error {
26
+ case missingId
27
+ case encodingFailed
28
+ }
29
+
30
+ public final class NativeOfflineQueue {
31
+ private let fileURL: URL
32
+ private let maxSize: Int
33
+ private let queue = DispatchQueue(label: "inc.apex.offline-queue")
34
+
35
+ public init(fileURL: URL, maxSize: Int = 1000) {
36
+ self.fileURL = fileURL
37
+ self.maxSize = max(1, maxSize)
38
+ ensureStorageDirectory()
39
+ }
40
+
41
+ public func enqueue(_ event: NativeQueuedEvent) throws {
42
+ if event.id.isEmpty { throw OfflineQueueError.missingId }
43
+ try queue.sync {
44
+ var events = try readLocked()
45
+ events.append(event)
46
+ if events.count > maxSize {
47
+ events.removeFirst(events.count - maxSize)
48
+ }
49
+ try writeLocked(events)
50
+ }
51
+ }
52
+
53
+ public func peek(batchSize: Int) throws -> [NativeQueuedEvent] {
54
+ if batchSize < 1 { return [] }
55
+ return try queue.sync {
56
+ let all = try readLocked()
57
+ return Array(all.prefix(batchSize))
58
+ }
59
+ }
60
+
61
+ public func markSent(ids: [String]) throws {
62
+ if ids.isEmpty { return }
63
+ try queue.sync {
64
+ var events = try readLocked()
65
+ let idSet = Set(ids)
66
+ events.removeAll { idSet.contains($0.id) }
67
+ try writeLocked(events)
68
+ }
69
+ }
70
+
71
+ public func markFailed(ids: [String]) throws {
72
+ if ids.isEmpty { return }
73
+ try queue.sync {
74
+ var events = try readLocked()
75
+ let idSet = Set(ids)
76
+ events = events.map { event in
77
+ if idSet.contains(event.id) {
78
+ return NativeQueuedEvent(
79
+ id: event.id,
80
+ payload: event.payload,
81
+ attempts: event.attempts + 1,
82
+ enqueuedAt: event.enqueuedAt
83
+ )
84
+ }
85
+ return event
86
+ }
87
+ try writeLocked(events)
88
+ }
89
+ }
90
+
91
+ public func size() throws -> Int {
92
+ return try queue.sync {
93
+ try readLocked().count
94
+ }
95
+ }
96
+
97
+ public func oldestEventAt() throws -> Date? {
98
+ return try queue.sync {
99
+ let events = try readLocked()
100
+ return events.first?.enqueuedAt
101
+ }
102
+ }
103
+
104
+ public func clear() throws {
105
+ try queue.sync {
106
+ try writeLocked([])
107
+ }
108
+ }
109
+
110
+ // MARK: - Private
111
+
112
+ private func ensureStorageDirectory() {
113
+ let dir = fileURL.deletingLastPathComponent()
114
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
115
+ }
116
+
117
+ private func readLocked() throws -> [NativeQueuedEvent] {
118
+ guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] }
119
+ let data = try Data(contentsOf: fileURL)
120
+ if data.isEmpty { return [] }
121
+ let decoder = JSONDecoder()
122
+ decoder.dateDecodingStrategy = .iso8601
123
+ return try decoder.decode([NativeQueuedEvent].self, from: data)
124
+ }
125
+
126
+ private func writeLocked(_ events: [NativeQueuedEvent]) throws {
127
+ let encoder = JSONEncoder()
128
+ encoder.dateEncodingStrategy = .iso8601
129
+ let data = try encoder.encode(events)
130
+ try data.write(to: fileURL, options: .atomic)
131
+ }
132
+ }
133
+
134
+ /// Minimal Codable wrapper for heterogeneous JSON values.
135
+ public struct AnyCodable: Codable, Equatable {
136
+ public let value: Any
137
+
138
+ public init(_ value: Any) {
139
+ self.value = value
140
+ }
141
+
142
+ public init(from decoder: Decoder) throws {
143
+ let container = try decoder.singleValueContainer()
144
+ if container.decodeNil() {
145
+ self.value = NSNull()
146
+ } else if let v = try? container.decode(Bool.self) {
147
+ self.value = v
148
+ } else if let v = try? container.decode(Int.self) {
149
+ self.value = v
150
+ } else if let v = try? container.decode(Double.self) {
151
+ self.value = v
152
+ } else if let v = try? container.decode(String.self) {
153
+ self.value = v
154
+ } else if let v = try? container.decode([AnyCodable].self) {
155
+ self.value = v.map { $0.value }
156
+ } else if let v = try? container.decode([String: AnyCodable].self) {
157
+ self.value = v.mapValues { $0.value }
158
+ } else {
159
+ self.value = NSNull()
160
+ }
161
+ }
162
+
163
+ public func encode(to encoder: Encoder) throws {
164
+ var container = encoder.singleValueContainer()
165
+ switch value {
166
+ case is NSNull:
167
+ try container.encodeNil()
168
+ case let v as Bool:
169
+ try container.encode(v)
170
+ case let v as Int:
171
+ try container.encode(v)
172
+ case let v as Double:
173
+ try container.encode(v)
174
+ case let v as String:
175
+ try container.encode(v)
176
+ case let v as [Any]:
177
+ try container.encode(v.map { AnyCodable($0) })
178
+ case let v as [String: Any]:
179
+ try container.encode(v.mapValues { AnyCodable($0) })
180
+ default:
181
+ try container.encodeNil()
182
+ }
183
+ }
184
+
185
+ public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
186
+ // Best-effort equality for Codable containers. Used in tests only.
187
+ let l = String(describing: lhs.value)
188
+ let r = String(describing: rhs.value)
189
+ return l == r
190
+ }
191
+ }
@@ -0,0 +1,113 @@
1
+ //
2
+ // SessionManager.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // Native mirror of the TS SessionManager — starts on first activity, ends
6
+ // after `timeoutMinutes` of inactivity. The JS bridge side also runs a
7
+ // session manager; the native one handles background-state transitions
8
+ // (which JS timers can't observe reliably) so sessions end cleanly when
9
+ // the OS suspends the app.
10
+ //
11
+
12
+ import Foundation
13
+
14
+ public struct NativeSessionSnapshot: Equatable {
15
+ public let sessionId: String
16
+ public let startedAt: Date
17
+ public var lastActivityAt: Date
18
+ public var eventCount: Int
19
+ public var endedAt: Date?
20
+ public var durationSeconds: Int?
21
+ }
22
+
23
+ public final class NativeSessionManager {
24
+ private let timeoutSeconds: TimeInterval
25
+ private let clock: () -> Date
26
+ private let onStart: ((NativeSessionSnapshot) -> Void)?
27
+ private let onEnd: ((NativeSessionSnapshot) -> Void)?
28
+ private var timer: Timer?
29
+ private(set) public var current: NativeSessionSnapshot?
30
+ private let queue = DispatchQueue(label: "inc.apex.session-manager")
31
+
32
+ public init(
33
+ timeoutMinutes: Int = 30,
34
+ clock: @escaping () -> Date = { Date() },
35
+ onStart: ((NativeSessionSnapshot) -> Void)? = nil,
36
+ onEnd: ((NativeSessionSnapshot) -> Void)? = nil
37
+ ) {
38
+ self.timeoutSeconds = TimeInterval(timeoutMinutes * 60)
39
+ self.clock = clock
40
+ self.onStart = onStart
41
+ self.onEnd = onEnd
42
+ }
43
+
44
+ public func recordActivity() -> NativeSessionSnapshot {
45
+ return queue.sync { () -> NativeSessionSnapshot in
46
+ let now = clock()
47
+ if current == nil {
48
+ let snapshot = NativeSessionSnapshot(
49
+ sessionId: UUID().uuidString.lowercased(),
50
+ startedAt: now,
51
+ lastActivityAt: now,
52
+ eventCount: 1,
53
+ endedAt: nil,
54
+ durationSeconds: nil
55
+ )
56
+ current = snapshot
57
+ onStart?(snapshot)
58
+ } else {
59
+ var s = current!
60
+ s.lastActivityAt = now
61
+ s.eventCount += 1
62
+ current = s
63
+ }
64
+ scheduleTimeoutLocked()
65
+ return current!
66
+ }
67
+ }
68
+
69
+ public func endSession() {
70
+ queue.sync {
71
+ endSessionLocked()
72
+ }
73
+ }
74
+
75
+ public func forceStart() -> NativeSessionSnapshot {
76
+ queue.sync {
77
+ if current != nil {
78
+ endSessionLocked()
79
+ }
80
+ }
81
+ return recordActivity()
82
+ }
83
+
84
+ public func getCurrent() -> NativeSessionSnapshot? {
85
+ return queue.sync { current }
86
+ }
87
+
88
+ // MARK: - Private
89
+
90
+ private func scheduleTimeoutLocked() {
91
+ timer?.invalidate()
92
+ let timer = Timer.scheduledTimer(withTimeInterval: timeoutSeconds, repeats: false) { [weak self] _ in
93
+ self?.queue.async {
94
+ self?.endSessionLocked()
95
+ }
96
+ }
97
+ RunLoop.main.add(timer, forMode: .common)
98
+ self.timer = timer
99
+ }
100
+
101
+ private func endSessionLocked() {
102
+ guard var snapshot = current else { return }
103
+ timer?.invalidate()
104
+ timer = nil
105
+
106
+ let endAt = clock()
107
+ snapshot.endedAt = endAt
108
+ let duration = max(0, Int(endAt.timeIntervalSince(snapshot.startedAt)))
109
+ snapshot.durationSeconds = duration
110
+ onEnd?(snapshot)
111
+ current = nil
112
+ }
113
+ }
@@ -0,0 +1,95 @@
1
+ //
2
+ // SkanManager.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // Wraps SKAdNetwork conversion value updates.
6
+ //
7
+ // - SKAN 3.0 (iOS 11.3-14.x): `SKAdNetwork.updateConversionValue(Int)` — fine value 0-63.
8
+ // - SKAN 4.0 (iOS 16.1+): `SKAdNetwork.updatePostbackConversionValue(_:coarseValue:completionHandler:)`
9
+ // — fine value 0-63 plus optional coarse value (`.low`, `.medium`, `.high`).
10
+ //
11
+ // The class validates inputs (0-63 fine) and routes to the correct API
12
+ // based on iOS availability and whether a coarse value is provided.
13
+ //
14
+
15
+ import Foundation
16
+ import StoreKit
17
+
18
+ public enum SkanCoarseValue: String {
19
+ case low = "low"
20
+ case medium = "medium"
21
+ case high = "high"
22
+ }
23
+
24
+ public enum SkanError: Error, Equatable {
25
+ case fineValueOutOfRange(Int)
26
+ }
27
+
28
+ public final class SkanManager {
29
+ private let updater: SkanUpdaterProtocol
30
+
31
+ public init(updater: SkanUpdaterProtocol = DefaultSkanUpdater()) {
32
+ self.updater = updater
33
+ }
34
+
35
+ /// Validates `fineValue` is in 0...63. Throws on out-of-range.
36
+ public static func validateFineValue(_ fineValue: Int) throws {
37
+ if fineValue < 0 || fineValue > 63 {
38
+ throw SkanError.fineValueOutOfRange(fineValue)
39
+ }
40
+ }
41
+
42
+ /// Updates SKAN conversion value. Uses SKAN 4.0 postback API when a coarse
43
+ /// value is supplied and runtime supports it; otherwise falls back to the
44
+ /// legacy SKAN 3.0 `updateConversionValue`.
45
+ public func update(
46
+ fineValue: Int,
47
+ coarseValue: SkanCoarseValue? = nil,
48
+ completion: @escaping (Error?) -> Void
49
+ ) {
50
+ do {
51
+ try SkanManager.validateFineValue(fineValue)
52
+ } catch {
53
+ completion(error)
54
+ return
55
+ }
56
+ updater.updateConversionValue(fineValue: fineValue, coarseValue: coarseValue, completion: completion)
57
+ }
58
+ }
59
+
60
+ public protocol SkanUpdaterProtocol {
61
+ func updateConversionValue(
62
+ fineValue: Int,
63
+ coarseValue: SkanCoarseValue?,
64
+ completion: @escaping (Error?) -> Void
65
+ )
66
+ }
67
+
68
+ public final class DefaultSkanUpdater: SkanUpdaterProtocol {
69
+ public init() {}
70
+
71
+ public func updateConversionValue(
72
+ fineValue: Int,
73
+ coarseValue: SkanCoarseValue?,
74
+ completion: @escaping (Error?) -> Void
75
+ ) {
76
+ if let coarse = coarseValue, #available(iOS 16.1, *) {
77
+ let apiCoarse: SKAdNetwork.CoarseConversionValue
78
+ switch coarse {
79
+ case .low: apiCoarse = .low
80
+ case .medium: apiCoarse = .medium
81
+ case .high: apiCoarse = .high
82
+ }
83
+ SKAdNetwork.updatePostbackConversionValue(fineValue, coarseValue: apiCoarse) { error in
84
+ completion(error)
85
+ }
86
+ } else if #available(iOS 15.4, *) {
87
+ SKAdNetwork.updatePostbackConversionValue(fineValue) { error in
88
+ completion(error)
89
+ }
90
+ } else {
91
+ SKAdNetwork.updateConversionValue(fineValue)
92
+ completion(nil)
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,269 @@
1
+ //
2
+ // ApexCapacitorPlugin.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // Main plugin class registered with Capacitor. Thin orchestration layer —
6
+ // actual logic lives in the companion Manager/Provider classes so the
7
+ // pure-Swift pieces remain testable without the Capacitor SDK loaded.
8
+ //
9
+ // Methods must be @objc-annotated for Capacitor's ObjC bridge to expose
10
+ // them to JS. CAPPluginCall is Capacitor's request/response wrapper.
11
+ //
12
+
13
+ import Foundation
14
+ import Capacitor
15
+
16
+ @objc(ApexCapacitorPlugin)
17
+ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
18
+
19
+ // CAPBridgedPlugin metadata for Capacitor 6+ runtime registration.
20
+ public let identifier = "ApexCapacitorPlugin"
21
+ public let jsName = "ApexCapacitorPlugin"
22
+ public let pluginMethods: [CAPPluginMethod] = [
23
+ CAPPluginMethod(name: "initialize", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "requestTrackingAuthorization", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "getTrackingStatus", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "getAdvertisingId", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "getInstallReferrer", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "getVisitorId", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "setVisitorId", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "updateConversionValue", returnType: CAPPluginReturnPromise),
31
+ CAPPluginMethod(name: "getInitialDeepLink", returnType: CAPPluginReturnPromise),
32
+ CAPPluginMethod(name: "getDeviceInfo", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "startSession", returnType: CAPPluginReturnPromise),
34
+ CAPPluginMethod(name: "endSession", returnType: CAPPluginReturnPromise),
35
+ CAPPluginMethod(name: "getCurrentSession", returnType: CAPPluginReturnPromise),
36
+ CAPPluginMethod(name: "track", returnType: CAPPluginReturnPromise),
37
+ CAPPluginMethod(name: "getQueueSize", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "flushQueue", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "setTestMode", returnType: CAPPluginReturnPromise),
40
+ ]
41
+
42
+ // MARK: - Dependencies (lazy; constructed after initialize())
43
+
44
+ private var attManager = AttManager()
45
+ private var skanManager = SkanManager()
46
+ private var deviceInfo = DeviceInfo()
47
+ private var deepLinks = DeepLinkManager()
48
+ private var adIds = AdvertisingIdProvider()
49
+ private lazy var sessionManager: NativeSessionManager = {
50
+ return NativeSessionManager(
51
+ onStart: { [weak self] snapshot in
52
+ self?.notifyListeners("sessionStart", data: ["sessionId": snapshot.sessionId])
53
+ },
54
+ onEnd: { [weak self] snapshot in
55
+ self?.notifyListeners("sessionEnd", data: [
56
+ "sessionId": snapshot.sessionId,
57
+ "durationSeconds": snapshot.durationSeconds ?? 0
58
+ ])
59
+ }
60
+ )
61
+ }()
62
+
63
+ private var offlineQueue: NativeOfflineQueue?
64
+ private var visitorId: String = ""
65
+ private var testMode = false
66
+ private var debug = false
67
+
68
+ public override func load() {
69
+ let defaults = UserDefaults.standard
70
+ visitorId = defaults.string(forKey: "apex.visitorId") ?? UUID().uuidString.lowercased()
71
+ defaults.set(visitorId, forKey: "apex.visitorId")
72
+
73
+ deepLinks.setWarmLinkHandler { [weak self] url in
74
+ self?.notifyListeners("deepLink", data: ["url": url.absoluteString])
75
+ }
76
+ }
77
+
78
+ // MARK: - Lifecycle
79
+
80
+ @objc public func initialize(_ call: CAPPluginCall) {
81
+ let projectKey = call.getString("projectKey") ?? ""
82
+ if projectKey.isEmpty {
83
+ call.reject("projectKey is required")
84
+ return
85
+ }
86
+ testMode = call.getBool("testMode") ?? false
87
+ debug = call.getBool("debug") ?? false
88
+ let queueDir = FileManager.default
89
+ .urls(for: .applicationSupportDirectory, in: .userDomainMask)
90
+ .first!
91
+ .appendingPathComponent("apex-capacitor")
92
+ let queueFile = queueDir.appendingPathComponent("events.json")
93
+ offlineQueue = NativeOfflineQueue(
94
+ fileURL: queueFile,
95
+ maxSize: call.getInt("offlineQueueMaxSize") ?? 1000
96
+ )
97
+ call.resolve()
98
+ }
99
+
100
+ // MARK: - ATT
101
+
102
+ @objc public func requestTrackingAuthorization(_ call: CAPPluginCall) {
103
+ attManager.request { status in
104
+ call.resolve(["status": status.rawValue])
105
+ }
106
+ }
107
+
108
+ @objc public func getTrackingStatus(_ call: CAPPluginCall) {
109
+ call.resolve(["status": attManager.currentStatus().rawValue])
110
+ }
111
+
112
+ // MARK: - Identifiers
113
+
114
+ @objc public func getAdvertisingId(_ call: CAPPluginCall) {
115
+ let result = adIds.current()
116
+ call.resolve([
117
+ "id": result.id as Any,
118
+ "fallback": result.fallback as Any
119
+ ])
120
+ }
121
+
122
+ @objc public func getInstallReferrer(_ call: CAPPluginCall) {
123
+ // iOS has no equivalent of Play Install Referrer.
124
+ call.resolve(["referrer": NSNull()])
125
+ }
126
+
127
+ @objc public func getVisitorId(_ call: CAPPluginCall) {
128
+ call.resolve(["visitorId": visitorId])
129
+ }
130
+
131
+ @objc public func setVisitorId(_ call: CAPPluginCall) {
132
+ guard let id = call.getString("visitorId"), !id.isEmpty else {
133
+ call.reject("visitorId is required")
134
+ return
135
+ }
136
+ visitorId = id
137
+ UserDefaults.standard.set(id, forKey: "apex.visitorId")
138
+ call.resolve()
139
+ }
140
+
141
+ // MARK: - SKAN
142
+
143
+ @objc public func updateConversionValue(_ call: CAPPluginCall) {
144
+ let fineValue = call.getInt("fineValue") ?? 0
145
+ var coarse: SkanCoarseValue? = nil
146
+ if let raw = call.getString("coarseValue") {
147
+ coarse = SkanCoarseValue(rawValue: raw)
148
+ }
149
+ skanManager.update(fineValue: fineValue, coarseValue: coarse) { error in
150
+ if let error = error {
151
+ call.reject("SKAN update failed: \(error.localizedDescription)")
152
+ } else {
153
+ call.resolve()
154
+ }
155
+ }
156
+ }
157
+
158
+ // MARK: - Deep Linking
159
+
160
+ @objc public func getInitialDeepLink(_ call: CAPPluginCall) {
161
+ if let url = deepLinks.consumeInitialUrl() {
162
+ call.resolve(["url": url.absoluteString])
163
+ } else {
164
+ call.resolve(["url": NSNull()])
165
+ }
166
+ }
167
+
168
+ // MARK: - Device Info
169
+
170
+ @objc public func getDeviceInfo(_ call: CAPPluginCall) {
171
+ let meta = deviceInfo.collect()
172
+ call.resolve([
173
+ "platform": meta.platform,
174
+ "osVersion": meta.osVersion,
175
+ "model": meta.model,
176
+ "appVersion": meta.appVersion,
177
+ "bundleId": meta.bundleId,
178
+ "timezone": meta.timezone,
179
+ "locale": meta.locale
180
+ ])
181
+ }
182
+
183
+ // MARK: - Sessions
184
+
185
+ @objc public func startSession(_ call: CAPPluginCall) {
186
+ let snapshot = sessionManager.forceStart()
187
+ call.resolve(["sessionId": snapshot.sessionId])
188
+ }
189
+
190
+ @objc public func endSession(_ call: CAPPluginCall) {
191
+ sessionManager.endSession()
192
+ call.resolve()
193
+ }
194
+
195
+ @objc public func getCurrentSession(_ call: CAPPluginCall) {
196
+ let current = sessionManager.getCurrent()
197
+ call.resolve([
198
+ "sessionId": current?.sessionId ?? NSNull(),
199
+ "startedAt": current?.startedAt.iso8601String ?? NSNull()
200
+ ])
201
+ }
202
+
203
+ // MARK: - Events & Queue
204
+
205
+ @objc public func track(_ call: CAPPluginCall) {
206
+ guard let queue = offlineQueue else {
207
+ call.reject("Plugin not initialized — call initialize() first")
208
+ return
209
+ }
210
+
211
+ let id = call.getString("id") ?? UUID().uuidString.lowercased()
212
+ let payloadDict = call.options.compactMapValues { $0 }
213
+ var codablePayload: [String: AnyCodable] = [:]
214
+ for (k, v) in payloadDict {
215
+ codablePayload[k] = AnyCodable(v)
216
+ }
217
+ let event = NativeQueuedEvent(
218
+ id: id,
219
+ payload: codablePayload,
220
+ attempts: 0,
221
+ enqueuedAt: Date()
222
+ )
223
+ do {
224
+ try queue.enqueue(event)
225
+ call.resolve()
226
+ } catch {
227
+ call.reject("Failed to enqueue event: \(error.localizedDescription)")
228
+ }
229
+ }
230
+
231
+ @objc public func getQueueSize(_ call: CAPPluginCall) {
232
+ guard let queue = offlineQueue else {
233
+ call.resolve(["count": 0, "oldestEventAt": NSNull()])
234
+ return
235
+ }
236
+ do {
237
+ let count = try queue.size()
238
+ let oldest = try queue.oldestEventAt()?.iso8601String
239
+ call.resolve([
240
+ "count": count,
241
+ "oldestEventAt": oldest ?? NSNull()
242
+ ])
243
+ } catch {
244
+ call.reject("Failed to read queue: \(error.localizedDescription)")
245
+ }
246
+ }
247
+
248
+ @objc public func flushQueue(_ call: CAPPluginCall) {
249
+ // Flush is handled by the JS side through the same offline queue
250
+ // protocol. Native queue is drained by the batch sender which runs
251
+ // in JS. Here we just report no-op success.
252
+ call.resolve(["flushed": 0, "remaining": (try? offlineQueue?.size()) ?? 0])
253
+ }
254
+
255
+ @objc public func setTestMode(_ call: CAPPluginCall) {
256
+ testMode = call.getBool("enabled") ?? false
257
+ call.resolve()
258
+ }
259
+ }
260
+
261
+ // MARK: - Date ISO 8601 helper
262
+
263
+ private extension Date {
264
+ var iso8601String: String {
265
+ let formatter = ISO8601DateFormatter()
266
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
267
+ return formatter.string(from: self)
268
+ }
269
+ }