@advenue/react-native 1.0.0 → 1.0.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.
@@ -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,
@@ -30,6 +30,7 @@ import io.advenue.core.extractAemCampaignIds
30
30
  import io.advenue.core.tcfToConsent
31
31
  import io.advenue.platform.collectDeviceInfo
32
32
  import io.advenue.platform.readTcf
33
+ import io.advenue.platform.readAppVersion
33
34
  import io.advenue.platform.collectEnrichment
34
35
  import io.advenue.platform.sha256Hex
35
36
  import io.advenue.platform.resolveIdentity
@@ -256,6 +257,14 @@ internal class FacadeState {
256
257
  // Replace-and-shut-down, never add.
257
258
  stop()
258
259
 
260
+ // The SDK resolves the app version itself (readAppVersion); a config value
261
+ // is ignored — and said so, rather than looking like it took effect.
262
+ if (config.appVersion != null) {
263
+ config.onError(
264
+ "config.ignored:appVersion",
265
+ IllegalArgumentException("appVersion is resolved by the SDK and has no effect"),
266
+ )
267
+ }
259
268
  val scoped = InstallScopedStore(application)
260
269
  val prefs = PreferencesStore(application)
261
270
  val uuid = SystemUuids()
@@ -292,12 +301,13 @@ internal class FacadeState {
292
301
  platform = "android",
293
302
  deviceId = identity.deviceId,
294
303
  installationId = identity.installationId,
295
- appVersion = config.appVersion,
304
+ appVersion = readAppVersion(application),
296
305
  osVersion = Build.VERSION.RELEASE,
297
306
  sdkVersion = (config.sdkVersion ?: AdvenueVersion.CURRENT).take(32),
298
307
  requireConsent = config.requireConsent,
299
308
  sessionWindowMs = config.sessionWindowMs,
300
309
  batchSize = config.batchSize,
310
+ piiScrubEnabled = config.piiScrubEnabled,
301
311
  ),
302
312
  store = CompositeStore(prefs, scoped),
303
313
  clock = SystemClock(),
@@ -368,6 +378,8 @@ internal class FacadeState {
368
378
  gaid = enrichment.gaid,
369
379
  androidId = null,
370
380
  appInstanceId = enrichment.appInstanceId,
381
+ // B2: bayrak artık düşürülmüyor — olaya işlenir.
382
+ limitAdTracking = enrichment.limitAdTracking,
371
383
  ),
372
384
  )
373
385
  // Device metadata rides the install, where Meta CAPI reads it.
@@ -458,7 +470,10 @@ internal class FacadeState {
458
470
  }
459
471
 
460
472
  private fun sendDeepLink(url: String) {
461
- submit(Command.Track("deep_link", mapOf("url" to url), "custom"))
473
+ // B3: ham query gönderilmez — token/e-posta query'de taşınır. AEM
474
+ // ayrıştırması ve sourceUrlHash TAM url'den yapılır (aşağıda), yalnızca
475
+ // gönderilen `url` özelliği scheme+host+path'e indirgenir.
476
+ submit(Command.Track("deep_link", mapOf("url" to stripUrlQuery(url)), "custom"))
462
477
 
463
478
  // Meta AEM: a link from Meta carries al_applink_data with an opaque,
464
479
  // Meta-encrypted campaign_ids blob. Emitted at most once per URL — the
@@ -482,6 +497,7 @@ internal class FacadeState {
482
497
  private fun queryValue(url: String, name: String): String? =
483
498
  runCatching { android.net.Uri.parse(url).getQueryParameter(name) }.getOrNull()
484
499
 
500
+
485
501
  /** Test surface: how many links are waiting for `initialize`. */
486
502
  val bufferedDeepLinkCount: Int
487
503
  get() = synchronized(lock) { pendingDeepLinks.size }
@@ -547,3 +563,12 @@ internal class FacadeState {
547
563
  val app: Application?,
548
564
  )
549
565
  }
566
+
567
+ /**
568
+ * B3: deep-link URL'sinden query+fragment atılır (token/e-posta taşınır).
569
+ * Saf dize işlemi — Android çalıştırıcısı gerekmez, JVM testinde de çalışır.
570
+ */
571
+ internal fun stripUrlQuery(url: String): String {
572
+ val cut = url.indexOfAny(charArrayOf('?', '#'))
573
+ return if (cut >= 0) url.substring(0, cut) else url
574
+ }
@@ -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,24 @@ 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,
56
67
  ) {
68
+ init {
69
+ // B5: şemasız endpoint fail-fast — geçersiz şema transportta değil,
70
+ // kurulumda yakalanır.
71
+ require(allowInsecureHttp || endpoint.startsWith("https://")) {
72
+ "Advenue endpoint must use https:// (set allowInsecureHttp only for tests)"
73
+ }
74
+ }
57
75
  /**
58
76
  * Java-friendly builder. Kotlin's named and default arguments are invisible
59
77
  * from Java, and both incumbents are Java-first — a Kotlin SDK that is painful
@@ -64,6 +82,7 @@ constructor(
64
82
 
65
83
  public fun endpoint(value: String): Builder = apply { config = config.copy(endpoint = value) }
66
84
 
85
+ /** Ignored — see [AdvenueConfig.appVersion]. */
67
86
  public fun appVersion(value: String?): Builder = apply {
68
87
  config = config.copy(appVersion = value)
69
88
  }
@@ -98,6 +117,14 @@ constructor(
98
117
  config = config.copy(fbAppId = value)
99
118
  }
100
119
 
120
+ public fun piiScrubEnabled(value: Boolean): Builder = apply {
121
+ config = config.copy(piiScrubEnabled = value)
122
+ }
123
+
124
+ public fun allowInsecureHttp(value: Boolean): Builder = apply {
125
+ config = config.copy(allowInsecureHttp = value)
126
+ }
127
+
101
128
  public fun build(): AdvenueConfig = config
102
129
  }
