@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,123 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses Play Install Referrer strings into a map of click-ID claims.
|
|
5
|
+
*
|
|
6
|
+
* Android's Play Install Referrer API returns a URL-encoded key=value&... string
|
|
7
|
+
* that carries the ad-network click IDs used for attribution. We extract the
|
|
8
|
+
* fields the server expects (gclid, fbclid, ttclid, msclkid, li_fat_id, wbraid,
|
|
9
|
+
* gbraid, and utm_* parameters) and surface them as a typed Kotlin map.
|
|
10
|
+
*
|
|
11
|
+
* This is the single most important piece of Android attribution — without it,
|
|
12
|
+
* all paid installs appear as organic and ROAS is meaningless.
|
|
13
|
+
*
|
|
14
|
+
* Pure-logic Kotlin: no Android runtime imports so it runs under plain JUnit.
|
|
15
|
+
*/
|
|
16
|
+
object InstallReferrerParser {
|
|
17
|
+
|
|
18
|
+
/** Known click-ID keys we always surface at the top level of the result. */
|
|
19
|
+
private val KNOWN_CLICK_IDS = setOf(
|
|
20
|
+
"gclid",
|
|
21
|
+
"fbclid",
|
|
22
|
+
"ttclid", // TikTok
|
|
23
|
+
"msclkid", // Microsoft / Bing Ads
|
|
24
|
+
"li_fat_id", // LinkedIn
|
|
25
|
+
"wbraid", // Google App Campaigns web-to-app
|
|
26
|
+
"gbraid", // Google App Campaigns app-to-app
|
|
27
|
+
"epik", // Pinterest
|
|
28
|
+
"rdt_cid", // Reddit
|
|
29
|
+
"twclid" // Twitter / X
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
private val KNOWN_UTM_KEYS = setOf(
|
|
33
|
+
"utm_source",
|
|
34
|
+
"utm_medium",
|
|
35
|
+
"utm_campaign",
|
|
36
|
+
"utm_term",
|
|
37
|
+
"utm_content"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/** Parsed referrer claims — populated fields + any extras the ad network sent. */
|
|
41
|
+
data class Claims(
|
|
42
|
+
val raw: String,
|
|
43
|
+
val clickIds: Map<String, String>,
|
|
44
|
+
val utm: Map<String, String>,
|
|
45
|
+
val other: Map<String, String>
|
|
46
|
+
) {
|
|
47
|
+
/** Convenience for the most common access pattern. */
|
|
48
|
+
fun hasAnyClickId(): Boolean = clickIds.isNotEmpty()
|
|
49
|
+
fun hasAnyAttribution(): Boolean = clickIds.isNotEmpty() || utm.isNotEmpty()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a raw referrer string into structured claims. An empty or null
|
|
54
|
+
* input returns an empty Claims object — the install is treated as organic.
|
|
55
|
+
*/
|
|
56
|
+
fun parse(raw: String?): Claims {
|
|
57
|
+
val safeRaw = raw?.trim().orEmpty()
|
|
58
|
+
if (safeRaw.isEmpty()) {
|
|
59
|
+
return Claims(raw = "", clickIds = emptyMap(), utm = emptyMap(), other = emptyMap())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
val clicks = mutableMapOf<String, String>()
|
|
63
|
+
val utm = mutableMapOf<String, String>()
|
|
64
|
+
val other = mutableMapOf<String, String>()
|
|
65
|
+
|
|
66
|
+
for (pair in splitPairs(safeRaw)) {
|
|
67
|
+
val key = pair.first.lowercase()
|
|
68
|
+
val value = pair.second
|
|
69
|
+
if (value.isEmpty()) continue
|
|
70
|
+
|
|
71
|
+
when {
|
|
72
|
+
KNOWN_CLICK_IDS.contains(key) -> clicks[key] = value
|
|
73
|
+
KNOWN_UTM_KEYS.contains(key) -> utm[key] = value
|
|
74
|
+
else -> other[key] = value
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Claims(raw = safeRaw, clickIds = clicks, utm = utm, other = other)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun splitPairs(raw: String): List<Pair<String, String>> {
|
|
82
|
+
val decoded = urlDecode(raw)
|
|
83
|
+
return decoded.split("&")
|
|
84
|
+
.mapNotNull { segment ->
|
|
85
|
+
val idx = segment.indexOf('=')
|
|
86
|
+
if (idx <= 0) return@mapNotNull null
|
|
87
|
+
val k = segment.substring(0, idx)
|
|
88
|
+
val v = segment.substring(idx + 1)
|
|
89
|
+
if (k.isBlank()) return@mapNotNull null
|
|
90
|
+
urlDecode(k) to urlDecode(v)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decode percent-encoded strings and `+` as space. We implement this
|
|
96
|
+
* manually rather than using `URLDecoder` to keep the class dependency-free
|
|
97
|
+
* for JUnit tests.
|
|
98
|
+
*/
|
|
99
|
+
internal fun urlDecode(value: String): String {
|
|
100
|
+
if (value.isEmpty()) return value
|
|
101
|
+
val sb = StringBuilder(value.length)
|
|
102
|
+
var i = 0
|
|
103
|
+
while (i < value.length) {
|
|
104
|
+
val c = value[i]
|
|
105
|
+
when {
|
|
106
|
+
c == '+' -> sb.append(' ')
|
|
107
|
+
c == '%' && i + 2 < value.length -> {
|
|
108
|
+
val hex = value.substring(i + 1, i + 3)
|
|
109
|
+
val byte = hex.toIntOrNull(16)
|
|
110
|
+
if (byte != null) {
|
|
111
|
+
sb.append(byte.toChar())
|
|
112
|
+
i += 2
|
|
113
|
+
} else {
|
|
114
|
+
sb.append(c)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else -> sb.append(c)
|
|
118
|
+
}
|
|
119
|
+
i++
|
|
120
|
+
}
|
|
121
|
+
return sb.toString()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Durable event queue backed by a plain JSON file inside the app's internal
|
|
8
|
+
* storage directory. File-based storage is pragmatic for our event scale
|
|
9
|
+
* (a few hundred to low thousands of events max) — Room's migration machinery
|
|
10
|
+
* is overkill.
|
|
11
|
+
*
|
|
12
|
+
* Thread-safe via an intrinsic lock. The JS-side queue is the source of truth;
|
|
13
|
+
* this native queue exists so events survive the app being killed while the
|
|
14
|
+
* JS runtime is frozen.
|
|
15
|
+
*
|
|
16
|
+
* Pure-Kotlin-plus-org.json — runs under JUnit with a FileStorage abstraction
|
|
17
|
+
* that tests can swap for in-memory without any Android runtime.
|
|
18
|
+
*/
|
|
19
|
+
data class NativeQueuedEvent(
|
|
20
|
+
val id: String,
|
|
21
|
+
val payload: Map<String, Any?>,
|
|
22
|
+
val attempts: Int,
|
|
23
|
+
val enqueuedAtMs: Long,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
interface QueueStorage {
|
|
27
|
+
fun readAll(): String?
|
|
28
|
+
fun writeAll(contents: String)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class NativeOfflineQueue(
|
|
32
|
+
private val storage: QueueStorage,
|
|
33
|
+
private val maxSize: Int = 1000,
|
|
34
|
+
) {
|
|
35
|
+
init {
|
|
36
|
+
require(maxSize >= 1) { "maxSize must be >= 1, got $maxSize" }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private val lock = Any()
|
|
40
|
+
|
|
41
|
+
fun enqueue(event: NativeQueuedEvent) {
|
|
42
|
+
require(event.id.isNotEmpty()) { "event.id is required" }
|
|
43
|
+
synchronized(lock) {
|
|
44
|
+
val events = readLocked().toMutableList()
|
|
45
|
+
events.add(event)
|
|
46
|
+
if (events.size > maxSize) {
|
|
47
|
+
val overflow = events.size - maxSize
|
|
48
|
+
events.subList(0, overflow).clear()
|
|
49
|
+
}
|
|
50
|
+
writeLocked(events)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fun peek(batchSize: Int): List<NativeQueuedEvent> {
|
|
55
|
+
if (batchSize < 1) return emptyList()
|
|
56
|
+
return synchronized(lock) {
|
|
57
|
+
readLocked().take(batchSize)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fun markSent(ids: List<String>) {
|
|
62
|
+
if (ids.isEmpty()) return
|
|
63
|
+
synchronized(lock) {
|
|
64
|
+
val idSet = ids.toSet()
|
|
65
|
+
val kept = readLocked().filterNot { idSet.contains(it.id) }
|
|
66
|
+
writeLocked(kept)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun markFailed(ids: List<String>) {
|
|
71
|
+
if (ids.isEmpty()) return
|
|
72
|
+
synchronized(lock) {
|
|
73
|
+
val idSet = ids.toSet()
|
|
74
|
+
val next = readLocked().map {
|
|
75
|
+
if (idSet.contains(it.id)) it.copy(attempts = it.attempts + 1) else it
|
|
76
|
+
}
|
|
77
|
+
writeLocked(next)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun size(): Int = synchronized(lock) { readLocked().size }
|
|
82
|
+
|
|
83
|
+
fun oldestEventAtMs(): Long? = synchronized(lock) {
|
|
84
|
+
readLocked().firstOrNull()?.enqueuedAtMs
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fun clear() = synchronized(lock) { writeLocked(emptyList()) }
|
|
88
|
+
|
|
89
|
+
// ── Serialization ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
private fun readLocked(): List<NativeQueuedEvent> {
|
|
92
|
+
val raw = storage.readAll() ?: return emptyList()
|
|
93
|
+
if (raw.isBlank()) return emptyList()
|
|
94
|
+
return try {
|
|
95
|
+
val array = JSONArray(raw)
|
|
96
|
+
val result = mutableListOf<NativeQueuedEvent>()
|
|
97
|
+
for (i in 0 until array.length()) {
|
|
98
|
+
val obj = array.getJSONObject(i)
|
|
99
|
+
val payloadObj = obj.optJSONObject("payload")
|
|
100
|
+
val payload = payloadObj?.let { jsonObjectToMap(it) } ?: emptyMap()
|
|
101
|
+
result.add(
|
|
102
|
+
NativeQueuedEvent(
|
|
103
|
+
id = obj.getString("id"),
|
|
104
|
+
payload = payload,
|
|
105
|
+
attempts = obj.optInt("attempts", 0),
|
|
106
|
+
enqueuedAtMs = obj.optLong("enqueuedAtMs", 0L),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
result
|
|
111
|
+
} catch (_: Exception) {
|
|
112
|
+
emptyList()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private fun writeLocked(events: List<NativeQueuedEvent>) {
|
|
117
|
+
val array = JSONArray()
|
|
118
|
+
for (ev in events) {
|
|
119
|
+
val obj = JSONObject()
|
|
120
|
+
obj.put("id", ev.id)
|
|
121
|
+
obj.put("attempts", ev.attempts)
|
|
122
|
+
obj.put("enqueuedAtMs", ev.enqueuedAtMs)
|
|
123
|
+
obj.put("payload", JSONObject(ev.payload))
|
|
124
|
+
array.put(obj)
|
|
125
|
+
}
|
|
126
|
+
storage.writeAll(array.toString())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun jsonObjectToMap(obj: JSONObject): Map<String, Any?> {
|
|
130
|
+
val result = mutableMapOf<String, Any?>()
|
|
131
|
+
val keys = obj.keys()
|
|
132
|
+
while (keys.hasNext()) {
|
|
133
|
+
val key = keys.next()
|
|
134
|
+
result[key] = obj.opt(key)
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* In-memory storage backend for tests.
|
|
142
|
+
*/
|
|
143
|
+
class InMemoryQueueStorage : QueueStorage {
|
|
144
|
+
private var contents: String? = null
|
|
145
|
+
|
|
146
|
+
override fun readAll(): String? = contents
|
|
147
|
+
override fun writeAll(contents: String) {
|
|
148
|
+
this.contents = contents
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
import java.util.UUID
|
|
4
|
+
import java.util.concurrent.TimeUnit
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Native Android session manager. Mirrors the TS and Swift session managers —
|
|
8
|
+
* a session starts on the first activity and ends after `timeoutMinutes` of
|
|
9
|
+
* inactivity (default 30). The JS bridge also runs a session manager; this
|
|
10
|
+
* native copy catches background-state transitions that JS timers can't
|
|
11
|
+
* observe after Doze / App Standby kicks in.
|
|
12
|
+
*
|
|
13
|
+
* Pure Kotlin — injectable clock + scheduler keep unit tests deterministic.
|
|
14
|
+
*/
|
|
15
|
+
data class SessionSnapshot(
|
|
16
|
+
val sessionId: String,
|
|
17
|
+
val startedAtMs: Long,
|
|
18
|
+
var lastActivityMs: Long,
|
|
19
|
+
var eventCount: Int,
|
|
20
|
+
var endedAtMs: Long? = null,
|
|
21
|
+
var durationSeconds: Long? = null,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
fun interface Scheduler {
|
|
25
|
+
/** Schedule a one-shot callback after `delayMs`, returns a cancel token. */
|
|
26
|
+
fun schedule(delayMs: Long, callback: () -> Unit): Any
|
|
27
|
+
|
|
28
|
+
companion object {
|
|
29
|
+
/** Default implementation — no-op scheduler used as a fallback in tests. */
|
|
30
|
+
val Noop: Scheduler = Scheduler { _, _ -> Any() }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun interface ScheduleCanceller {
|
|
35
|
+
fun cancel(token: Any)
|
|
36
|
+
|
|
37
|
+
companion object {
|
|
38
|
+
val Noop: ScheduleCanceller = ScheduleCanceller { _ -> }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class NativeSessionManager(
|
|
43
|
+
private val timeoutMinutes: Int = 30,
|
|
44
|
+
private val now: () -> Long = { System.currentTimeMillis() },
|
|
45
|
+
private val scheduler: Scheduler = Scheduler.Noop,
|
|
46
|
+
private val canceller: ScheduleCanceller = ScheduleCanceller.Noop,
|
|
47
|
+
private val onStart: ((SessionSnapshot) -> Unit)? = null,
|
|
48
|
+
private val onEnd: ((SessionSnapshot) -> Unit)? = null,
|
|
49
|
+
) {
|
|
50
|
+
private val timeoutMs: Long = TimeUnit.MINUTES.toMillis(timeoutMinutes.toLong())
|
|
51
|
+
private var current: SessionSnapshot? = null
|
|
52
|
+
private var cancelToken: Any? = null
|
|
53
|
+
private val lock = Any()
|
|
54
|
+
|
|
55
|
+
fun recordActivity(): SessionSnapshot {
|
|
56
|
+
synchronized(lock) {
|
|
57
|
+
val nowMs = now()
|
|
58
|
+
if (current == null) {
|
|
59
|
+
val snapshot = SessionSnapshot(
|
|
60
|
+
sessionId = UUID.randomUUID().toString().lowercase(),
|
|
61
|
+
startedAtMs = nowMs,
|
|
62
|
+
lastActivityMs = nowMs,
|
|
63
|
+
eventCount = 1,
|
|
64
|
+
)
|
|
65
|
+
current = snapshot
|
|
66
|
+
onStart?.invoke(snapshot.copy())
|
|
67
|
+
} else {
|
|
68
|
+
current!!.lastActivityMs = nowMs
|
|
69
|
+
current!!.eventCount++
|
|
70
|
+
}
|
|
71
|
+
rescheduleTimeoutLocked()
|
|
72
|
+
return current!!.copy()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fun endSession() {
|
|
77
|
+
synchronized(lock) {
|
|
78
|
+
endLocked()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun forceStart(): SessionSnapshot {
|
|
83
|
+
synchronized(lock) { endLocked() }
|
|
84
|
+
return recordActivity()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fun getCurrent(): SessionSnapshot? = synchronized(lock) { current?.copy() }
|
|
88
|
+
|
|
89
|
+
private fun rescheduleTimeoutLocked() {
|
|
90
|
+
cancelToken?.let { canceller.cancel(it) }
|
|
91
|
+
cancelToken = scheduler.schedule(timeoutMs) {
|
|
92
|
+
synchronized(lock) { endLocked() }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private fun endLocked() {
|
|
97
|
+
val snapshot = current ?: return
|
|
98
|
+
cancelToken?.let { canceller.cancel(it) }
|
|
99
|
+
cancelToken = null
|
|
100
|
+
|
|
101
|
+
val nowMs = now()
|
|
102
|
+
snapshot.endedAtMs = nowMs
|
|
103
|
+
snapshot.durationSeconds = maxOf(0L, (nowMs - snapshot.startedAtMs) / 1000L)
|
|
104
|
+
|
|
105
|
+
onEnd?.invoke(snapshot.copy())
|
|
106
|
+
current = null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch sender with exponential-backoff retries.
|
|
3
|
+
*
|
|
4
|
+
* Drains the offline queue by sending events in batches to `/api/events`.
|
|
5
|
+
* Retries on 5xx and network errors with exponential backoff (1s, 2s, 4s).
|
|
6
|
+
* Gives up after 3 attempts per batch — events remain in the queue for a
|
|
7
|
+
* future flush attempt but are marked with incremented `attempts` so old
|
|
8
|
+
* failing events don't block new ones indefinitely.
|
|
9
|
+
*
|
|
10
|
+
* The fetch function is injectable for testing and to allow future runtimes
|
|
11
|
+
* to swap in native HTTP clients (e.g. on restricted networks).
|
|
12
|
+
*/
|
|
13
|
+
import type { OfflineQueue } from "./offline-queue";
|
|
14
|
+
export interface BatchSenderOptions {
|
|
15
|
+
/** API base URL. Default `https://api.apex.inc`. */
|
|
16
|
+
apiUrl?: string;
|
|
17
|
+
/** Project key attached to each request. Required. */
|
|
18
|
+
projectKey: string;
|
|
19
|
+
/** Max events sent per batch. Default 50. */
|
|
20
|
+
batchSize?: number;
|
|
21
|
+
/** Max retry attempts before giving up on a batch. Default 3. */
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
/** Base backoff delay in milliseconds. Default 1000. */
|
|
24
|
+
baseBackoffMs?: number;
|
|
25
|
+
/** Injectable fetch (Node, test mocks, custom runtimes). */
|
|
26
|
+
fetchFn?: typeof fetch;
|
|
27
|
+
/** Injectable sleep for testing (must return a Promise that resolves). */
|
|
28
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
29
|
+
/** Platform header to include (`ios` / `android`). */
|
|
30
|
+
platformHeader?: "ios" | "android" | "web";
|
|
31
|
+
/** Enable verbose logs. */
|
|
32
|
+
debug?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface FlushResult {
|
|
35
|
+
flushed: number;
|
|
36
|
+
remaining: number;
|
|
37
|
+
attemptedBatches: number;
|
|
38
|
+
lastError?: string;
|
|
39
|
+
}
|
|
40
|
+
export declare class BatchSender {
|
|
41
|
+
private readonly apiUrl;
|
|
42
|
+
private readonly projectKey;
|
|
43
|
+
private readonly batchSize;
|
|
44
|
+
private readonly maxRetries;
|
|
45
|
+
private readonly baseBackoffMs;
|
|
46
|
+
private readonly fetchFn;
|
|
47
|
+
private readonly sleepFn;
|
|
48
|
+
private readonly platformHeader?;
|
|
49
|
+
private readonly debug;
|
|
50
|
+
constructor(options: BatchSenderOptions);
|
|
51
|
+
/**
|
|
52
|
+
* Flushes as many events from the queue as possible, one batch at a time,
|
|
53
|
+
* with exponential backoff on retryable failures. Stops early on
|
|
54
|
+
* non-retryable errors (400/401/403) and leaves remaining events queued.
|
|
55
|
+
*/
|
|
56
|
+
flush(queue: OfflineQueue): Promise<FlushResult>;
|
|
57
|
+
private sendBatchWithRetry;
|
|
58
|
+
private postBatch;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=batch-sender.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch-sender.d.ts","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAe,MAAM,iBAAiB,CAAC;AAEjE,MAAM,WAAW,kBAAkB;IACjC,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,sDAAsD;IACtD,cAAc,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,KAAK,CAAC;IAC3C,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAA4B;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAU;gBAEpB,OAAO,EAAE,kBAAkB;IAiBvC;;;;OAIG;IACG,KAAK,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;YAgCxC,kBAAkB;YAyClB,SAAS;CAoBxB"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Batch sender with exponential-backoff retries.
|
|
4
|
+
*
|
|
5
|
+
* Drains the offline queue by sending events in batches to `/api/events`.
|
|
6
|
+
* Retries on 5xx and network errors with exponential backoff (1s, 2s, 4s).
|
|
7
|
+
* Gives up after 3 attempts per batch — events remain in the queue for a
|
|
8
|
+
* future flush attempt but are marked with incremented `attempts` so old
|
|
9
|
+
* failing events don't block new ones indefinitely.
|
|
10
|
+
*
|
|
11
|
+
* The fetch function is injectable for testing and to allow future runtimes
|
|
12
|
+
* to swap in native HTTP clients (e.g. on restricted networks).
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.BatchSender = void 0;
|
|
16
|
+
class BatchSender {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.apiUrl = (options.apiUrl ?? "https://api.apex.inc").replace(/\/+$/, "");
|
|
19
|
+
this.projectKey = options.projectKey;
|
|
20
|
+
this.batchSize = options.batchSize ?? 50;
|
|
21
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
22
|
+
this.baseBackoffMs = options.baseBackoffMs ?? 1000;
|
|
23
|
+
this.fetchFn = options.fetchFn ?? globalThis.fetch?.bind(globalThis);
|
|
24
|
+
this.sleepFn =
|
|
25
|
+
options.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
26
|
+
this.platformHeader = options.platformHeader;
|
|
27
|
+
this.debug = options.debug ?? false;
|
|
28
|
+
if (!this.fetchFn) {
|
|
29
|
+
throw new Error("BatchSender: fetch is not available. Provide options.fetchFn.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Flushes as many events from the queue as possible, one batch at a time,
|
|
34
|
+
* with exponential backoff on retryable failures. Stops early on
|
|
35
|
+
* non-retryable errors (400/401/403) and leaves remaining events queued.
|
|
36
|
+
*/
|
|
37
|
+
async flush(queue) {
|
|
38
|
+
let flushed = 0;
|
|
39
|
+
let attemptedBatches = 0;
|
|
40
|
+
let lastError;
|
|
41
|
+
while (true) {
|
|
42
|
+
const batch = await queue.peek(this.batchSize);
|
|
43
|
+
if (batch.length === 0)
|
|
44
|
+
break;
|
|
45
|
+
attemptedBatches += 1;
|
|
46
|
+
const outcome = await this.sendBatchWithRetry(batch);
|
|
47
|
+
if (outcome.status === "success") {
|
|
48
|
+
const eventIds = batch.map((q) => q.event.id).filter((id) => !!id);
|
|
49
|
+
await queue.markSent(eventIds);
|
|
50
|
+
flushed += batch.length;
|
|
51
|
+
}
|
|
52
|
+
else if (outcome.status === "retryable_exhausted") {
|
|
53
|
+
const eventIds = batch.map((q) => q.event.id).filter((id) => !!id);
|
|
54
|
+
await queue.markFailed(eventIds);
|
|
55
|
+
lastError = outcome.error;
|
|
56
|
+
break; // leave for next flush cycle
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Non-retryable: leave events in place but stop flushing.
|
|
60
|
+
lastError = outcome.error;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const remaining = await queue.size();
|
|
65
|
+
return { flushed, remaining, attemptedBatches, lastError };
|
|
66
|
+
}
|
|
67
|
+
async sendBatchWithRetry(batch) {
|
|
68
|
+
let lastError = "unknown";
|
|
69
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const response = await this.postBatch(batch);
|
|
72
|
+
if (response.ok) {
|
|
73
|
+
return { status: "success" };
|
|
74
|
+
}
|
|
75
|
+
// Non-retryable client errors — leave events, surface to caller.
|
|
76
|
+
if (response.status >= 400 && response.status < 500) {
|
|
77
|
+
return { status: "non_retryable", error: `HTTP ${response.status}` };
|
|
78
|
+
}
|
|
79
|
+
// Retryable: 5xx or unknown. Fall through to backoff.
|
|
80
|
+
lastError = `HTTP ${response.status}`;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
84
|
+
}
|
|
85
|
+
if (attempt < this.maxRetries - 1) {
|
|
86
|
+
const backoff = this.baseBackoffMs * 2 ** attempt;
|
|
87
|
+
if (this.debug) {
|
|
88
|
+
console.log(`[apex-capacitor] batch-sender retry ${attempt + 1}/${this.maxRetries - 1} in ${backoff}ms (${lastError})`);
|
|
89
|
+
}
|
|
90
|
+
await this.sleepFn(backoff);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { status: "retryable_exhausted", error: lastError };
|
|
94
|
+
}
|
|
95
|
+
async postBatch(batch) {
|
|
96
|
+
const body = {
|
|
97
|
+
projectKey: this.projectKey,
|
|
98
|
+
events: batch.map((q) => q.event),
|
|
99
|
+
};
|
|
100
|
+
const headers = {
|
|
101
|
+
"Content-Type": "application/json",
|
|
102
|
+
"X-Apex-Project-Key": this.projectKey,
|
|
103
|
+
};
|
|
104
|
+
if (this.platformHeader) {
|
|
105
|
+
headers["X-Apex-Platform"] = this.platformHeader;
|
|
106
|
+
}
|
|
107
|
+
return this.fetchFn(`${this.apiUrl}/api/events`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers,
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
exports.BatchSender = BatchSender;
|
|
115
|
+
//# sourceMappingURL=batch-sender.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"batch-sender.js","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":"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"}
|