@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.
Files changed (42) hide show
  1. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -1
  2. package/android/src/main/kotlin/io/advenue/Advenue.kt +76 -11
  3. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +36 -1
  4. package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
  6. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +36 -3
  7. package/android/src/main/kotlin/io/advenue/core/Engine.kt +96 -4
  8. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
  9. package/android/src/main/kotlin/io/advenue/core/Limits.kt +3 -0
  10. package/android/src/main/kotlin/io/advenue/core/PiiScrub.kt +77 -0
  11. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
  12. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +14 -0
  13. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
  14. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
  15. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
  16. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
  17. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +5 -0
  18. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
  19. package/dist/index.cjs +16 -4
  20. package/dist/index.d.cts +18 -4
  21. package/dist/index.d.ts +18 -4
  22. package/dist/index.js +16 -4
  23. package/ios/AdvenueIosModule.swift +4 -2
  24. package/ios/vendor/Advenue/Advenue.swift +88 -17
  25. package/ios/vendor/Advenue/AdvenueConfig.swift +22 -2
  26. package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
  27. package/ios/vendor/Advenue/DebugLog.swift +46 -0
  28. package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
  29. package/ios/vendor/AdvenueCore/Engine.swift +83 -9
  30. package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
  31. package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
  32. package/ios/vendor/AdvenuePlatform/CacheFileStore.swift +90 -0
  33. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
  34. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
  35. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +31 -0
  36. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
  37. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +20 -4
  38. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
  39. package/package.json +1 -1
  40. package/src/index.ts +20 -3
  41. package/src/native-types.ts +0 -1
  42. package/src/types.ts +18 -3
@@ -44,7 +44,6 @@ class AdvenueAndroidModule : Module() {
44
44
  AdvenueConfig(
45
45
  apiKey = apiKey,
46
46
  endpoint = (options["endpoint"] as? String) ?: DEFAULT_ENDPOINT,
47
- appVersion = options["appVersion"] as? String,
48
47
  requireConsent = (options["requireConsent"] as? Boolean) ?: false,
49
48
  batchSize = (options["batchSize"] as? Number)?.toInt() ?: 20,
50
49
  flushIntervalMs = (options["flushIntervalMs"] as? Number)?.toLong() ?: 15_000L,
@@ -58,6 +57,10 @@ class AdvenueAndroidModule : Module() {
58
57
  // The JavaScript has sent this since before the inversion; nothing
59
58
  // read it, so Meta install-referrer attribution never ran.
60
59
  fbAppId = options["fbAppId"] as? String,
60
+ // Logs the start, failures and sent batches to logcat (tag Advenue).
61
+ debug = (options["debug"] as? Boolean) ?: false,
62
+ // Test only: an http:// endpoint is refused without it.
63
+ allowInsecureHttp = (options["allowInsecureHttp"] as? Boolean) ?: false,
61
64
  ),
62
65
  )
63
66
  }
@@ -19,6 +19,8 @@ import io.advenue.core.SystemClock
19
19
  import io.advenue.core.SystemUuids
20
20
  import io.advenue.core.TimerScheduler
21
21
  import io.advenue.platform.HttpConversionFetcher
22
+ import io.advenue.platform.INSTALL_HOLD_MARGIN_MS
23
+ import io.advenue.platform.REFERRER_DEADLINE_MS
22
24
  import io.advenue.platform.HttpUrlTransport
23
25
  import io.advenue.platform.IdentityResolution
24
26
  import io.advenue.platform.EnrichmentSources
@@ -30,6 +32,8 @@ import io.advenue.core.extractAemCampaignIds
30
32
  import io.advenue.core.tcfToConsent
31
33
  import io.advenue.platform.collectDeviceInfo
32
34
  import io.advenue.platform.readTcf
35
+ import io.advenue.platform.readAppVersion
36
+ import io.advenue.platform.readLaunchedInForeground
33
37
  import io.advenue.platform.collectEnrichment
34
38
  import io.advenue.platform.sha256Hex
35
39
  import io.advenue.platform.resolveIdentity