103
130
  }
@@ -19,6 +19,8 @@ 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()
@@ -121,7 +123,12 @@ internal class CommandPipe(
121
123
  is Command.SetConsent -> engine.setConsent(command.granted)
122
124
  is Command.SetUserId -> engine.setUserId(command.id)
123
125
  is Command.SetIdentity ->
124
- engine.setIdentity(command.gaid, command.androidId, command.appInstanceId)
126
+ engine.setIdentity(
127
+ command.gaid,
128
+ command.androidId,
129
+ command.appInstanceId,
130
+ limitAdTracking = command.limitAdTracking,
131
+ )
125
132
  is Command.SetAppInstanceId -> engine.setAppInstanceId(command.id)
126
133
  is Command.TrackInstall -> engine.trackInstall(command.properties)
127
134
  is Command.SetConsentData -> engine.setConsentData(command.consent)
@@ -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,6 +75,8 @@ 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
@@ -90,7 +95,9 @@ internal class AdvenueEngine(
90
95
  onError("track.rejected:$rejection", IngestError(400))
91
96
  return false
92
97
  }
93
- queue.enqueue(newEvent(type = type, name = name, properties = properties))
98
+ // B1: PII scrub (Swift PIIScrub ile aynı sözleşme).
99
+ val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
100
+ queue.enqueue(newEvent(type = type, name = name, properties = scrubbed))
94
101
  return true
95
102
  }
96
103
 
