@advenue/react-native 1.0.0 → 1.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/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -1
- package/android/src/main/kotlin/io/advenue/Advenue.kt +76 -11
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +36 -1
- package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +36 -3
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +96 -4
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +3 -0
- package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +14 -0
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +5 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
- package/dist/index.cjs +16 -4
- package/dist/index.d.cts +18 -4
- package/dist/index.d.ts +18 -4
- package/dist/index.js +16 -4
- package/ios/AdvenueIosModule.swift +4 -2
- package/ios/vendor/Advenue/Advenue.swift +88 -17
- package/ios/vendor/Advenue/AdvenueConfig.swift +22 -2
- package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
- package/ios/vendor/Advenue/DebugLog.swift +46 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
- package/ios/vendor/AdvenueCore/Engine.swift +83 -9
- package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
- package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +31 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +20 -4
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
- package/package.json +1 -1
- package/src/index.ts +20 -3
- package/src/native-types.ts +0 -1
- package/src/types.ts +18 -3
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package io.advenue.core
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* B1: track properties PII scrubbing (varsayılan-açık) — Swift `PIIScrub`
|
|
5
|
+
* ile aynı sözleşme. Anahtar-adı tabanlı + değer-örüntü tabanlı eşleşen
|
|
6
|
+
* değerler gönderilmeden önce kaldırılır; ham değer ağa çıkmaz.
|
|
7
|
+
*/
|
|
8
|
+
internal object PiiScrub {
|
|
9
|
+
private val blockedKeys: Set<String> = setOf(
|
|
10
|
+
"email", "emailaddress", "emailadres", "eposta", "mail",
|
|
11
|
+
"phone", "phonenumber", "mobile", "mobilenumber", "mobil",
|
|
12
|
+
"telefon", "telefonno", "tel", "telno", "gsm",
|
|
13
|
+
"firstname", "lastname", "ad", "soyad", "adsoyad", "adisoyadi",
|
|
14
|
+
"fullname", "namesurname", "isim", "isimsoyisim",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
private val blockedSubstrings: List<String> = listOf(
|
|
18
|
+
"email", "phone", "telefon", "soyad", "adsoyad", "firstname", "lastname",
|
|
19
|
+
"fullname",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
private val emailRegex =
|
|
23
|
+
Regex("[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}", RegexOption.IGNORE_CASE)
|
|
24
|
+
|
|
25
|
+
private val phoneChars: Set<Char> = "+0123456789 \t.-()".toSet()
|
|
26
|
+
|
|
27
|
+
internal fun normalizeKey(key: String): String =
|
|
28
|
+
key.lowercase().filter { it.isLetterOrDigit() }
|
|
29
|
+
|
|
30
|
+
internal fun isSensitiveKey(key: String): Boolean {
|
|
31
|
+
val normalized = normalizeKey(key)
|
|
32
|
+
if (blockedKeys.contains(normalized)) return true
|
|
33
|
+
return blockedSubstrings.any { normalized.contains(it) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Telefon sezgiseli (Swift ile aynı): çapasız alt-dize araması YOK.
|
|
38
|
+
* Değerin TÜMÜ telefon karakterlerinden oluşmalı ve rakam sayısı
|
|
39
|
+
* E.164 aralığında (10–15) olmalı.
|
|
40
|
+
*/
|
|
41
|
+
internal fun looksLikePhone(value: String): Boolean {
|
|
42
|
+
val trimmed = value.trim()
|
|
43
|
+
if (trimmed.isEmpty()) return false
|
|
44
|
+
if (!trimmed.all { phoneChars.contains(it) }) return false
|
|
45
|
+
val first = trimmed.first()
|
|
46
|
+
val last = trimmed.last()
|
|
47
|
+
if (!(first == '+' || first.isDigit()) || !last.isDigit()) return false
|
|
48
|
+
return trimmed.count { it.isDigit() } in 10..15
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
internal fun isSensitiveValue(value: String): Boolean =
|
|
52
|
+
emailRegex.containsMatchIn(value) || looksLikePhone(value)
|
|
53
|
+
|
|
54
|
+
private fun scrubValue(value: Any?): Any? =
|
|
55
|
+
when (value) {
|
|
56
|
+
is String -> if (isSensitiveValue(value)) null else value
|
|
57
|
+
is Map<*, *> -> {
|
|
58
|
+
@Suppress("UNCHECKED_CAST")
|
|
59
|
+
val cleaned = scrub(value as Map<String, Any?>) ?: return null
|
|
60
|
+
cleaned
|
|
61
|
+
}
|
|
62
|
+
is List<*> -> value.mapNotNull { scrubValue(it) }
|
|
63
|
+
else -> value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** PII taşıyan girdileri kaldırır. Girdi null ise null döner. */
|
|
67
|
+
internal fun scrub(properties: Map<String, Any?>?): Map<String, Any?>? {
|
|
68
|
+
if (properties == null) return null
|
|
69
|
+
val kept = LinkedHashMap<String, Any?>(properties.size)
|
|
70
|
+
for ((key, value) in properties) {
|
|
71
|
+
if (isSensitiveKey(key)) continue
|
|
72
|
+
val cleaned = scrubValue(value) ?: continue
|
|
73
|
+
kept[key] = cleaned
|
|
74
|
+
}
|
|
75
|
+
return kept
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -5,6 +5,12 @@ internal const val SESSION_STATE_KEY: String = "advenue.session"
|
|
|
5
5
|
/** 30 minutes. */
|
|
6
6
|
internal const val DEFAULT_SESSION_WINDOW_MS: Long = 1_800_000L
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The heartbeat persists at most this often: it runs on every recorded event,
|
|
10
|
+
* and a chatty app must not turn that into a storage write per event.
|
|
11
|
+
*/
|
|
12
|
+
internal const val SESSION_HEARTBEAT_MIN_INTERVAL_MS: Long = 1_000L
|
|
13
|
+
|
|
8
14
|
/** A session lifecycle event, wrapped by the engine as a `type: "session"` event. */
|
|
9
15
|
internal data class SessionEvent(val name: String, val properties: Map<String, Any?>)
|
|
10
16
|
|
|
@@ -16,6 +22,12 @@ private data class SessionState(
|
|
|
16
22
|
val activeStart: Long?,
|
|
17
23
|
val firstForegroundAt: Long,
|
|
18
24
|
val timeSpentMs: Long,
|
|
25
|
+
/**
|
|
26
|
+
* The last moment the open sub-session was known to be alive (F-SDK-7).
|
|
27
|
+
* Absent in state written before it existed, which reads as "alive at
|
|
28
|
+
* activeStart".
|
|
29
|
+
*/
|
|
30
|
+
val lastActiveAt: Long? = null,
|
|
19
31
|
) {
|
|
20
32
|
/**
|
|
21
33
|
* The persisted blob. Field names are frozen: a rename would make RN-written
|
|
@@ -30,6 +42,7 @@ private data class SessionState(
|
|
|
30
42
|
"activeStart" to activeStart,
|
|
31
43
|
"firstForegroundAt" to firstForegroundAt,
|
|
32
44
|
"timeSpentMs" to timeSpentMs,
|
|
45
|
+
"lastActiveAt" to lastActiveAt,
|
|
33
46
|
)
|
|
34
47
|
}
|
|
35
48
|
|
|
@@ -46,21 +59,88 @@ internal class SessionTracker(
|
|
|
46
59
|
) {
|
|
47
60
|
private var lastState: SessionState? = null
|
|
48
61
|
|
|
62
|
+
/**
|
|
63
|
+
* True once THIS process opened the current sub-session (X-SDK-1). Never
|
|
64
|
+
* persisted: a relaunched process starts false, so its tracks and flushes
|
|
65
|
+
* before [handleForeground] cannot keep the dead process's sub-session alive
|
|
66
|
+
* and move its kill time to the relaunch.
|
|
67
|
+
*/
|
|
68
|
+
private var ownsOpenSubSession = false
|
|
69
|
+
|
|
49
70
|
/** Drops the cached state (erasure) so a wiped store cannot be resurrected. */
|
|
50
71
|
public fun reset() {
|
|
51
72
|
lastState = null
|
|
73
|
+
ownsOpenSubSession = false
|
|
52
74
|
}
|
|
53
75
|
|
|
54
76
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
77
|
+
* Refreshes the open sub-session's last known activity. Called by the engine
|
|
78
|
+
* on every recorded event and every flush — the auto-flush timer is the
|
|
79
|
+
* foreground tick. It is what a relaunch after an OS kill measures the gap
|
|
80
|
+
* from, the role Adjust's `lastActivity` plays.
|
|
81
|
+
*
|
|
82
|
+
* A no-op until this process has run [handleForeground]: only the process
|
|
83
|
+
* that opened the sub-session may extend it. Otherwise an event or flush in a
|
|
84
|
+
* relaunched process (a background wake from a broadcast, push or WorkManager
|
|
85
|
+
* before the activity resumes) would stamp the killed sub-session alive "now"
|
|
86
|
+
* and count the dead time as active (X-SDK-1).
|
|
87
|
+
*/
|
|
88
|
+
public fun heartbeat() {
|
|
89
|
+
if (!ownsOpenSubSession) return
|
|
90
|
+
val state = load() ?: return
|
|
91
|
+
val activeStart = state.activeStart ?: return
|
|
92
|
+
val now = clock.nowMs()
|
|
93
|
+
if (now - (state.lastActiveAt ?: activeStart) < SESSION_HEARTBEAT_MIN_INTERVAL_MS) return
|
|
94
|
+
save(state.copy(lastActiveAt = now))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Foreground transition, including cold start. Returns the events to emit, in
|
|
99
|
+
* order:
|
|
100
|
+
* - a synthetic `session_end` first when a sub-session was still open — the
|
|
101
|
+
* OS killed the app before it could background. It is closed at its last
|
|
102
|
+
* heartbeat, not at the relaunch: the time the app was dead is not active
|
|
103
|
+
* time. A consumer must never see two sessions open, so it comes first.
|
|
104
|
+
* - then, measured from the background (or that kill time): nothing for a
|
|
105
|
+
* sub-session inside the window, or a `session_start` for a new session.
|
|
106
|
+
*
|
|
107
|
+
* A kill is not a session boundary by itself (F-SDK-7). Adjust's rule — a new
|
|
108
|
+
* session only after the session interval of inactivity — applies to a
|
|
109
|
+
* relaunch exactly as it does to a return from the background.
|
|
60
110
|
*/
|
|
61
111
|
public fun handleForeground(): List<SessionEvent> {
|
|
62
|
-
val state = load()
|
|
63
112
|
val now = clock.nowMs()
|
|
113
|
+
val state = load()
|
|
114
|
+
val activeStart = state?.activeStart ?: return resume(state, now)
|
|
115
|
+
|
|
116
|
+
val killTime = minOf(now, maxOf(activeStart, state.lastActiveAt ?: activeStart))
|
|
117
|
+
val activeMs = Math.max(0L, killTime - activeStart)
|
|
118
|
+
val timeSpentMs = state.timeSpentMs + activeMs
|
|
119
|
+
val sessionEnd =
|
|
120
|
+
SessionEvent(
|
|
121
|
+
"session_end",
|
|
122
|
+
mapOf(
|
|
123
|
+
"sessionId" to state.sessionId,
|
|
124
|
+
"sessionNumber" to state.sessionNumber,
|
|
125
|
+
"subSession" to state.subSessionCount,
|
|
126
|
+
"activeMs" to activeMs,
|
|
127
|
+
"timeSpentMs" to timeSpentMs,
|
|
128
|
+
"sessionLengthMs" to Math.max(0L, killTime - state.firstForegroundAt),
|
|
129
|
+
"synthetic" to true,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
val closed =
|
|
133
|
+
state.copy(
|
|
134
|
+
lastBackgroundAt = killTime,
|
|
135
|
+
activeStart = null,
|
|
136
|
+
lastActiveAt = null,
|
|
137
|
+
timeSpentMs = timeSpentMs,
|
|
138
|
+
)
|
|
139
|
+
return listOf(sessionEnd) + resume(closed, now)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The foreground decision once no sub-session is open. */
|
|
143
|
+
private fun resume(state: SessionState?, now: Long): List<SessionEvent> {
|
|
64
144
|
val gap = if (state?.lastBackgroundAt != null) now - state.lastBackgroundAt else null
|
|
65
145
|
|
|
66
146
|
if (state == null || gap == null || gap >= windowMs) {
|
|
@@ -77,8 +157,8 @@ internal class SessionTracker(
|
|
|
77
157
|
timeSpentMs = 0L,
|
|
78
158
|
),
|
|
79
159
|
)
|
|
80
|
-
|
|
81
|
-
|
|
160
|
+
ownsOpenSubSession = true
|
|
161
|
+
return listOf(
|
|
82
162
|
SessionEvent(
|
|
83
163
|
"session_start",
|
|
84
164
|
mapOf(
|
|
@@ -89,32 +169,18 @@ internal class SessionTracker(
|
|
|
89
169
|
// 0 rather than infinity, which would not survive JSON.
|
|
90
170
|
"timeSinceLastSessionMs" to (gap ?: 0L),
|
|
91
171
|
),
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
if (state?.activeStart != null) {
|
|
95
|
-
val killTime = state.lastBackgroundAt ?: now
|
|
96
|
-
val activeMs = Math.max(0L, killTime - state.activeStart)
|
|
97
|
-
val timeSpentMs = state.timeSpentMs + activeMs
|
|
98
|
-
val sessionEnd =
|
|
99
|
-
SessionEvent(
|
|
100
|
-
"session_end",
|
|
101
|
-
mapOf(
|
|
102
|
-
"sessionId" to state.sessionId,
|
|
103
|
-
"sessionNumber" to state.sessionNumber,
|
|
104
|
-
"subSession" to state.subSessionCount,
|
|
105
|
-
"activeMs" to activeMs,
|
|
106
|
-
"timeSpentMs" to timeSpentMs,
|
|
107
|
-
"sessionLengthMs" to Math.max(0L, killTime - state.firstForegroundAt),
|
|
108
|
-
"synthetic" to true,
|
|
109
|
-
),
|
|
110
|
-
)
|
|
111
|
-
return listOf(sessionEnd, sessionStart)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return listOf(sessionStart)
|
|
172
|
+
),
|
|
173
|
+
)
|
|
115
174
|
}
|
|
116
175
|
|
|
117
|
-
save(
|
|
176
|
+
save(
|
|
177
|
+
state.copy(
|
|
178
|
+
subSessionCount = state.subSessionCount + 1L,
|
|
179
|
+
activeStart = now,
|
|
180
|
+
lastActiveAt = null,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
ownsOpenSubSession = true
|
|
118
184
|
return emptyList()
|
|
119
185
|
}
|
|
120
186
|
|
|
@@ -125,7 +191,15 @@ internal class SessionTracker(
|
|
|
125
191
|
val now = clock.nowMs()
|
|
126
192
|
val activeMs = Math.max(0L, now - activeStart)
|
|
127
193
|
val timeSpentMs = state.timeSpentMs + activeMs
|
|
128
|
-
save(
|
|
194
|
+
save(
|
|
195
|
+
state.copy(
|
|
196
|
+
lastBackgroundAt = now,
|
|
197
|
+
activeStart = null,
|
|
198
|
+
lastActiveAt = null,
|
|
199
|
+
timeSpentMs = timeSpentMs,
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
ownsOpenSubSession = false
|
|
129
203
|
return SessionEvent(
|
|
130
204
|
"session_end",
|
|
131
205
|
mapOf(
|
|
@@ -157,6 +231,7 @@ internal class SessionTracker(
|
|
|
157
231
|
// it, an app updating from an older SDK would fail to load its own session
|
|
158
232
|
// and restart its numbering.
|
|
159
233
|
val timeSpentMs = (map["timeSpentMs"] as? Number)?.toLong() ?: 0L
|
|
234
|
+
val lastActiveAt = (map["lastActiveAt"] as? Number)?.toLong()
|
|
160
235
|
val firstForegroundAt =
|
|
161
236
|
(map["firstForegroundAt"] as? Number)?.toLong()
|
|
162
237
|
?: activeStart
|
|
@@ -172,6 +247,7 @@ internal class SessionTracker(
|
|
|
172
247
|
activeStart = activeStart,
|
|
173
248
|
firstForegroundAt = firstForegroundAt,
|
|
174
249
|
timeSpentMs = timeSpentMs,
|
|
250
|
+
lastActiveAt = lastActiveAt,
|
|
175
251
|
)
|
|
176
252
|
lastState = state
|
|
177
253
|
return state
|
|
@@ -103,6 +103,20 @@ internal fun tcfFrom(gdprApplies: Int?, purposeConsents: String?): Map<String, A
|
|
|
103
103
|
* source is unavailable — a misleading zero is worse than a missing value,
|
|
104
104
|
* because downstream it is indistinguishable from a real measurement.
|
|
105
105
|
*/
|
|
106
|
+
/**
|
|
107
|
+
* The app's own version (`versionName`) for every event's top-level
|
|
108
|
+
* `appVersion`. Resolved here, never taken from the app: the React Native 0.x
|
|
109
|
+
* layer filled it this way, and 1.0 relying on the config instead left every
|
|
110
|
+
* event unversioned (2026-09-19). Null when the package manager can't answer.
|
|
111
|
+
*/
|
|
112
|
+
internal fun readAppVersion(context: Context): String? =
|
|
113
|
+
runCatching {
|
|
114
|
+
@Suppress("DEPRECATION")
|
|
115
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
|
116
|
+
}
|
|
117
|
+
.getOrNull()
|
|
118
|
+
?.takeIf { it.isNotBlank() }
|
|
119
|
+
|
|
106
120
|
internal fun collectDeviceInfo(context: Context): Map<String, Any?> {
|
|
107
121
|
val map = LinkedHashMap<String, Any?>()
|
|
108
122
|
map["model"] = Build.MODEL
|
|
@@ -24,23 +24,37 @@ public class HttpConversionFetcher(
|
|
|
24
24
|
private val timeoutMs: Int = 15_000,
|
|
25
25
|
) : ConversionFetcher {
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
internal val requestMethod: String = "GET"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sends `?platform=android`: this fetcher is Android-only by construction,
|
|
31
|
+
* and a project key spans listings, so the server needs the platform to pick
|
|
32
|
+
* the one this install was filed under (Y-LINKS-1). Without it the lookup
|
|
33
|
+
* refused the project key the dashboard mints, and the deferred deep link
|
|
34
|
+
* never arrived. No `store`: the SDK does not report one on events either, so
|
|
35
|
+
* both sides fall back to the project's default Android store.
|
|
36
|
+
*/
|
|
37
|
+
internal fun requestUrl(): URL {
|
|
28
38
|
val escaped = URLEncoder.encode(deviceId, "UTF-8")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
return URL("$endpoint/sdk/conversion-data?deviceId=$escaped&platform=android")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
internal fun requestHeaders(): Map<String, String> = mapOf("x-api-key" to apiKey)
|
|
43
|
+
|
|
44
|
+
override fun fetch(): ConversionResult {
|
|
45
|
+
val connection = requestUrl().openConnection() as HttpURLConnection
|
|
32
46
|
try {
|
|
33
|
-
connection.requestMethod =
|
|
47
|
+
connection.requestMethod = requestMethod
|
|
34
48
|
connection.connectTimeout = timeoutMs
|
|
35
49
|
connection.readTimeout = timeoutMs
|
|
36
|
-
connection.setRequestProperty(
|
|
50
|
+
for ((name, value) in requestHeaders()) connection.setRequestProperty(name, value)
|
|
37
51
|
|
|
38
52
|
val status =
|
|
39
53
|
try {
|
|
40
54
|
connection.responseCode
|
|
41
|
-
} catch (
|
|
55
|
+
} catch (error: IOException) {
|
|
42
56
|
// No status to reason about; 408 marks it retryable, as sdk-core does.
|
|
43
|
-
throw IngestError(408)
|
|
57
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
44
58
|
}
|
|
45
59
|
if (status < 200 || status >= 300) throw IngestError(status)
|
|
46
60
|
|
|
@@ -59,8 +73,8 @@ public class HttpConversionFetcher(
|
|
|
59
73
|
)
|
|
60
74
|
} catch (error: IngestError) {
|
|
61
75
|
throw error
|
|
62
|
-
} catch (
|
|
63
|
-
throw IngestError(408)
|
|
76
|
+
} catch (error: IOException) {
|
|
77
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
64
78
|
} finally {
|
|
65
79
|
connection.disconnect()
|
|
66
80
|
}
|
|
@@ -38,9 +38,16 @@ internal enum class ForegroundTransition {
|
|
|
38
38
|
* `ProcessLifecycleOwner`'s constant. We cannot take the dependency, but we can
|
|
39
39
|
* take its number.
|
|
40
40
|
*/
|
|
41
|
-
internal class ForegroundTracker {
|
|
41
|
+
internal class ForegroundTracker(seededInForeground: Boolean = false) {
|
|
42
42
|
private var started = 0
|
|
43
|
-
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Seeded by initialize to match the session it did or did not open. Seeded
|
|
46
|
+
* as foregrounded, the launcher activity's first start is not a second
|
|
47
|
+
* foreground, and the stop of an activity that started before the bridge
|
|
48
|
+
* was registered still reaches the background.
|
|
49
|
+
*/
|
|
50
|
+
private var inForeground = seededInForeground
|
|
44
51
|
private var backgroundPending = false
|
|
45
52
|
|
|
46
53
|
public fun onSignal(signal: LifecycleSignal): ForegroundTransition =
|
|
@@ -5,6 +5,7 @@ import io.advenue.core.ClientEvent
|
|
|
5
5
|
import io.advenue.core.EventTransport
|
|
6
6
|
import io.advenue.core.IngestError
|
|
7
7
|
import io.advenue.core.JvmHmacSigner
|
|
8
|
+
import io.advenue.core.iso8601
|
|
8
9
|
import io.advenue.core.Signer
|
|
9
10
|
import io.advenue.core.signRequest
|
|
10
11
|
import io.advenue.core.parseJson
|
|
@@ -48,8 +49,17 @@ internal class HttpUrlTransport(
|
|
|
48
49
|
* wire shape without a network round trip, which is the part that has to be
|
|
49
50
|
* right.
|
|
50
51
|
*/
|
|
51
|
-
internal fun buildBody(events: List<ClientEvent
|
|
52
|
-
writeCanonicalJson(
|
|
52
|
+
internal fun buildBody(events: List<ClientEvent>, nowMs: Long = clock.nowMs()): String =
|
|
53
|
+
writeCanonicalJson(
|
|
54
|
+
mapOf(
|
|
55
|
+
"apiKey" to apiKey,
|
|
56
|
+
"events" to events.map { it.toMap() },
|
|
57
|
+
// The device clock at THIS attempt (spec 2026-10-01-data-fidelity D3):
|
|
58
|
+
// the server compares it with its receive time to measure the clock
|
|
59
|
+
// offset, so it is stamped per send and never persisted with the queue.
|
|
60
|
+
"sentAt" to iso8601(nowMs),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
53
63
|
|
|
54
64
|
/** The headers for [body]; empty of signature headers when no secret is set. */
|
|
55
65
|
internal fun buildHeaders(body: String, timestamp: String): Map<String, String> {
|
|
@@ -61,8 +71,11 @@ internal class HttpUrlTransport(
|
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
override fun send(events: List<ClientEvent>) {
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
// One reading for both `sentAt` and the signed timestamp: they describe the
|
|
75
|
+
// same instant, and two reads could straddle a clock change.
|
|
76
|
+
val nowMs = clock.nowMs()
|
|
77
|
+
val body = buildBody(events, nowMs)
|
|
78
|
+
val headers = buildHeaders(body, nowMs.toString())
|
|
66
79
|
|
|
67
80
|
val connection = URL("$endpoint/v1/events").openConnection() as HttpURLConnection
|
|
68
81
|
try {
|
|
@@ -77,9 +90,9 @@ internal class HttpUrlTransport(
|
|
|
77
90
|
val status =
|
|
78
91
|
try {
|
|
79
92
|
connection.responseCode
|
|
80
|
-
} catch (
|
|
93
|
+
} catch (error: IOException) {
|
|
81
94
|
// No response at all is a transient failure, not a poison payload.
|
|
82
|
-
throw IngestError(408)
|
|
95
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
83
96
|
}
|
|
84
97
|
if (status < 200 || status >= 300) throw IngestError(status)
|
|
85
98
|
|
|
@@ -92,8 +105,8 @@ internal class HttpUrlTransport(
|
|
|
92
105
|
}
|
|
93
106
|
} catch (error: IngestError) {
|
|
94
107
|
throw error
|
|
95
|
-
} catch (
|
|
96
|
-
throw IngestError(408)
|
|
108
|
+
} catch (error: IOException) {
|
|
109
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
97
110
|
} finally {
|
|
98
111
|
connection.disconnect()
|
|
99
112
|
}
|
|
@@ -28,6 +28,13 @@ import java.util.concurrent.TimeUnit
|
|
|
28
28
|
*/
|
|
29
29
|
internal const val REFERRER_DEADLINE_MS: Long = 5_000
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* How long past [REFERRER_DEADLINE_MS] flushes stay held for a pending install:
|
|
33
|
+
* the enrichment thread still has to submit the install through the pipe after
|
|
34
|
+
* its deadline.
|
|
35
|
+
*/
|
|
36
|
+
internal const val INSTALL_HOLD_MARGIN_MS: Long = 2_000
|
|
37
|
+
|
|
31
38
|
/** A shorter budget for the enrichment that is not attribution-critical. */
|
|
32
39
|
internal const val ENRICHMENT_DEADLINE_MS: Long = 3_000
|
|
33
40
|
|
|
@@ -4,6 +4,7 @@ import android.content.Context
|
|
|
4
4
|
import io.advenue.core.DEVICE_ID_KEY
|
|
5
5
|
import io.advenue.core.INSTALLATION_ID_KEY
|
|
6
6
|
import io.advenue.core.INSTALL_SENT_KEY
|
|
7
|
+
import io.advenue.core.QUEUE_KEY
|
|
7
8
|
import java.io.File
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -83,6 +84,10 @@ internal class InstallScopedStore(context: Context) : ScopedStore {
|
|
|
83
84
|
DEVICE_ID_KEY to "advenue-device-id",
|
|
84
85
|
INSTALLATION_ID_KEY to "advenue-installation-id",
|
|
85
86
|
INSTALL_SENT_KEY to "advenue-install-sent",
|
|
87
|
+
// B4: kuyruk blob'u (GAID'li olay gövdeleri) yedekten çıkar — geri
|
|
88
|
+
// yüklenen kuyruk hem PII sızıntısı hem de bayat olay yağmurudur.
|
|
89
|
+
// Eski SharedPreferences anahtarı ilk okumada migrate edilir.
|
|
90
|
+
QUEUE_KEY to "advenue-queue",
|
|
86
91
|
)
|
|
87
92
|
|
|
88
93
|
/** The keys this store owns; everything else belongs in [PreferencesStore]. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package io.advenue.platform
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
+
import android.app.ActivityManager
|
|
4
5
|
import android.app.Application
|
|
5
6
|
import android.os.Bundle
|
|
6
7
|
import android.os.Handler
|
|
@@ -19,8 +20,9 @@ internal class LifecycleBridge(
|
|
|
19
20
|
private val application: Application,
|
|
20
21
|
private val onForeground: () -> Unit,
|
|
21
22
|
private val onBackground: () -> Unit,
|
|
23
|
+
seededInForeground: Boolean,
|
|
22
24
|
) : Application.ActivityLifecycleCallbacks {
|
|
23
|
-
private val tracker = ForegroundTracker()
|
|
25
|
+
private val tracker = ForegroundTracker(seededInForeground)
|
|
24
26
|
private val handler = Handler(Looper.getMainLooper())
|
|
25
27
|
private val debounce = Runnable { apply(tracker.onSignal(LifecycleSignal.DebounceElapsed)) }
|
|
26
28
|
|
|
@@ -70,3 +72,23 @@ internal class LifecycleBridge(
|
|
|
70
72
|
|
|
71
73
|
override fun onActivityDestroyed(activity: Activity): Unit = Unit
|
|
72
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether this process is running for a user-visible launch, read at
|
|
78
|
+
* initialize. `Application.onCreate` runs for a tapped icon and equally for a
|
|
79
|
+
* push, a WorkManager job or a broadcast that wakes the process with no
|
|
80
|
+
* activity; only the first is a session. During a user launch the system has
|
|
81
|
+
* already made the process top, so its importance is `IMPORTANCE_FOREGROUND`;
|
|
82
|
+
* a background wake reports a service or cached importance. The same check
|
|
83
|
+
* crash reporters use to tell a cold start from a background start. True
|
|
84
|
+
* wherever it cannot be read, which keeps the pre-existing behaviour: the
|
|
85
|
+
* launch is a foreground.
|
|
86
|
+
*/
|
|
87
|
+
internal fun readLaunchedInForeground(): Boolean =
|
|
88
|
+
try {
|
|
89
|
+
val info = ActivityManager.RunningAppProcessInfo()
|
|
90
|
+
ActivityManager.getMyMemoryState(info)
|
|
91
|
+
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
|
|
92
|
+
} catch (_: Throwable) {
|
|
93
|
+
true
|
|
94
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -65,7 +65,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
65
65
|
// package.json
|
|
66
66
|
var package_default = {
|
|
67
67
|
name: "@advenue/react-native",
|
|
68
|
-
version: "1.
|
|
68
|
+
version: "1.1.0",
|
|
69
69
|
description: "Advenue MMP React Native SDK \u2014 attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
|
|
70
70
|
type: "module",
|
|
71
71
|
license: "MIT",
|
|
@@ -214,11 +214,18 @@ function getLinking() {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
// src/index.ts
|
|
217
|
-
var IGNORED_OPTIONS = [
|
|
217
|
+
var IGNORED_OPTIONS = [
|
|
218
|
+
"mmkv",
|
|
219
|
+
"secureStore",
|
|
220
|
+
"sdkConfigFetcher",
|
|
221
|
+
"tcfDataCollection",
|
|
222
|
+
"appVersion"
|
|
223
|
+
];
|
|
218
224
|
var started = false;
|
|
219
225
|
var deepLinkSub = null;
|
|
220
226
|
var errorSub = null;
|
|
221
227
|
var onErrorHook;
|
|
228
|
+
var debugLogging = false;
|
|
222
229
|
var consentCache = false;
|
|
223
230
|
function bridge() {
|
|
224
231
|
return { ios: getIosNative(), android: getAndroidNative() };
|
|
@@ -243,6 +250,7 @@ function report(context, cause) {
|
|
|
243
250
|
onErrorHook?.(context, cause);
|
|
244
251
|
} catch {
|
|
245
252
|
}
|
|
253
|
+
if (debugLogging) console.warn(`[Advenue] ${context}`, cause);
|
|
246
254
|
}
|
|
247
255
|
function requireStarted() {
|
|
248
256
|
if (started) return true;
|
|
@@ -256,6 +264,7 @@ var Advenue = {
|
|
|
256
264
|
*/
|
|
257
265
|
async initialize(config) {
|
|
258
266
|
onErrorHook = config.onError;
|
|
267
|
+
debugLogging = config.debug === true;
|
|
259
268
|
for (const option of IGNORED_OPTIONS) {
|
|
260
269
|
if (config[option] !== void 0) {
|
|
261
270
|
report(
|
|
@@ -268,14 +277,17 @@ var Advenue = {
|
|
|
268
277
|
call("bridgeInitialize", {
|
|
269
278
|
apiKey: config.apiKey,
|
|
270
279
|
endpoint: config.endpoint,
|
|
271
|
-
appVersion: config.appVersion,
|
|
272
280
|
requireConsent: config.requireConsent ?? false,
|
|
273
281
|
batchSize: config.batchSize,
|
|
274
282
|
flushIntervalMs: config.flushIntervalMs,
|
|
275
283
|
signingSecret: config.signingSecret,
|
|
276
284
|
fbAppId: config.fbAppId,
|
|
277
285
|
autoTrackInstall: config.autoTrackInstall !== false,
|
|
278
|
-
|
|
286
|
+
// Names the wrapper, like Flutter's `advenue_sdk/…`: a bare version reads
|
|
287
|
+
// like the native SDK's own stamp.
|
|
288
|
+
sdkVersion: `react-native/${package_default.version}`,
|
|
289
|
+
debug: config.debug === true,
|
|
290
|
+
allowInsecureHttp: config.allowInsecureHttp === true
|
|
279
291
|
});
|
|
280
292
|
started = true;
|
|
281
293
|
const { ios: iosForErrors, android: androidForErrors } = bridge();
|
package/dist/index.d.cts
CHANGED
|
@@ -54,9 +54,12 @@ interface TrackOptions {
|
|
|
54
54
|
interface AdvenueConfig {
|
|
55
55
|
apiKey: string;
|
|
56
56
|
endpoint?: string;
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* @deprecated Ignored: the native SDK resolves the app version from the
|
|
59
|
+
* package/bundle. Passing it reports `config.ignored:appVersion` via
|
|
60
|
+
* `onError`. Removed in the next major.
|
|
61
|
+
*/
|
|
58
62
|
appVersion?: string;
|
|
59
|
-
osVersion?: string;
|
|
60
63
|
/** Gates every event on `setTrackingConsent(true)`. */
|
|
61
64
|
requireConsent?: boolean;
|
|
62
65
|
/** Events per request. */
|
|
@@ -69,9 +72,21 @@ interface AdvenueConfig {
|
|
|
69
72
|
* extracted from it.
|
|
70
73
|
*/
|
|
71
74
|
signingSecret?: string;
|
|
72
|
-
|
|
75
|
+
/**
|
|
76
|
+
* **Test only.** Allows an `http://` endpoint — a local self-hosted ingest,
|
|
77
|
+
* or a test server — which the native SDKs otherwise refuse at initialize.
|
|
78
|
+
* Never set it in a production build: everything, the API key included,
|
|
79
|
+
* then travels unencrypted.
|
|
80
|
+
*/
|
|
81
|
+
allowInsecureHttp?: boolean;
|
|
73
82
|
/** Called when the SDK swallows a best-effort failure. Never receives PII. */
|
|
74
83
|
onError?: (context: string, cause: unknown) => void;
|
|
84
|
+
/**
|
|
85
|
+
* Development aid: the native SDKs log their start, every swallowed failure
|
|
86
|
+
* and each sent batch to Xcode's console / logcat, and every failure is also
|
|
87
|
+
* written to the JS console as `[Advenue] <context>`. Never logs an
|
|
88
|
+
* identifier or a payload. Leave off in production; use `onError`.
|
|
89
|
+
*/
|
|
75
90
|
debug?: boolean;
|
|
76
91
|
}
|
|
77
92
|
|
|
@@ -80,7 +95,6 @@ type TrackingAuthorizationStatus = 'authorized' | 'denied' | 'restricted' | 'not
|
|
|
80
95
|
interface BridgeInitOptions {
|
|
81
96
|
apiKey: string;
|
|
82
97
|
endpoint?: string;
|
|
83
|
-
appVersion?: string;
|
|
84
98
|
requireConsent: boolean;
|
|
85
99
|
batchSize?: number;
|
|
86
100
|
flushIntervalMs?: number;
|