@advenue/react-native 1.0.1 → 1.1.1

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 (37) hide show
  1. package/android/build.gradle +8 -2
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -0
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +49 -9
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +9 -1
  5. package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
  6. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +28 -2
  8. package/android/src/main/kotlin/io/advenue/core/Engine.kt +73 -3
  9. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
  10. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
  11. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
  12. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
  13. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
  14. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
  15. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
  16. package/dist/index.cjs +18 -2
  17. package/dist/index.d.cts +13 -3
  18. package/dist/index.d.ts +13 -3
  19. package/dist/index.js +18 -2
  20. package/ios/AdvenueIosModule.swift +4 -1
  21. package/ios/vendor/Advenue/Advenue.swift +53 -18
  22. package/ios/vendor/Advenue/AdvenueConfig.swift +17 -3
  23. package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
  24. package/ios/vendor/Advenue/DebugLog.swift +46 -0
  25. package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
  26. package/ios/vendor/AdvenueCore/Engine.swift +58 -4
  27. package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
  28. package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
  29. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
  30. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
  31. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +19 -0
  32. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
  33. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +4 -0
  34. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
  35. package/package.json +18 -9
  36. package/src/index.ts +11 -1
  37. package/src/types.ts +13 -3
@@ -8,14 +8,20 @@ plugins {
8
8
  id 'expo-module-gradle-plugin'
9
9
  }
10
10
 
11
+ // Single source of truth, mirroring AdvenueIos.podspec (`s.version =
12
+ // package['version']`): a hardcoded copy here shipped 0.6.3 while the
13
+ // package was 1.1.0. Inert at runtime — an autolinked Expo module is never
14
+ // published to Maven — but a wrong version misleads every build scan.
15
+ def packageJson = new groovy.json.JsonSlurper().parseText(file('../package.json').text)
16
+
11
17
  group = 'expo.modules.advenue'
12
- version = '0.6.3'
18
+ version = packageJson.version
13
19
 
14
20
  android {
15
21
  namespace "expo.modules.advenue"
16
22
  defaultConfig {
17
23
  versionCode 1
18
- versionName "0.6.3"
24
+ versionName packageJson.version
19
25
  }
20
26
  }
21
27
 
@@ -57,6 +57,10 @@ class AdvenueAndroidModule : Module() {
57
57
  // The JavaScript has sent this since before the inversion; nothing
58
58
  // read it, so Meta install-referrer attribution never ran.
59
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,
60
64
  ),
61
65
  )
62
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
@@ -31,6 +33,7 @@ import io.advenue.core.tcfToConsent
31
33
  import io.advenue.platform.collectDeviceInfo
32
34
  import io.advenue.platform.readTcf
33
35
  import io.advenue.platform.readAppVersion
36
+ import io.advenue.platform.readLaunchedInForeground
34
37
  import io.advenue.platform.collectEnrichment
35
38
  import io.advenue.platform.sha256Hex
36
39
  import io.advenue.platform.resolveIdentity
@@ -250,13 +253,19 @@ internal class FacadeState {
250
253
  */
251
254
  fun start(
252
255
  application: Application,
253
- config: AdvenueConfig,
256
+ requested: AdvenueConfig,
254
257
  transport: EventTransport? = null,
255
258
  sources: EnrichmentSources? = null,
259
+ launchedInForeground: () -> Boolean = ::readLaunchedInForeground,
260
+ logSink: AdvenueLogSink? = null,
256
261
  ) {
257
262
  // Replace-and-shut-down, never add.
258
263
  stop()
259
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
+
260
269
  // The SDK resolves the app version itself (readAppVersion); a config value
261
270
  // is ignored — and said so, rather than looking like it took effect.
262
271
  if (config.appVersion != null) {
@@ -275,12 +284,21 @@ internal class FacadeState {
275
284
  // and nothing starts, so no event carries an invented identifier. The
276
285
  // receiver below is the exit — without it the deferral would trade a
277
286
  // phantom id for permanent silence.
278
- config.onError("identity.deferred", IngestError(0))
279
- 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)
280
298
  return
281
299
  }
282
300
 
283
- val eventTransport =
301
+ val baseTransport =
284
302
  transport
285
303
  ?: HttpUrlTransport(
286
304
  endpoint = config.endpoint,
@@ -289,8 +307,14 @@ internal class FacadeState {
289
307
  clock = SystemClock(),
290
308
  // Diagnostics only: it runs after the batch is already accepted, so
291
309
  // nothing it does can turn a successful ingest into a failure.
292
- 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
+ },
293
315
  )
316
+ val eventTransport =
317
+ if (config.debug) LoggingTransport(baseTransport, log) else baseTransport
294
318
 
295
319
  val scheduler = TimerScheduler()
296
320
  val engine =
@@ -339,10 +363,19 @@ internal class FacadeState {
339
363
  pendingDeepLinks = mutableListOf()
340
364
  }
341
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
+
342
372
  // Replayed in arrival order, before the first session, so a deferred deep
343
373
  // link is attributed to the launch it belongs to.
344
374
  buffered.forEach { sendDeepLink(it) }
345
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()
346
379
  val bridge =
347
380
  LifecycleBridge(
348
381
  application,
@@ -353,12 +386,19 @@ internal class FacadeState {
353
386
  // for hours.
354
387
  pipe.submit(Command.Flush)
355
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,
356
394
  )
357
395
  bridge.start()
358
- // Application.onCreate runs before the first activity starts, so the
359
- // foreground signal arrives from the bridge; a cold start where the SDK is
360
- // initialised from an already-running activity still needs this.
361
- 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
+ )
362
402
 
363
403
  // Enrichment then install, off the caller's thread. initialize() returns
364
404
  // synchronously — an SDK that blocks Application.onCreate for five seconds
@@ -64,6 +64,12 @@ constructor(
64
64
  * API anahtarı dahil tüm olayları şifresiz taşır.
65
65
  */
66
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,
67
73
  ) {
68
74
  init {
69
75
  // B5: şemasız endpoint fail-fast — geçersiz şema transportta değil,
@@ -125,6 +131,8 @@ constructor(
125
131
  config = config.copy(allowInsecureHttp = value)
126
132
  }
127
133
 
134
+ public fun debug(value: Boolean): Builder = apply { config = config.copy(debug = value) }
135
+
128
136
  public fun build(): AdvenueConfig = config
129
137
  }
130
138
  }
@@ -137,5 +145,5 @@ constructor(
137
145
  * the event.
138
146
  */
139
147
  public object AdvenueVersion {
140
- public const val CURRENT: String = "0.1.0"
148
+ public const val CURRENT: String = "1.1.0"
141
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
  }
@@ -27,6 +27,9 @@ internal sealed class Command {
27
27
 
28
28
  public data class TrackInstall(val properties: Map<String, Any?>?) : Command()
29
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
+
30
33
  internal data class SetConsentData(val consent: Consent?) : Command()
31
34
 
32
35
  internal data class SetPushToken(val token: String?, val provider: String?) : Command()
@@ -131,23 +134,46 @@ internal class CommandPipe(
131
134
  )
132
135
  is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
133
136
  is Command.TrackInstall -> engine.trackInstall(command.properties)
137
+ is Command.BeginInstall -> engine.beginInstall(command.holdMs)
134
138
  is Command.SetConsentData -> engine.setConsentData(command.consent)
135
139
  is Command.SetPushToken -> engine.setPushToken(command.token, command.provider)
136
140
  Command.ForgetMe -> engine.forgetMe()
137
141
  Command.Foreground -> engine.notifyForeground()
138
142
  Command.Background -> engine.notifyBackground()
139
- 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
+ }
140
154
  is Command.BatchOutcome ->
141
155
  engine.onBatchOutcome(command.error)?.let { sends.put(Send(listOf(it), single = it)) }
156
+ ?: flushIfRequested()
142
157
  is Command.SingleOutcome ->
143
158
  engine.onSingleOutcome(command.event, command.error)?.let {
144
159
  sends.put(Send(listOf(it), single = it))
145
- }
160
+ } ?: flushIfRequested()
146
161
  is Command.RunScheduled -> command.work()
147
162
  Command.Shutdown -> Unit
148
163
  }
149
164
  }
