@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.
- package/ApexCapacitorPlugin.podspec +17 -0
- package/LICENSE +17 -0
- package/README.md +136 -0
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +325 -0
- package/android/src/main/java/inc/apex/capacitor/DeepLinkManager.kt +47 -0
- package/android/src/main/java/inc/apex/capacitor/InstallReferrerParser.kt +123 -0
- package/android/src/main/java/inc/apex/capacitor/OfflineQueue.kt +150 -0
- package/android/src/main/java/inc/apex/capacitor/SessionManager.kt +108 -0
- package/dist/batch-sender.d.ts +60 -0
- package/dist/batch-sender.d.ts.map +1 -0
- package/dist/batch-sender.js +115 -0
- package/dist/batch-sender.js.map +1 -0
- package/dist/definitions.d.ts +224 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +14 -0
- package/dist/definitions.js.map +1 -0
- package/dist/esm/batch-sender.d.ts +60 -0
- package/dist/esm/batch-sender.d.ts.map +1 -0
- package/dist/esm/batch-sender.js +111 -0
- package/dist/esm/batch-sender.js.map +1 -0
- package/dist/esm/definitions.d.ts +224 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +13 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/event-id.d.ts +17 -0
- package/dist/esm/event-id.d.ts.map +1 -0
- package/dist/esm/event-id.js +57 -0
- package/dist/esm/event-id.js.map +1 -0
- package/dist/esm/index.d.ts +29 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +30 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/offline-queue.d.ts +111 -0
- package/dist/esm/offline-queue.d.ts.map +1 -0
- package/dist/esm/offline-queue.js +240 -0
- package/dist/esm/offline-queue.js.map +1 -0
- package/dist/esm/session-manager.d.ts +63 -0
- package/dist/esm/session-manager.d.ts.map +1 -0
- package/dist/esm/session-manager.js +100 -0
- package/dist/esm/session-manager.js.map +1 -0
- package/dist/esm/web.d.ts +65 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +203 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/event-id.d.ts +17 -0
- package/dist/event-id.d.ts.map +1 -0
- package/dist/event-id.js +61 -0
- package/dist/event-id.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/offline-queue.d.ts +111 -0
- package/dist/offline-queue.d.ts.map +1 -0
- package/dist/offline-queue.js +246 -0
- package/dist/offline-queue.js.map +1 -0
- package/dist/session-manager.d.ts +63 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +104 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/web.d.ts +65 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +207 -0
- package/dist/web.js.map +1 -0
- package/ios/Package.swift +34 -0
- package/ios/Sources/ApexCapacitorPlugin/AdvertisingIdProvider.swift +66 -0
- package/ios/Sources/ApexCapacitorPlugin/AttManager.swift +82 -0
- package/ios/Sources/ApexCapacitorPlugin/DeepLinkManager.swift +64 -0
- package/ios/Sources/ApexCapacitorPlugin/DeviceInfo.swift +107 -0
- package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +191 -0
- package/ios/Sources/ApexCapacitorPlugin/SessionManager.swift +113 -0
- package/ios/Sources/ApexCapacitorPlugin/SkanManager.swift +95 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +269 -0
- package/ios/Tests/ApexCapacitorPluginTests/AdvertisingIdProviderTests.swift +74 -0
- package/ios/Tests/ApexCapacitorPluginTests/AttManagerTests.swift +82 -0
- package/ios/Tests/ApexCapacitorPluginTests/DeepLinkManagerTests.swift +69 -0
- package/ios/Tests/ApexCapacitorPluginTests/DeviceInfoTests.swift +52 -0
- package/ios/Tests/ApexCapacitorPluginTests/OfflineQueueTests.swift +134 -0
- package/ios/Tests/ApexCapacitorPluginTests/SessionManagerTests.swift +98 -0
- package/ios/Tests/ApexCapacitorPluginTests/SkanManagerTests.swift +91 -0
- 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
|
+
}
|