@@ -249,13 +253,27 @@ internal class FacadeState {
249
253
  */
250
254
  fun start(
251
255
  application: Application,
252
- config: AdvenueConfig,
256
+ requested: AdvenueConfig,
253
257
  transport: EventTransport? = null,
254
258
  sources: EnrichmentSources? = null,
259
+ launchedInForeground: () -> Boolean = ::readLaunchedInForeground,
260
+ logSink: AdvenueLogSink? = null,
255
261
  ) {
256
262
  // Replace-and-shut-down, never add.
257
263
  stop()
258
264
 
265
+ // `debug`: every swallowed failure goes through onError, so logging there
266
+ // covers all of them — wrapped once, before anything below captures it.
267
+ val (config, log) = withDebugLogging(requested, logSink)
268
+
269
+ // The SDK resolves the app version itself (readAppVersion); a config value
270
+ // is ignored — and said so, rather than looking like it took effect.
271
+ if (config.appVersion != null) {
272
+ config.onError(
273
+ "config.ignored:appVersion",
274
+ IllegalArgumentException("appVersion is resolved by the SDK and has no effect"),
275
+ )
276
+ }
259
277
  val scoped = InstallScopedStore(application)
260
278
  val prefs = PreferencesStore(application)
261
279
  val uuid = SystemUuids()
@@ -266,12 +284,21 @@ internal class FacadeState {
266
284
  // and nothing starts, so no event carries an invented identifier. The
267
285
  // receiver below is the exit — without it the deferral would trade a
268
286
  // phantom id for permanent silence.
269
- config.onError("identity.deferred", IngestError(0))
270
- registerUnlockRetry(application, config, transport, sources)
287
+ // Reported as what it is: an IngestError(0) read as an HTTP failure
288
+ // that never happened, naming no cause.
289
+ config.onError(
290
+ "identity.deferred",
291
+ IllegalStateException(
292
+ "credential-encrypted storage is locked (not unlocked since boot); " +
293
+ "the SDK starts when the user unlocks the device",
294
+ ),
295
+ )
296
+ // The unwrapped config: the retry wraps it again on its own start.
297
+ registerUnlockRetry(application, requested, transport, sources)
271
298
  return
272
299
  }
273
300
 
274
- val eventTransport =
301
+ val baseTransport =
275
302
  transport
276
303
  ?: HttpUrlTransport(
277
304
  endpoint = config.endpoint,
@@ -280,8 +307,14 @@ internal class FacadeState {
280
307
  clock = SystemClock(),
281
308
  // Diagnostics only: it runs after the batch is already accepted, so
282
309
  // nothing it does can turn a successful ingest into a failure.
283
- onAccepted = { appId -> resolvedAppId = appId },
310
+ onAccepted = { appId ->
311
+ // A key pasted from the wrong app is otherwise silent.
312
+ if (resolvedAppId != appId) log("[Advenue] ingesting into app $appId")
313
+ resolvedAppId = appId
314
+ },
284
315
  )
316
+ val eventTransport =
317
+ if (config.debug) LoggingTransport(baseTransport, log) else baseTransport
285
318
 
286
319
  val scheduler = TimerScheduler()
287
320
  val engine =
@@ -292,12 +325,13 @@ internal class FacadeState {
292
325
  platform = "android",
293
326
  deviceId = identity.deviceId,
294
327
  installationId = identity.installationId,
295
- appVersion = config.appVersion,
328
+ appVersion = readAppVersion(application),
296
329
  osVersion = Build.VERSION.RELEASE,
297
330
  sdkVersion = (config.sdkVersion ?: AdvenueVersion.CURRENT).take(32),
298
331
  requireConsent = config.requireConsent,
299
332
  sessionWindowMs = config.sessionWindowMs,
300
333
  batchSize = config.batchSize,
334
+ piiScrubEnabled = config.piiScrubEnabled,
301
335
  ),
302
336
  store = CompositeStore(prefs, scoped),
303
337
  clock = SystemClock(),
@@ -329,10 +363,19 @@ internal class FacadeState {
329
363
  pendingDeepLinks = mutableListOf()
330
364
  }
331
365
 
366
+ // Before anything else is recorded: a launch that still owes its install
367
+ // stamps it now and holds every flush until the install is enqueued, so
368
+ // the server never sees this launch's session or events ahead of it. The
369
+ // hold outlives the referrer deadline by a margin and then lapses.
370
+ pipe.submit(Command.BeginInstall(REFERRER_DEADLINE_MS + INSTALL_HOLD_MARGIN_MS))
371
+
332
372
  // Replayed in arrival order, before the first session, so a deferred deep
333
373
  // link is attributed to the launch it belongs to.
334
374
  buffered.forEach { sendDeepLink(it) }
335
375
 
376
+ // A process woken with no activity (a push, a WorkManager job, a
377
+ // broadcast) is not a session: the first activity start opens it instead.
378
+ val inForeground = launchedInForeground()
336
379
  val bridge =
337
380
  LifecycleBridge(
338
381
  application,
@@ -343,12 +386,19 @@ internal class FacadeState {
343
386
  // for hours.
344
387
  pipe.submit(Command.Flush)
345
388
  },
389
+ // Seeded to match: the Foreground below IS this launch's session, so
390
+ // the launcher activity's start that follows must not open a second
391
+ // one — the engine would read the open session as a killed app and
392
+ // split it with a synthetic session_end.
393
+ seededInForeground = inForeground,
346
394
  )
347
395
  bridge.start()
348
- // Application.onCreate runs before the first activity starts, so the
349
- // foreground signal arrives from the bridge; a cold start where the SDK is
350
- // initialised from an already-running activity still needs this.
351
- pipe.submit(Command.Foreground)
396
+ if (inForeground) pipe.submit(Command.Foreground)
397
+ log(
398
+ "[Advenue] initialized — endpoint ${config.endpoint}, " +
399
+ "sdk ${config.sdkVersion ?: AdvenueVersion.CURRENT}, " +
400
+ if (inForeground) "foreground launch" else "background launch, no session yet",
401
+ )
352
402
 
353
403
  // Enrichment then install, off the caller's thread. initialize() returns
354
404
  // synchronously — an SDK that blocks Application.onCreate for five seconds
@@ -368,6 +418,8 @@ internal class FacadeState {
368
418
  gaid = enrichment.gaid,
369
419
  androidId = null,
370
420
  appInstanceId = enrichment.appInstanceId,
421
+ // B2: bayrak artık düşürülmüyor — olaya işlenir.
422
+ limitAdTracking = enrichment.limitAdTracking,
371
423
  ),
372
424
  )
373
425
  // Device metadata rides the install, where Meta CAPI reads it.
@@ -458,7 +510,10 @@ internal class FacadeState {
458
510
  }
459
511
 
460
512
  private fun sendDeepLink(url: String) {
461
- submit(Command.Track("deep_link", mapOf("url" to url), "custom"))
513
+ // B3: ham query gönderilmez — token/e-posta query'de taşınır. AEM
514
+ // ayrıştırması ve sourceUrlHash TAM url'den yapılır (aşağıda), yalnızca
515
+ // gönderilen `url` özelliği scheme+host+path'e indirgenir.
516
+ submit(Command.Track("deep_link", mapOf("url" to stripUrlQuery(url)), "custom"))
462
517
 
463
518
  // Meta AEM: a link from Meta carries al_applink_data with an opaque,
464
519
  // Meta-encrypted campaign_ids blob. Emitted at most once per URL — the
@@ -482,6 +537,7 @@ internal class FacadeState {
482
537
  private fun queryValue(url: String, name: String): String? =
483
538
  runCatching { android.net.Uri.parse(url).getQueryParameter(name) }.getOrNull()
484
539
 
540
+
485
541
  /** Test surface: how many links are waiting for `initialize`. */
486
542
  val bufferedDeepLinkCount: Int
487
543
  get() = synchronized(lock) { pendingDeepLinks.size }
@@ -547,3 +603,12 @@ internal class FacadeState {
547
603
  val app: Application?,
548
604
  )
549
605
  }
606
+
607
+ /**
608
+ * B3: deep-link URL'sinden query+fragment atılır (token/e-posta taşınır).
609
+ * Saf dize işlemi — Android çalıştırıcısı gerekmez, JVM testinde de çalışır.
610
+ */
611
+ internal fun stripUrlQuery(url: String): String {
612
+ val cut = url.indexOfAny(charArrayOf('?', '#'))
613
+ return if (cut >= 0) url.substring(0, cut) else url
614
+ }
@@ -12,6 +12,7 @@ public data class AdvenueConfig
12
12
  constructor(
13
13
  val apiKey: String,
14
14
  val endpoint: String = DEFAULT_ENDPOINT,
15
+ /** Ignored: the SDK reads the app version from the package itself. Setting it reports `config.ignored:appVersion` through [onError]. Removed in the next major. */
15
16
  val appVersion: String? = null,
16
17
  val requireConsent: Boolean = false,
17
18
  val sessionWindowMs: Long = DEFAULT_SESSION_WINDOW_MS,
@@ -53,7 +54,30 @@ constructor(
53
54
  * iOS has no equivalent. A field on the Swift config would do nothing.
54
55
  */
55
56
  val fbAppId: String? = null,
57
+ /**
58
+ * B1: track properties PII scrub'u. Varsayılan-açık; yalnızca açıkça
59
+ * kapatın (dokümante risk — ham PII ingest'e gider). Swift ile aynı.
60
+ */
61
+ val piiScrubEnabled: Boolean = true,
62
+ /**
63
+ * B5: testler için `http://` izni. Üretimde kapalı tutun — açık `http://`
64
+ * API anahtarı dahil tüm olayları şifresiz taşır.
65
+ */
66
+ val allowInsecureHttp: Boolean = false,
67
+ /**
68
+ * Development aid: logs the SDK's start, every failure [onError] sees and each
69
+ * accepted batch to logcat (tag `Advenue`). Never logs an identifier or a
70
+ * payload. Leave off in production; route failures through [onError] instead.
71
+ */
72
+ val debug: Boolean = false,
56
73
  ) {
74
+ init {
75
+ // B5: şemasız endpoint fail-fast — geçersiz şema transportta değil,
76
+ // kurulumda yakalanır.
77
+ require(allowInsecureHttp || endpoint.startsWith("https://")) {
78
+ "Advenue endpoint must use https:// (set allowInsecureHttp only for tests)"
79
+ }
80
+ }
57
81
  /**
58
82
  * Java-friendly builder. Kotlin's named and default arguments are invisible
59
83
  * from Java, and both incumbents are Java-first — a Kotlin SDK that is painful
@@ -64,6 +88,7 @@ constructor(
64
88
 
65
89
  public fun endpoint(value: String): Builder = apply { config = config.copy(endpoint = value) }
66
90
 
91
+ /** Ignored — see [AdvenueConfig.appVersion]. */
67
92
  public fun appVersion(value: String?): Builder = apply {
68
93
  config = config.copy(appVersion = value)
69
94
  }
@@ -98,6 +123,16 @@ constructor(
98
123
  config = config.copy(fbAppId = value)
99
124
  }
100
125
 
126
+ public fun piiScrubEnabled(value: Boolean): Builder = apply {
127
+ config = config.copy(piiScrubEnabled = value)
128
+ }
129
+
130
+ public fun allowInsecureHttp(value: Boolean): Builder = apply {
131
+ config = config.copy(allowInsecureHttp = value)
132
+ }
133
+
134
+ public fun debug(value: Boolean): Builder = apply { config = config.copy(debug = value) }
135
+
101
136
  public fun build(): AdvenueConfig = config
102
137
  }
103
138
  }
@@ -110,5 +145,5 @@ constructor(
110
145
  * the event.
111
146
  */
112
147
  public object AdvenueVersion {
113
- public const val CURRENT: String = "0.1.0"
148
+ public const val CURRENT: String = "1.1.0"
114
149
  }
@@ -0,0 +1,51 @@
1
+ package io.advenue
2
+
3
+ import android.util.Log
4
+ import io.advenue.core.ClientEvent
5
+ import io.advenue.core.EventTransport
6
+
7
+ /** Where [AdvenueConfig.debug] writes: logcat in an app, a recorder in tests. */
8
+ internal fun interface AdvenueLogSink {
9
+ fun log(message: String)
10
+ }
11
+
12
+ /** Logcat, tag `Advenue`. The lines carry failure contexts, counts and the app
13
+ * id the server resolved — never an identifier or a payload. */
14
+ internal object LogcatSink : AdvenueLogSink {
15
+ override fun log(message: String) {
16
+ Log.i("Advenue", message)
17
+ }
18
+ }
19
+
20
+ /** Logs each accepted batch; failures already reach the log through the
21
+ * wrapped `onError` (`flush.transport`, `flush.poison`). */
22
+ internal class LoggingTransport(
23
+ private val inner: EventTransport,
24
+ private val log: (String) -> Unit,
25
+ ) : EventTransport {
26
+ override fun send(events: List<ClientEvent>) {
27
+ inner.send(events)
28
+ log("[Advenue] sent ${events.size} event(s)")
29
+ }
30
+ }
31
+
32
+ /**
33
+ * With `debug` on: the config with `onError` also writing to the log, and the
34
+ * log itself. Off: the config unchanged and a log that discards.
35
+ */
36
+ internal fun withDebugLogging(
37
+ config: AdvenueConfig,
38
+ sink: AdvenueLogSink?,
39
+ ): Pair<AdvenueConfig, (String) -> Unit> {
40
+ if (!config.debug) return config to {}
41
+ val out = sink ?: LogcatSink
42
+ val report = config.onError
43
+ val logged =
44
+ config.copy(
45
+ onError = { context, error ->
46
+ out.log("[Advenue] $context: $error")
47
+ report(context, error)
48
+ },
49
+ )
50
+ return logged to { message -> out.log(message) }
51
+ }
@@ -5,7 +5,20 @@ package io.advenue.core
5
5
  * poison payload: retrying a 400 forever would block the queue head, and
6
6
  * dropping a 429 would discard events over a throttle.
7
7
  */
8
- internal class IngestError(public val status: Int) : Exception("advenue ingest failed: $status") {
8
+ internal class IngestError(
9
+ public val status: Int,
10
+ /**
11
+ * Set when no response arrived at all — DNS, refused connection, timeout, no
12
+ * network. [status] is then 408 so the backoff retries it, but that 408 was
13
+ * never sent by a server, and logging it as one sends whoever debugs it
14
+ * looking at the wrong end (F-SDK-10).
15
+ */
16
+ public val networkCause: String? = null,
17
+ ) :
18
+ Exception(
19
+ if (networkCause != null) "advenue ingest failed: no response (network error: $networkCause)"
20
+ else "advenue ingest failed: $status",
21
+ ) {
9
22
  public val isRetryable: Boolean
10
23
  get() = status == 408 || status == 429 || status >= 500
11
24
  }
@@ -19,12 +19,17 @@ internal sealed class Command {
19
19
  val gaid: String?,
20
20
  val androidId: String?,
21
21
  val appInstanceId: String?,
22
+ /** B2: reklam kimliği rıza bayrağı — toplanıp düşürülüyordu. */
23
+ val limitAdTracking: Boolean? = null,
22
24
  ) : Command()
23
25
 
24
26
  internal data class SetAppInstanceId(val id: String?) : Command()
25
27
 
26
28
  public data class TrackInstall(val properties: Map<String, Any?>?) : Command()
27
29
 
30
+ /** A launch that still owes its install: hold flushes for at most [holdMs]. */
31
+ internal data class BeginInstall(val holdMs: Long) : Command()
32
+
28
33
  internal data class SetConsentData(val consent: Consent?) : Command()
29
34
 
30
35
  internal data class SetPushToken(val token: String?, val provider: String?) : Command()
@@ -121,26 +126,54 @@ internal class CommandPipe(
121
126
  is Command.SetConsent -> engine.setConsent(command.granted)
122
127
  is Command.SetUserId -> engine.setUserId(command.id)
123
128
  is Command.SetIdentity ->
124
- engine.setIdentity(command.gaid, command.androidId, command.appInstanceId)
129
+ engine.setIdentity(
130
+ command.gaid,
131
+ command.androidId,
132
+ command.appInstanceId,
133
+ limitAdTracking = command.limitAdTracking,
134
+ )
125
135
  is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
126
136
  is Command.TrackInstall -> engine.trackInstall(command.properties)
137
+ is Command.BeginInstall -> engine.beginInstall(command.holdMs)
127
138
  is Command.SetConsentData -> engine.setConsentData(command.consent)
128
139
  is Command.SetPushToken -> engine.setPushToken(command.token, command.provider)
129
140
  Command.ForgetMe -> engine.forgetMe()
130
141
  Command.Foreground -> engine.notifyForeground()
131
142
  Command.Background -> engine.notifyBackground()
132
- Command.Flush -> engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
143
+ Command.Flush -> {
144
+ val batch = engine.beginFlush()
145
+ if (batch != null) {
146
+ sends.put(Send(batch, single = null))
147
+ } else if (engine.isFlushing()) {
148
+ // Asked for while a batch is on the wire: run it when that send
149
+ // finishes rather than drop it — the events tracked meanwhile would
150
+ // otherwise wait a whole auto-flush interval.
151
+ flushRequested = true
152
+ }
153
+ }
133
154
  is Command.BatchOutcome ->
134
155
  engine.onBatchOutcome(command.error)?.let { sends.put(Send(listOf(it), single = it)) }
156
+ ?: flushIfRequested()
135
157
  is Command.SingleOutcome ->
136
158
  engine.onSingleOutcome(command.event, command.error)?.let {
137
159
  sends.put(Send(listOf(it), single = it))
138
- }
160
+ } ?: flushIfRequested()
139
161
  is Command.RunScheduled -> command.work()
140
162
  Command.Shutdown -> Unit
141
163
  }
142
164
  }
143
165
 
166
+ /** Core-thread only, like everything [handle] touches. */
167
+ private var flushRequested = false
168
+
169
+ /** The flush a send in flight deferred, once that send is over. After a
170
+ * transient failure the backoff makes this a no-op: the timer retries. */
171
+ private fun flushIfRequested() {
172
+ if (!flushRequested || engine.isFlushing()) return
173
+ flushRequested = false
174
+ engine.beginFlush()?.let { sends.put(Send(it, single = null)) }
175
+ }
176
+
144
177
  /**
145
178
  * Synchronous, non-blocking and **ordered** — the entire reason the ingress
146
179
  * is a queue.
@@ -39,6 +39,9 @@ internal data class EngineConfig(
39
39
  val batchSize: Int = 20,
40
40
  val retryBaseMs: Double = 1_000.0,
41
41
  val retryCapMs: Double = 60_000.0,
42
+ /** B1: track properties PII scrub'u. Varsayılan-açık; yalnızca açık bayrakla
43
+ * kapatılır (opt-out) ve bu dokümante risklidir. Swift EngineConfig ile aynı. */
44
+ val piiScrubEnabled: Boolean = true,
42
45
  )
43
46
 
44
47
  /**
@@ -72,9 +75,24 @@ internal class AdvenueEngine(
72
75
  private var gaid: String? = null
73
76
  private var androidId: String? = null
74
77
  private var appInstanceId: String? = null
78
+ /** B2: rıza bayrağı — her olaya işlenir, persist edilmez (kimlik gibi). */
79
+ private var limitAdTracking: Boolean? = null
75
80
  private var consentData: Consent? = readConsentData()
76
81
  private var pushToken: String? = null
77
82
  private var pushProvider: String? = null
83
+ /**
84
+ * F-SDK-3: when this launch began an install that is not recorded yet. The
85
+ * install is stamped with it — the first open, not the moment the referrer
86
+ * read that preceded it finished.
87
+ */
88
+ private var installStartedAtMs: Long? = null
89
+
90
+ /**
91
+ * Flushes are held until the install is enqueued, so nothing reaches the
92
+ * server ahead of it. Bounded: past this instant the hold lapses, because a
93
+ * lost enrichment thread must never strand the queue.
94
+ */
95
+ private var installHoldUntilMs: Long? = null
78
96
  private var flushing = false
79
97
  private var consecutiveFailures = 0
80
98
  private var backoffUntilMs = 0L
@@ -90,7 +108,12 @@ internal class AdvenueEngine(
90
108
  onError("track.rejected:$rejection", IngestError(400))
91
109
  return false
92
110
  }
93
- queue.enqueue(newEvent(type = type, name = name, properties = properties))
111
+ // B1: PII scrub (Swift PIIScrub ile aynı sözleşme).
112
+ val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
113
+ queue.enqueue(newEvent(type = type, name = name, properties = scrubbed))
114
+ // An event is proof of life: a relaunch after an OS kill measures the gap
115
+ // from the last one (F-SDK-7).
116
+ sessions.heartbeat()
94
117
  return true
95
118
  }
96
119
 
@@ -146,6 +169,12 @@ internal class AdvenueEngine(
146
169
  (parseJson(store.getString(CONSENT_DATA_KEY) ?: "") as? Map<*, *>)?.let(Consent::fromMap)
147
170
 
148
171
  public fun setUserId(id: String?) {
172
+ // B6: 128 üstü id tüm batch'i 400'e düşürür (ingest bütün-batch ayrıştırır).
173
+ // Reddet, persist etme, onError ile bildir (Limits.kt deseni).
174
+ if (id != null && id.length > MAX_USER_ID_LENGTH) {
175
+ onError("account.setUserId:id_too_long", IngestError(400))
176
+ return
177
+ }
149
178
  customerUserId = id
150
179
  if (id != null) store.setString(USER_ID_KEY, id) else store.remove(USER_ID_KEY)
151
180
  }
@@ -160,11 +189,13 @@ internal class AdvenueEngine(
160
189
  androidId: String?,
161
190
  appInstanceId: String?,
162
191
  idfa: String? = null,
192
+ limitAdTracking: Boolean? = null,
163
193
  ) {
164
194
  this.gaid = gaid
165
195
  this.androidId = androidId
166
196
  this.appInstanceId = appInstanceId
167
197
  this.idfa = idfa
198
+ this.limitAdTracking = limitAdTracking
168
199
  }
169
200
 
170
201
  /**
@@ -187,13 +218,55 @@ internal class AdvenueEngine(
187
218
  * failure.
188
219
  */
189
220
  public fun trackInstall(properties: Map<String, Any?>? = null): Boolean {
221
+ val startedAtMs = installStartedAtMs
222
+ // Every outcome below ends the wait: recorded, refused or already sent,
223
+ // nothing is coming that the held events should still wait for.
224
+ releaseInstallHold()
190
225
  if (forgotten || (config.requireConsent && !consent)) return false
191
226
  if (store.getString(INSTALL_SENT_KEY) == "1") return false
192
- queue.enqueue(newEvent(type = "install", name = "install", properties = properties))
227
+ // B1: install properties de scrub kapsamındadır.
228
+ val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
229
+ // At the head: the session and custom events recorded while enrichment ran
230
+ // are already queued, and the install must reach the server before them.
231
+ queue.enqueueFirst(
232
+ newEvent(
233
+ type = "install",
234
+ name = "install",
235
+ properties = scrubbed,
236
+ timestampMs = startedAtMs ?: clock.nowMs(),
237
+ ),
238
+ )
193
239
  store.setString(INSTALL_SENT_KEY, "1")
194
240
  return true
195
241
  }
196
242
 
243
+ /**
244
+ * Marks the start of a launch that still owes its install. Called by the
245
+ * facade BEFORE the launch's foreground, while enrichment (Play referrer,
246
+ * advertising id, attestation) runs. Adjust and AppsFlyer both hold every
247
+ * later package behind the first one; this does the same, for at most
248
+ * [holdMs].
249
+ */
250
+ public fun beginInstall(holdMs: Long) {
251
+ if (forgotten || store.getString(INSTALL_SENT_KEY) == "1") return
252
+ val now = clock.nowMs()
253
+ installStartedAtMs = now
254
+ installHoldUntilMs = now + holdMs
255
+ }
256
+
257
+ /** Whether a pending install still holds flushes back. */
258
+ private fun heldForInstall(): Boolean {
259
+ val until = installHoldUntilMs ?: return false
260
+ if (clock.nowMs() < until) return true
261
+ installHoldUntilMs = null
262
+ return false
263
+ }
264
+
265
+ private fun releaseInstallHold() {
266
+ installStartedAtMs = null
267
+ installHoldUntilMs = null
268
+ }
269
+
197
270
  /**
198
271
  * Erasure, including the install-scoped identifiers.
199
272
  *
@@ -211,6 +284,7 @@ internal class AdvenueEngine(
211
284
  */
212
285
  public fun forgetMe() {
213
286
  forgotten = true
287
+ releaseInstallHold()
214
288
  queue.clear()
215
289
  sessions.reset()
216
290
  consent = false
@@ -231,6 +305,10 @@ internal class AdvenueEngine(
231
305
  DEVICE_ID_KEY,
232
306
  INSTALLATION_ID_KEY,
233
307
  INSTALL_SENT_KEY,
308
+ // queue.clear() above persists an empty blob synchronously; remove it
309
+ // so erasure leaves no queue file behind either. Safe to do last:
310
+ // `forgotten` stops every later enqueue, so nothing rewrites it.
311
+ QUEUE_KEY,
234
312
  )) {
235
313
  store.remove(key)
236
314
  }
@@ -269,9 +347,21 @@ internal class AdvenueEngine(
269
347
  /** Whether a transport is wired at all. */
270
348
  internal fun canFlush(): Boolean = transport != null
271
349
 
350
+ /** Whether a batch is on the wire (its outcome not yet consumed). */
351
+ internal fun isFlushing(): Boolean = flushing
352
+
272
353
  /** Decides what to send. Null means there is nothing to do. */
273
354
  internal fun beginFlush(): List<ClientEvent>? {
274
- if (transport == null || flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs) {
355
+ // The auto-flush timer is the foreground tick: it keeps the open
356
+ // sub-session's last activity fresh even when the app records nothing.
357
+ if (!forgotten) sessions.heartbeat()
358
+ if (
359
+ transport == null ||
360
+ flushing ||
361
+ queue.size == 0 ||
362
+ clock.nowMs() < backoffUntilMs ||
363
+ heldForInstall()
364
+ ) {
275
365
  return null
276
366
  }
277
367
  // Clamped to the wire's limit, not trusted. The server answers 400 for a
@@ -387,13 +477,14 @@ internal class AdvenueEngine(
387
477
  type: String,
388
478
  name: String,
389
479
  properties: Map<String, Any?>?,
480
+ timestampMs: Long = clock.nowMs(),
390
481
  ): ClientEvent =
391
482
  ClientEvent(
392
483
  id = uuid.next(),
393
484
  deviceId = config.deviceId,
394
485
  type = type,
395
486
  name = name,
396
- timestamp = iso8601(clock.nowMs()),
487
+ timestamp = iso8601(timestampMs),
397
488
  platform = config.platform,
398
489
  installationId = config.installationId,
399
490
  appVersion = config.appVersion,
@@ -402,6 +493,7 @@ internal class AdvenueEngine(
402
493
  customerUserId = customerUserId,
403
494
  idfa = idfa,
404
495
  gaid = gaid,
496
+ limitAdTracking = limitAdTracking,
405
497
  androidId = androidId,
406
498
  appInstanceId = appInstanceId,
407
499
  consent = consentData?.toMap(),
@@ -35,6 +35,18 @@ internal class EventQueue(
35
35
  schedulePersist()
36
36
  }
37
37
 
38
+ /**
39
+ * Puts an event at the HEAD of the queue — the install, which is the first
40
+ * package of an installation and must not be preceded by the session and
41
+ * custom events recorded while enrichment ran. Over the cap, the oldest
42
+ * events behind it are dropped, never the event just placed.
43
+ */
44
+ public fun enqueueFirst(event: ClientEvent) {
45
+ events.add(0, event)
46
+ while (events.size > maxSize) events.removeAt(1)
47
+ schedulePersist()
48
+ }
49
+
38
50
  public fun peek(max: Int): List<ClientEvent> = events.take(max)
39
51
 
40
52
  public fun ack(sent: List<ClientEvent>) {
@@ -22,6 +22,9 @@ package io.advenue.core
22
22
  /** `clientEventSchema.name` — z.string().min(1).max(128). */
23
23
  internal const val MAX_NAME_LENGTH: Int = 128
24
24
 
25
+ /** `customerUserId` — z.string().max(128). Üstü batch'i 400'e düşürür. */
26
+ internal const val MAX_USER_ID_LENGTH: Int = 128
27
+
25
28
  /** `propertiesSchema` — at most this many keys. */
26
29
  internal const val MAX_PROPERTIES_KEYS: Int = 50
27
30