@@ -146,6 +153,12 @@ internal class AdvenueEngine(
146
153
  (parseJson(store.getString(CONSENT_DATA_KEY) ?: "") as? Map<*, *>)?.let(Consent::fromMap)
147
154
 
148
155
  public fun setUserId(id: String?) {
156
+ // B6: 128 üstü id tüm batch'i 400'e düşürür (ingest bütün-batch ayrıştırır).
157
+ // Reddet, persist etme, onError ile bildir (Limits.kt deseni).
158
+ if (id != null && id.length > MAX_USER_ID_LENGTH) {
159
+ onError("account.setUserId:id_too_long", IngestError(400))
160
+ return
161
+ }
149
162
  customerUserId = id
150
163
  if (id != null) store.setString(USER_ID_KEY, id) else store.remove(USER_ID_KEY)
151
164
  }
@@ -160,11 +173,13 @@ internal class AdvenueEngine(
160
173
  androidId: String?,
161
174
  appInstanceId: String?,
162
175
  idfa: String? = null,
176
+ limitAdTracking: Boolean? = null,
163
177
  ) {
164
178
  this.gaid = gaid
165
179
  this.androidId = androidId
166
180
  this.appInstanceId = appInstanceId
167
181
  this.idfa = idfa
182
+ this.limitAdTracking = limitAdTracking
168
183
  }
169
184
 
170
185
  /**
@@ -189,7 +204,9 @@ internal class AdvenueEngine(
189
204
  public fun trackInstall(properties: Map<String, Any?>? = null): Boolean {
190
205
  if (forgotten || (config.requireConsent && !consent)) return false
191
206
  if (store.getString(INSTALL_SENT_KEY) == "1") return false
192
- queue.enqueue(newEvent(type = "install", name = "install", properties = properties))
207
+ // B1: install properties de scrub kapsamındadır.
208
+ val scrubbed = if (config.piiScrubEnabled) PiiScrub.scrub(properties) else properties
209
+ queue.enqueue(newEvent(type = "install", name = "install", properties = scrubbed))
193
210
  store.setString(INSTALL_SENT_KEY, "1")
194
211
  return true
195
212
  }
@@ -231,6 +248,10 @@ internal class AdvenueEngine(
231
248
  DEVICE_ID_KEY,
232
249
  INSTALLATION_ID_KEY,
233
250
  INSTALL_SENT_KEY,
251
+ // queue.clear() above persists an empty blob synchronously; remove it
252
+ // so erasure leaves no queue file behind either. Safe to do last:
253
+ // `forgotten` stops every later enqueue, so nothing rewrites it.
254
+ QUEUE_KEY,
234
255
  )) {
235
256
  store.remove(key)
236
257
  }
@@ -402,6 +423,7 @@ internal class AdvenueEngine(
402
423
  customerUserId = customerUserId,
403
424
  idfa = idfa,
404
425
  gaid = gaid,
426
+ limitAdTracking = limitAdTracking,
405
427
  androidId = androidId,
406
428
  appInstanceId = appInstanceId,
407
429
  consent = consentData?.toMap(),
@@ -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
 
@@ -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
+ }
@@ -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
@@ -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]. */
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.0.0",
68
+ version: "1.0.1",
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,7 +214,13 @@ function getLinking() {
214
214
  }
215
215
 
216
216
  // src/index.ts
217
- var IGNORED_OPTIONS = ["mmkv", "secureStore", "sdkConfigFetcher", "tcfDataCollection"];
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;
@@ -268,7 +274,6 @@ var Advenue = {
268
274
  call("bridgeInitialize", {
269
275
  apiKey: config.apiKey,
270
276
  endpoint: config.endpoint,
271
- appVersion: config.appVersion,
272
277
  requireConsent: config.requireConsent ?? false,
273
278
  batchSize: config.batchSize,
274
279
  flushIntervalMs: config.flushIntervalMs,
package/dist/index.d.cts CHANGED
@@ -55,6 +55,11 @@ interface AdvenueConfig {
55
55
  apiKey: string;
56
56
  endpoint?: string;
57
57
  platform?: 'ios' | 'android' | 'web' | string;
58
+ /**
59
+ * @deprecated Ignored: the native SDK resolves the app version from the
60
+ * package/bundle. Passing it reports `config.ignored:appVersion` via
61
+ * `onError`. Removed in the next major.
62
+ */
58
63
  appVersion?: string;
59
64
  osVersion?: string;
60
65
  /** Gates every event on `setTrackingConsent(true)`. */
@@ -80,7 +85,6 @@ type TrackingAuthorizationStatus = 'authorized' | 'denied' | 'restricted' | 'not
80
85
  interface BridgeInitOptions {
81
86
  apiKey: string;
82
87
  endpoint?: string;
83
- appVersion?: string;
84
88
  requireConsent: boolean;
85
89
  batchSize?: number;
86
90
  flushIntervalMs?: number;
package/dist/index.d.ts CHANGED
@@ -55,6 +55,11 @@ interface AdvenueConfig {
55
55
  apiKey: string;
56
56
  endpoint?: string;
57
57
  platform?: 'ios' | 'android' | 'web' | string;
58
+ /**
59
+ * @deprecated Ignored: the native SDK resolves the app version from the
60
+ * package/bundle. Passing it reports `config.ignored:appVersion` via
61
+ * `onError`. Removed in the next major.
62
+ */
58
63
  appVersion?: string;
59
64
  osVersion?: string;
60
65
  /** Gates every event on `setTrackingConsent(true)`. */
@@ -80,7 +85,6 @@ type TrackingAuthorizationStatus = 'authorized' | 'denied' | 'restricted' | 'not
80
85
  interface BridgeInitOptions {
81
86
  apiKey: string;
82
87
  endpoint?: string;
83
- appVersion?: string;
84
88
  requireConsent: boolean;
85
89
  batchSize?: number;
86
90
  flushIntervalMs?: number;
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ var require_native_require = __commonJS({
54
54
  // package.json
55
55
  var package_default = {
56
56
  name: "@advenue/react-native",
57
- version: "1.0.0",
57
+ version: "1.0.1",
58
58
  description: "Advenue MMP React Native SDK \u2014 attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
59
59
  type: "module",
60
60
  license: "MIT",
@@ -203,7 +203,13 @@ function getLinking() {
203
203
  }
204
204
 
205
205
  // src/index.ts
206
- var IGNORED_OPTIONS = ["mmkv", "secureStore", "sdkConfigFetcher", "tcfDataCollection"];
206
+ var IGNORED_OPTIONS = [
207
+ "mmkv",
208
+ "secureStore",
209
+ "sdkConfigFetcher",
210
+ "tcfDataCollection",
211
+ "appVersion"
212
+ ];
207
213
  var started = false;
208
214
  var deepLinkSub = null;
209
215
  var errorSub = null;
@@ -257,7 +263,6 @@ var Advenue = {
257
263
  call("bridgeInitialize", {
258
264
  apiKey: config.apiKey,
259
265
  endpoint: config.endpoint,
260
- appVersion: config.appVersion,
261
266
  requireConsent: config.requireConsent ?? false,
262
267
  batchSize: config.batchSize,
263
268
  flushIntervalMs: config.flushIntervalMs,
@@ -37,7 +37,6 @@ public class AdvenueIosModule: Module {
37
37
  var config = AdvenueConfig(
38
38
  apiKey: app,
39
39
  endpoint: (options["endpoint"] as? String) ?? DEFAULT_ENDPOINT,
40
- appVersion: options["appVersion"] as? String,
41
40
  requireConsent: (options["requireConsent"] as? Bool) ?? false,
42
41
  signingSecret: options["signingSecret"] as? String,
43
42
  onError: { [weak self] context, error in
@@ -208,12 +208,30 @@ final class FacadeState: @unchecked Sendable {
208
208
  _ config: AdvenueConfig,
209
209
  transport: (any EventTransport)? = nil,
210
210
  sources: EnrichmentSources? = nil,
211
- skan skanReporter: (any SkanReporter)? = nil
211
+ skan skanReporter: (any SkanReporter)? = nil,
212
+ appVersion readVersion: () -> String? = readAppVersion
212
213
  ) {
213
214
  // Replace-and-shut-down, never add.
214
215
  stop()
215
216
 
216
- let store = UserDefaultsStore()
217
+ // The SDK resolves the app version itself (readAppVersion); a config value
218
+ // is ignored — and said so, rather than looking like it took effect.
219
+ if config.appVersion != nil {
220
+ config.onError(
221
+ "config.ignored:appVersion",
222
+ IngestError(status: 0))
223
+ }
224
+
225
+ // B4: kuyruk blob'u Caches dosyalarına (yedek dışı); UserDefaults'ta
226
+ // kalan eski blob ilk okumada migrate edilir. Caches yoksa UserDefaults
227
+ // geri dönüşüdür — yedek kapsamı açılır ama SDK çalışır (fail-open, loglanır).
228
+ let store: any KeyValueStore
229
+ if let files = CacheFileStore.caches() {
230
+ store = CompositeStore(files: files)
231
+ } else {
232
+ store = UserDefaultsStore()
233
+ config.onError("store.cache_unavailable", IngestError(status: 0))
234
+ }
217
235
  let secure = KeychainStore()
218
236
  let uuid = SystemUUIDs()
219
237
 
@@ -245,7 +263,7 @@ final class FacadeState: @unchecked Sendable {
245
263
  let engine = AdvenueEngine(
246
264
  config: EngineConfig(
247
265
  apiKey: config.apiKey, platform: "ios", deviceId: deviceId,
248
- installationId: installationId, appVersion: config.appVersion,
266
+ installationId: installationId, appVersion: readVersion(),
249
267
  osVersion: osVersion,
250
268
  sdkVersion: String((config.sdkVersion ?? AdvenueVersion.current).prefix(32)),
251
269
  requireConsent: config.requireConsent, sessionWindowMs: config.sessionWindowMs,
@@ -357,7 +375,8 @@ final class FacadeState: @unchecked Sendable {
357
375
  self?.submit(
358
376
  .setIdentity(
359
377
  idfa: enrichment.idfa, vendorId: enrichment.vendorId,
360
- appInstanceId: enrichment.appInstanceId))
378
+ appInstanceId: enrichment.appInstanceId,
379
+ limitAdTracking: enrichment.limitAdTracking))
361
380
  self?.submit(.setDeviceInfo(collectDeviceInfo()))
362
381
  // The CMP writes TCF to the standard defaults, and reading it is the
363
382
  // difference between shipping a real consent signal and shipping none.
@@ -487,10 +506,27 @@ final class FacadeState: @unchecked Sendable {
487
506
  return pendingDeepLinks.count
488
507
  }
489
508
 
509
+ /// B3: deep-link URL'sinden query+fragment atılır. URLComponents
510
+ /// çözümlenemezse ham dizenin `?`/`#` öncesi alınır (asla ham query sızmaz).
511
+ func stripUrlQuery(_ url: URL) -> String {
512
+ if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
513
+ components.query = nil
514
+ components.fragment = nil
515
+ if let stripped = components.string { return stripped }
516
+ }
517
+ let raw = url.absoluteString
518
+ if let cut = raw.firstIndex(where: { $0 == "?" || $0 == "#" }) {
519
+ return String(raw[..<cut])
520
+ }
521
+ return raw
522
+ }
523
+
490
524
  private func send(_ url: URL) {
525
+ // B3: ham query gönderilmez — token/e-posta query'de taşınır. AEM
526
+ // ayrıştırması ve hash TAM url'den yapılır (aşağıda).
491
527
  submit(
492
528
  .track(
493
- name: "deep_link", properties: ["url": .string(url.absoluteString)], type: "custom"))
529
+ name: "deep_link", properties: ["url": .string(stripUrlQuery(url))], type: "custom"))
494
530
 
495
531
  // Meta AEM: a link from Meta carries al_applink_data with an opaque,
496
532
  // Meta-encrypted campaign_ids blob. Emitted at most once per URL — the same
@@ -3,6 +3,8 @@ import Foundation
3
3
  public struct AdvenueConfig: Sendable {
4
4
  public var apiKey: String
5
5
  public var endpoint: String
6
+ /// Ignored: the SDK reads the app version from the bundle itself. Setting it
7
+ /// reports `config.ignored:appVersion` through `onError`. Removed in the next major.
6
8
  public var appVersion: String?
7
9
  public var requireConsent: Bool
8
10
  public var sessionWindowMs: Int64
@@ -41,6 +43,12 @@ public struct AdvenueConfig: Sendable {
41
43
  /// the whole batch down with a 400, so it is truncated rather than sent.
42
44
  public var sdkVersion: String?
43
45
 
46
+ /// B5: `https://` şema kuralı. Fail-fast `precondition` ile kurulumda
47
+ /// yakalanır; kuralın kendisi test edilebilir saf fonksiyondur.
48
+ public static func isSecureEndpoint(_ endpoint: String) -> Bool {
49
+ endpoint.lowercased().hasPrefix("https://")
50
+ }
51
+
44
52
  public init(
45
53
  apiKey: String,
46
54
  endpoint: String = DEFAULT_ENDPOINT,
@@ -52,8 +60,12 @@ public struct AdvenueConfig: Sendable {
52
60
  signingSecret: String? = nil,
53
61
  conversionValues: ConversionValueConfig? = nil,
54
62
  onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
55
- sdkVersion: String? = nil
63
+ sdkVersion: String? = nil,
64
+ allowInsecureHttp: Bool = false
56
65
  ) {
66
+ precondition(
67
+ allowInsecureHttp || Self.isSecureEndpoint(endpoint),
68
+ "Advenue endpoint must use https:// (allowInsecureHttp is test-only)")
57
69
  self.apiKey = apiKey
58
70
  self.endpoint = endpoint
59
71
  self.appVersion = appVersion
@@ -147,6 +147,8 @@ public actor AdvenueEngine {
147
147
  event.sdkVersion = config.sdkVersion
148
148
  event.customerUserId = customerUserId
149
149
  event.idfa = idfa
150
+ // B2: rıza bayrağı olaya işlenir.
151
+ event.limitAdTracking = limitAdTracking
150
152
  event.vendorId = vendorId
151
153
  event.appInstanceId = appInstanceId
152
154
  event.consent = consentData
@@ -163,10 +165,17 @@ public actor AdvenueEngine {
163
165
  /// Identity attached to every subsequent event. Not persisted: it is
164
166
  /// re-resolved each launch, because ATT status and the vendor id can both
165
167
  /// change between them.
166
- public func setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?) {
168
+ /// B2: rıza bayrağı — her olaya işlenir, persist edilmez (kimlik gibi).
169
+ private var limitAdTracking: Bool?
170
+
171
+ public func setIdentity(
172
+ idfa: String?, vendorId: String?, appInstanceId: String?,
173
+ limitAdTracking: Bool? = nil
174
+ ) {
167
175
  self.idfa = idfa
168
176
  self.vendorId = vendorId
169
177
  self.appInstanceId = appInstanceId
178
+ self.limitAdTracking = limitAdTracking
170
179
  }
171
180
 
172
181
  /// The Firebase App Instance ID alone. Separate from `setIdentity` because it
@@ -206,6 +215,8 @@ public actor AdvenueEngine {
206
215
  event.sdkVersion = config.sdkVersion
207
216
  event.customerUserId = customerUserId
208
217
  event.idfa = idfa
218
+ // B2: rıza bayrağı install olayına da işlenir.
219
+ event.limitAdTracking = limitAdTracking
209
220
  event.vendorId = vendorId
210
221
  event.appInstanceId = appInstanceId
211
222
  event.consent = consentData
@@ -347,6 +358,11 @@ public actor AdvenueEngine {
347
358
 
348
359
 
349
360
  public func setUserId(_ id: String?) {
361
+ // B6: 128 üstü id tüm batch'i 400'e düşürür. Reddet, persist etme.
362
+ if let id, id.count > 128 {
363
+ onError("account.setUserId:id_too_long", IngestError(status: 400))
364
+ return
365
+ }
350
366
  customerUserId = id
351
367
  if let id {
352
368
  store.set(id, forKey: USER_ID_KEY)
@@ -368,7 +384,9 @@ public actor AdvenueEngine {
368
384
  pushToken = nil
369
385
  pushProvider = nil
370
386
  customerUserId = nil
371
- for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY] {
387
+ // QUEUE_KEY last: queue.clear() above persists an empty blob synchronously,
388
+ // and `forgotten` stops every later enqueue, so nothing rewrites it.
389
+ for key in [CONSENT_KEY, CONSENT_DATA_KEY, SESSION_STATE_KEY, USER_ID_KEY, QUEUE_KEY] {
372
390
  store.removeObject(forKey: key)
373
391
  }
374
392
  }
@@ -477,7 +495,7 @@ public enum Command: Sendable {
477
495
  case foreground
478
496
  case background
479
497
  case flush
480
- case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?)
498
+ case setIdentity(idfa: String?, vendorId: String?, appInstanceId: String?, limitAdTracking: Bool? = nil)
481
499
  case setAppInstanceId(String?)
482
500
  case setConsentData(Consent?)
483
501
  case setPushToken(token: String?, provider: String?)
@@ -543,8 +561,10 @@ public final class CommandPipe: @unchecked Sendable {
543
561
  await engine.notifyBackground()
544
562
  case .flush:
545
563
  await engine.flush()
546
- case .setIdentity(let idfa, let vendorId, let appInstanceId):
547
- await engine.setIdentity(idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId)
564
+ case .setIdentity(let idfa, let vendorId, let appInstanceId, let limitAdTracking):
565
+ await engine.setIdentity(
566
+ idfa: idfa, vendorId: vendorId, appInstanceId: appInstanceId,
567
+ limitAdTracking: limitAdTracking)
548
568
  case .setAppInstanceId(let id):
549
569
  await engine.setAppInstanceId(id)
550
570
  case .trackInstall(let token, let attestation, let challenge):
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// B4: dosya destekli anahtar-değer deposu. Caches dizini iCloud/yedek
4
+ /// kapsamı dışındadır — kuyruk blob'u (reklam kimlikli olay gövdeleri)
5
+ /// yedek imajına sızmaz. Linux taşınabilirliği için yalnızca Foundation.
6
+ public struct CacheFileStore: KeyValueStore, @unchecked Sendable {
7
+ private let directory: URL
8
+ private let fileManager = FileManager.default
9
+
10
+ /// - Parameter directory: Kök dizin. Üretimde Caches/advenue kullanılır;
11
+ /// testler geçici dizin verir.
12
+ public init(directory: URL) {
13
+ self.directory = directory
14
+ }
15
+
16
+ public static func caches() -> CacheFileStore? {
17
+ guard
18
+ let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
19
+ else { return nil }
20
+ let directory = base.appendingPathComponent("advenue", isDirectory: true)
21
+ try? FileManager.default.createDirectory(
22
+ at: directory, withIntermediateDirectories: true)
23
+ return CacheFileStore(directory: directory)
24
+ }
25
+
26
+ private func file(forKey key: String) -> URL {
27
+ // Anahtar dosya adı olur — boş/kök kaçışı engellenir.
28
+ let safe = key.replacingOccurrences(of: "/", with: "_")
29
+ return directory.appendingPathComponent(safe, isDirectory: false)
30
+ }
31
+
32
+ public func string(forKey key: String) -> String? {
33
+ try? String(contentsOf: file(forKey: key), encoding: .utf8)
34
+ }
35
+
36
+ public func set(_ value: String, forKey key: String) {
37
+ try? fileManager.createDirectory(
38
+ at: directory, withIntermediateDirectories: true)
39
+ try? value.write(to: file(forKey: key), atomically: true, encoding: .utf8)
40
+ }
41
+
42
+ public func removeObject(forKey key: String) {
43
+ try? fileManager.removeItem(at: file(forKey: key))
44
+ }
45
+ }
46
+
47
+ /// B4: bileşik depo — kuyruk blob'u dosyalara (yedek dışı), geri kalan
48
+ /// UserDefaults'a. Android `CompositeStore` ile aynı yönlendirme.
49
+ /// Eski UserDefaults anahtarı ilk okumada migrate edilir (RN uyumluluğu).
50
+ public struct CompositeStore: KeyValueStore, @unchecked Sendable {
51
+ private let defaults: UserDefaultsStore
52
+ private let files: CacheFileStore
53
+ private let fileKeys: Set<String>
54
+
55
+ public init(
56
+ defaults: UserDefaultsStore = UserDefaultsStore(),
57
+ files: CacheFileStore,
58
+ fileKeys: Set<String> = [QUEUE_KEY]
59
+ ) {
60
+ self.defaults = defaults
61
+ self.files = files
62
+ self.fileKeys = fileKeys
63
+ }
64
+
65
+ public func string(forKey key: String) -> String? {
66
+ guard fileKeys.contains(key) else { return defaults.string(forKey: key) }
67
+ if let hit = files.string(forKey: key) { return hit }
68
+ // Tek seferlik geriye dönük migrate: RN/önceki sürüm UserDefaults'ta bıraktı.
69
+ guard let legacy = defaults.string(forKey: key) else { return nil }
70
+ files.set(legacy, forKey: key)
71
+ defaults.removeObject(forKey: key)
72
+ return legacy
73
+ }
74
+
75
+ public func set(_ value: String, forKey key: String) {
76
+ if fileKeys.contains(key) {
77
+ files.set(value, forKey: key)
78
+ } else {
79
+ defaults.set(value, forKey: key)
80
+ }
81
+ }
82
+
83
+ public func removeObject(forKey key: String) {
84
+ if fileKeys.contains(key) {
85
+ files.removeObject(forKey: key)
86
+ } else {
87
+ defaults.removeObject(forKey: key)
88
+ }
89
+ }
90
+ }
@@ -4,6 +4,18 @@ import Foundation
4
4
  import UIKit
5
5
  #endif
6
6
 
7
+ /// The app's own version (`CFBundleShortVersionString`) for every event's
8
+ /// top-level `appVersion`. Resolved by the SDK, never taken from the app: the
9
+ /// React Native 0.x layer filled it this way, and 1.0 relying on the config
10
+ /// instead left every event unversioned (2026-09-19). Nil when the bundle has
11
+ /// no version.
12
+ public func readAppVersion() -> String? {
13
+ guard let short = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
14
+ !short.isEmpty
15
+ else { return nil }
16
+ return short
17
+ }
18
+
7
19
  /// Device metadata for Meta CAPI `extinfo`.
8
20
  ///
9
21
  /// Best-effort per field: a field is **omitted**, never defaulted, when its
@@ -13,11 +13,14 @@ public struct Enrichment: Sendable, Equatable {
13
13
  public var vendorId: String?
14
14
  public var appInstanceId: String?
15
15
  public var adservicesToken: String?
16
+ /// B2: reklam takibi sınırlama bayrağı — düşürülüyordu, artık olaya işlenir.
17
+ public var limitAdTracking: Bool
16
18
 
17
19
  public init(
18
20
  idfa: String? = nil, vendorId: String? = nil,
19
21
  appInstanceId: String? = nil, adservicesToken: String? = nil,
20
- attestation: AttestationResult? = nil, attestationChallenge: String? = nil
22
+ attestation: AttestationResult? = nil, attestationChallenge: String? = nil,
23
+ limitAdTracking: Bool = false
21
24
  ) {
22
25
  self.attestation = attestation
23
26
  self.attestationChallenge = attestationChallenge
@@ -25,6 +28,7 @@ public struct Enrichment: Sendable, Equatable {
25
28
  self.vendorId = vendorId
26
29
  self.appInstanceId = appInstanceId
27
30
  self.adservicesToken = adservicesToken
31
+ self.limitAdTracking = limitAdTracking
28
32
  }
29
33
  }
30
34
 
@@ -34,6 +38,9 @@ public struct Enrichment: Sendable, Equatable {
34
38
  public struct EnrichmentSources: Sendable {
35
39
  public var searchAdsToken: @Sendable () async -> String?
36
40
  public var advertisingId: @Sendable () -> (idfa: String?, vendorId: String?)
41
+ /// B2: ATT sınırlama bayrağı. Ayrı kapanış — advertisingId demetini
42
+ /// değiştirmek mevcut sahte kaynakları kırardı.
43
+ public var limitAdTracking: @Sendable () -> Bool
37
44
  public var appInstanceId: @Sendable () async -> String?
38
45
  /// Returns the attested challenge and its result, or nil. Every failure path
39
46
  /// — unsupported device, challenge fetch, attest — yields nil, because an
@@ -46,12 +53,14 @@ public struct EnrichmentSources: Sendable {
46
53
  appInstanceId: @escaping @Sendable () async -> String?,
47
54
  attestation: @escaping @Sendable () async -> (challenge: String, result: AttestationResult)? = {
48
55
  nil
49
- }
56
+ },
57
+ limitAdTracking: @escaping @Sendable () -> Bool = { false }
50
58
  ) {
51
59
  self.searchAdsToken = searchAdsToken
52
60
  self.advertisingId = advertisingId
53
61
  self.appInstanceId = appInstanceId
54
62
  self.attestation = attestation
63
+ self.limitAdTracking = limitAdTracking
55
64
  }
56
65
 
57
66
  /// Production wiring. `AdvertisingIdentity.advertisingId` already returns nil
@@ -68,7 +77,8 @@ public struct EnrichmentSources: Sendable {
68
77
  return (idfa: identity.advertisingId, vendorId: identity.vendorId)
69
78
  },
70
79
  appInstanceId: { await appInstanceIdProvider?() },
71
- attestation: { await attestation?() })
80
+ attestation: { await attestation?() },
81
+ limitAdTracking: { AdvertisingIdentity().limitAdTracking })
72
82
  }
73
83
  }
74
84
 
@@ -85,7 +95,9 @@ public func collectEnrichment(
85
95
  ) async -> Enrichment {
86
96
  // Synchronous reads; no reason to race them.
87
97
  let ids = sources.advertisingId()
88
- var result = Enrichment(idfa: ids.idfa, vendorId: ids.vendorId)
98
+ var result = Enrichment(
99
+ idfa: ids.idfa, vendorId: ids.vendorId,
100
+ limitAdTracking: sources.limitAdTracking())
89
101
 
90
102
  await withTaskGroup(of: (String, String?).self) { group in
91
103
  group.addTask { ("adservices", await sources.searchAdsToken()) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@advenue/react-native",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Advenue MMP React Native SDK — attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,12 @@
30
30
  "publishConfig": {
31
31
  "access": "public"
32
32
  },
33
+ "scripts": {
34
+ "build": "node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs && tsup && node scripts/check-dist.mjs",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run --minWorkers=1 --maxWorkers=2 && node --test scripts/vendor-natives.test.mjs && node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs",
37
+ "vendor": "node scripts/vendor-natives.mjs"
38
+ },
33
39
  "dependencies": {},
34
40
  "peerDependencies": {
35
41
  "@expo/config-plugins": ">=8",
@@ -50,11 +56,5 @@
50
56
  "typescript": "^5.7.2",
51
57
  "vitest": "^2.1.8"
52
58
  },
53
- "react-native": "./dist/index.cjs",
54
- "scripts": {
55
- "build": "node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs && tsup && node scripts/check-dist.mjs",
56
- "typecheck": "tsc --noEmit",
57
- "test": "vitest run --minWorkers=1 --maxWorkers=2 && node --test scripts/vendor-natives.test.mjs && node scripts/vendor-natives.mjs && node scripts/check-vendored-swift.mjs",
58
- "vendor": "node scripts/vendor-natives.mjs"
59
- }
60
- }
59
+ "react-native": "./dist/index.cjs"
60
+ }
package/src/index.ts CHANGED
@@ -27,7 +27,15 @@ export type {
27
27
  } from './native-types';
28
28
 
29
29
  /** Options this package used to act on and the native SDKs now decide. */
30
- const IGNORED_OPTIONS = ['mmkv', 'secureStore', 'sdkConfigFetcher', 'tcfDataCollection'] as const;
30
+ // appVersion joins them: the native SDK resolves it from the package/bundle
31
+ // (as the 0.x JS layer did), so a config value would only shadow the real one.
32
+ const IGNORED_OPTIONS = [
33
+ 'mmkv',
34
+ 'secureStore',
35
+ 'sdkConfigFetcher',
36
+ 'tcfDataCollection',
37
+ 'appVersion',
38
+ ] as const;
31
39
 
32
40
  export interface RNAdvenueConfig extends AdvenueConfig {
33
41
  /** Meta app id, for the Meta install referrer on Android. */
@@ -131,7 +139,6 @@ export const Advenue = {
131
139
  call('bridgeInitialize', {
132
140
  apiKey: config.apiKey,
133
141
  endpoint: config.endpoint,
134
- appVersion: config.appVersion,
135
142
  requireConsent: config.requireConsent ?? false,
136
143
  batchSize: config.batchSize,
137
144
  flushIntervalMs: config.flushIntervalMs,
@@ -19,7 +19,6 @@ export type TrackingAuthorizationStatus =
19
19
  export interface BridgeInitOptions {
20
20
  apiKey: string;
21
21
  endpoint?: string;
22
- appVersion?: string;
23
22
  requireConsent: boolean;
24
23
  batchSize?: number;
25
24
  flushIntervalMs?: number;
package/src/types.ts CHANGED
@@ -60,6 +60,11 @@ export interface AdvenueConfig {
60
60
  apiKey: string;
61
61
  endpoint?: string;
62
62
  platform?: 'ios' | 'android' | 'web' | string;
63
+ /**
64
+ * @deprecated Ignored: the native SDK resolves the app version from the
65
+ * package/bundle. Passing it reports `config.ignored:appVersion` via
66
+ * `onError`. Removed in the next major.
67
+ */
63
68
  appVersion?: string;
64
69
  osVersion?: string;
65
70
  /** Gates every event on `setTrackingConsent(true)`. */