150
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
+
151
177
  /**
152
178
  * Synchronous, non-blocking and **ordered** — the entire reason the ingress
153
179
  * is a queue.
@@ -80,6 +80,19 @@ internal class AdvenueEngine(
80
80
  private var consentData: Consent? = readConsentData()
81
81
  private var pushToken: String? = null
82
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
83
96
  private var flushing = false
84
97
  private var consecutiveFailures = 0
85
98
  private var backoffUntilMs = 0L
@@ -98,6 +111,9 @@ internal class AdvenueEngine(
98
111
  // B1: PII scrub (Swift PIIScrub ile aynı sözleşme).
99
112
  val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
100
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()
101
117
  return true
102
118
  }
103
119
 
@@ -202,15 +218,55 @@ internal class AdvenueEngine(
202
218
  * failure.
203
219
  */
204
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()
205
225
  if (forgotten || (config.requireConsent && !consent)) return false
206
226
  if (store.getString(INSTALL_SENT_KEY) == "1") return false
207
227
  // B1: install properties de scrub kapsamındadır.
208
228
  val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
209
- queue.enqueue(newEvent(type = "install", name = "install", properties = scrubbed))
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
+ )
210
239
  store.setString(INSTALL_SENT_KEY, "1")
211
240
  return true
212
241
  }
213
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
+
214
270
  /**
215
271
  * Erasure, including the install-scoped identifiers.
216
272
  *
@@ -228,6 +284,7 @@ internal class AdvenueEngine(
228
284
  */
229
285
  public fun forgetMe() {
230
286
  forgotten = true
287
+ releaseInstallHold()
231
288
  queue.clear()
232
289
  sessions.reset()
233
290
  consent = false
@@ -290,9 +347,21 @@ internal class AdvenueEngine(
290
347
  /** Whether a transport is wired at all. */
291
348
  internal fun canFlush(): Boolean = transport != null
292
349
 
350
+ /** Whether a batch is on the wire (its outcome not yet consumed). */
351
+ internal fun isFlushing(): Boolean = flushing
352
+
293
353
  /** Decides what to send. Null means there is nothing to do. */
294
354
  internal fun beginFlush(): List<ClientEvent>? {
295
- 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
+ ) {
296
365
  return null
297
366
  }
298
367
  // Clamped to the wire's limit, not trusted. The server answers 400 for a
@@ -408,13 +477,14 @@ internal class AdvenueEngine(
408
477
  type: String,
409
478
  name: String,
410
479
  properties: Map<String, Any?>?,
480
+ timestampMs: Long = clock.nowMs(),
411
481
  ): ClientEvent =
412
482
  ClientEvent(
413
483
  id = uuid.next(),
414
484
  deviceId = config.deviceId,
415
485
  type = type,
416
486
  name = name,
417
- timestamp = iso8601(clock.nowMs()),
487
+ timestamp = iso8601(timestampMs),
418
488
  platform = config.platform,
419
489
  installationId = config.installationId,
420
490
  appVersion = config.appVersion,
@@ -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>) {