Skip to content

Gate reinstaller variant override with privacy-config feature flag#8708

Open
catalinradoiu wants to merge 3 commits into
developfrom
feature/cradoiu/privacy-config-prevent-atb-variant-override
Open

Gate reinstaller variant override with privacy-config feature flag#8708
catalinradoiu wants to merge 3 commits into
developfrom
feature/cradoiu/privacy-config-prevent-atb-variant-override

Conversation

@catalinradoiu
Copy link
Copy Markdown
Contributor

@catalinradoiu catalinradoiu commented May 28, 2026

Task/Issue URL: https://app.asana.com/1/137249556945/project/1211724162604201/task/1215139098848581

Description

Today the reinstaller (isAppReinstall()) listener unconditionally writes statisticsDataStore.variant = "ru" before ATB init. When a user is both a reinstaller and arrives via an active DDGRA install-referrer campaign, the Play Store referrer chain has already written the campaign suffix into statisticsDataStore.variant; the reinstaller listener then overwrites it, and the campaign suffix is lost from every outgoing ATB string (/exti, /atb.js, search rewrites, pixels). No current impact (no active DDGRA campaign in over a year), but the next campaign would silently under-report attribution because DDG has more reinstallers than new installers.

This PR introduces a new privacy-config-driven feature flag, reinstallerVariantProtection, which exposes a list of "protected" variants via remote settings:

"reinstallerVariantProtection": {
  "state": "enabled",
  "settings": { "variants": ["de", "df"] }
}

ReinstallAtbListener.beforeAtbInit() now checks the current variant against the protected list and skips the variant = REINSTALL_VARIANT write when it matches. If the feature is disabled, the settings are missing/malformed, the protected list is empty, or the current variant is null / not in the list, the listener falls through to today's behaviour (write "ru"). The default (DefaultFeatureValue.FALSE, no settings) preserves the current "reinstaller wins" semantics — important because reinstallers are intentionally excluded from most experiments and the "ru" variant carries that signal.

Changes:

  • New ReinstallerVariantProtectionFeature (@ContributesRemoteFeature, AppScope) with a self() toggle and a ReinstallerVariantProtectionSettings(variants) data class for JSON parsing.
  • ReinstallAtbListener now injects the feature and Moshi, parses the protected variants from self().getSettings(), and gates the variant write on isEnabled() + list membership.

Steps to test this PR

The end-to-end on-device flow needs three pieces of local-only scaffolding on top of the PR commits:

  1. A privacy-config JSON patch that enables reinstallerVariantProtection in your local privacy config.
  2. One of two test git patches (test-de-override.patch or test-mq-override.patch) which add diagnostic logs and stub StatisticsSharedPreferences.variant so a campaign-style variant survives the VariantManagerPlugin reset. Each patch fakes a different variant; the two patches verify the two branches of the new code path.
  3. A reinstaller marker (appReinstalled=true in the build-config prefs) so the isAppReinstall() guard fires.

0. One-time setup of the privacy-config patch

Create privacy-config/privacy-config-internal/local-config-patches/reinstaller-variant-protection.json (gitignored) with this content:

[
  {
    "op": "add",
    "path": "/features/reinstallerVariantProtection",
    "value": {
      "state": "enabled",
      "settings": {
        "variants": ["de", "df"]
      }
    }
  }
]

Create or update privacy-config/privacy-config-internal/local.properties (gitignored) with:

config_patches=privacy-config/privacy-config-internal/local-config-patches/reinstaller-variant-protection.json

This is the JSON-Patch override mechanism documented in privacy-config/privacy-config-internal/README.md — it works on internalDebug / internalRelease builds only.

1. Verify the protected variant is preserved (positive case)

Apply the DE override test patch:

test-de-override.patch
diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt
index 565a4b9609..a35e6a2ce6 100644
--- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt
+++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt
@@ -49,7 +49,14 @@ class ReinstallAtbListener @Inject constructor(
 
         if (appBuildConfig.isAppReinstall()) {
             val currentVariant = statisticsDataStore.variant
-            if (currentVariant == null || currentVariant !in getProtectedVariants()) {
+            val protected = getProtectedVariants()
+            val isEnabled = reinstallerVariantProtectionFeature.self().isEnabled()
+            val rawSettings = reinstallerVariantProtectionFeature.self().getSettings()
+            logcat(tag = "ReinstallersCampaignLogTag") {
+                "ReinstallAtbListener: currentVariant='$currentVariant', isEnabled=$isEnabled, " +
+                    "rawSettings='$rawSettings', protectedList=$protected"
+            }
+            if (currentVariant == null || currentVariant !in protected) {
                 statisticsDataStore.variant = REINSTALL_VARIANT
                 logcat(INFO) { "Variant update for returning user" }
             } else {
diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt
index 2056871df2..dfcf101800 100644
--- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt
+++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt
@@ -81,6 +81,8 @@ class AtbInitializer @Inject constructor(
                     } ?: onPluginTimeout(pluginName)
                 }
 
+                logcat(tag = "ReinstallersCampaignLogTag") { "AtbInitializer: pre-ATB listeners complete; resolved variant='${statisticsDataStore.variant}'" }
+
                 // First time we initializeAtb
                 statisticsUpdater.initializeAtb()
             }
diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt
index f9a24f4ff7..d0b089e200 100644
--- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt
+++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt
@@ -80,9 +80,10 @@ class StatisticsRequester @Inject constructor(
                 val atb = Atb(it.version)
                 logcat(INFO) { "$atb" }
                 store.saveAtb(atb)
-                val atbWithVariant = atb.formatWithVariant(variantManager.getVariantKey())
+                val variant = variantManager.getVariantKey()
+                val atbWithVariant = atb.formatWithVariant(variant)
 
-                logcat(INFO) { "Initialized ATB: $atbWithVariant" }
+                logcat(tag = "ReinstallersCampaignLogTag") { "Initialized ATB: atb='${atb.version}', variant='$variant', wire='$atbWithVariant'" }
                 service.exti(atbWithVariant)
             }
             .subscribe(
diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt
index e37e6fea12..f2f4f37264 100644
--- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt
+++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt
@@ -26,7 +26,16 @@ class StatisticsSharedPreferences @Inject constructor(private val context: Conte
     StatisticsDataStore {
 
     override var variant: String?
-        get() = preferences.getString(KEY_VARIANT, null)
+        get() {
+            val stored = preferences.getString(KEY_VARIANT, null)
+            // Simulate a DDGRA "de" campaign install. VariantManagerPlugin clears the
+            // variant to "" when it's not in the privacy config's active variants list,
+            // which on a stock privacy config wipes our pre-seed before ReinstallAtbListener
+            // can read it. Falling back to "de" whenever the stored value is empty/null lets
+            // us verify reinstallerVariantProtection end-to-end without polluting the
+            // experimentalVariants feature.
+            return if (stored.isNullOrEmpty()) "de" else stored
+        }
         set(value) = preferences.edit { putString(KEY_VARIANT, value) }
 
     override var referrerVariant: String?

Clean install the app on the device and filter the logs by ReinstallersCampaignLogTag

Expected logs:

D ReinstallersCampaignLogTag: ReinstallAtbListener: currentVariant='de', isEnabled=true, rawSettings='{"variants":["de","df"]}', protectedList=[de, df]
D ReinstallersCampaignLogTag: AtbInitializer: pre-ATB listeners complete; resolved variant='de'
D ReinstallersCampaignLogTag: Initialized ATB: atb='vXXX-Y', variant='de', wire='vXXX-Yde'

Key signal: the listener skips the overwrite, resolved variant='de', and the wire ATB carries the de suffix.

2. Verify the non-protected path still overwrites (counter-test)

Reset the working tree and apply the MQ override test patch instead:

test-mq-override.patch
diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt
index 565a4b9609..a35e6a2ce6 100644
--- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt
+++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt
@@ -49,7 +49,14 @@ class ReinstallAtbListener @Inject constructor(
 
         if (appBuildConfig.isAppReinstall()) {
             val currentVariant = statisticsDataStore.variant
-            if (currentVariant == null || currentVariant !in getProtectedVariants()) {
+            val protected = getProtectedVariants()
+            val isEnabled = reinstallerVariantProtectionFeature.self().isEnabled()
+            val rawSettings = reinstallerVariantProtectionFeature.self().getSettings()
+            logcat(tag = "ReinstallersCampaignLogTag") {
+                "ReinstallAtbListener: currentVariant='$currentVariant', isEnabled=$isEnabled, " +
+                    "rawSettings='$rawSettings', protectedList=$protected"
+            }
+            if (currentVariant == null || currentVariant !in protected) {
                 statisticsDataStore.variant = REINSTALL_VARIANT
                 logcat(INFO) { "Variant update for returning user" }
             } else {
diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt
index 2056871df2..dfcf101800 100644
--- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt
+++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt
@@ -81,6 +81,8 @@ class AtbInitializer @Inject constructor(
                     } ?: onPluginTimeout(pluginName)
                 }
 
+                logcat(tag = "ReinstallersCampaignLogTag") { "AtbInitializer: pre-ATB listeners complete; resolved variant='${statisticsDataStore.variant}'" }
+
                 // First time we initializeAtb
                 statisticsUpdater.initializeAtb()
             }
diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt
index f9a24f4ff7..d0b089e200 100644
--- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt
+++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt
@@ -80,9 +80,10 @@ class StatisticsRequester @Inject constructor(
                 val atb = Atb(it.version)
                 logcat(INFO) { "$atb" }
                 store.saveAtb(atb)
-                val atbWithVariant = atb.formatWithVariant(variantManager.getVariantKey())
+                val variant = variantManager.getVariantKey()
+                val atbWithVariant = atb.formatWithVariant(variant)
 
-                logcat(INFO) { "Initialized ATB: $atbWithVariant" }
+                logcat(tag = "ReinstallersCampaignLogTag") { "Initialized ATB: atb='${atb.version}', variant='$variant', wire='$atbWithVariant'" }
                 service.exti(atbWithVariant)
             }
             .subscribe(
diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt
index e37e6fea12..06ea9de9b1 100644
--- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt
+++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/store/StatisticsSharedPreferences.kt
@@ -26,7 +26,16 @@ class StatisticsSharedPreferences @Inject constructor(private val context: Conte
     StatisticsDataStore {
 
     override var variant: String?
-        get() = preferences.getString(KEY_VARIANT, null)
+        get() {
+            val stored = preferences.getString(KEY_VARIANT, null)
+            // Simulate a DDGRA "de" campaign install. VariantManagerPlugin clears the
+            // variant to "" when it's not in the privacy config's active variants list,
+            // which on a stock privacy config wipes our pre-seed before ReinstallAtbListener
+            // can read it. Falling back to "de" whenever the stored value is empty/null lets
+            // us verify reinstallerVariantProtection end-to-end without polluting the
+            // experimentalVariants feature.
+            return if (stored.isNullOrEmpty()) "mq" else stored
+        }
         set(value) = preferences.edit { putString(KEY_VARIANT, value) }
 
     override var referrerVariant: String?

Clean install the app on the device and filter the logs by ReinstallersCampaignLogTag

Expected logs:

D ReinstallersCampaignLogTag: ReinstallAtbListener: currentVariant='mq', isEnabled=true, rawSettings='{"variants":["de","df"]}', protectedList=[de, df]
D ReinstallersCampaignLogTag: AtbInitializer: pre-ATB listeners complete; resolved variant='ru'
D ReinstallersCampaignLogTag: Initialized ATB: atb='vXXX-Y', variant='ru', wire='vXXX-Yru'

Key signal: same patched feature config, but because mq is not in [de, df] the listener overwrites to ru — today's behaviour preserved when the variant isn't protected.

UI changes

Before After
No UI changes No UI changes

Add ReinstallerVariantProtectionFeature so the reinstaller's "ru" variant
no longer overwrites a campaign suffix (e.g. DDGRA) that has already been
written to statisticsDataStore.variant by the Play Store referrer chain.
The feature exposes a list of protected variants via remote settings; when
the current variant matches one of them, the reinstaller write is skipped
and the campaign suffix is preserved on the ATB. Default behaviour
(feature disabled or empty list) keeps today's "reinstaller wins"
semantics intact.

Task: https://app.asana.com/1/137249556945/project/1211724162604201/task/1215139098848581
@catalinradoiu catalinradoiu changed the title Gate reinstaller variant override with privacy-config feature flag [WIP] Gate reinstaller variant override with privacy-config feature flag May 28, 2026
The new ReinstallerVariantProtectionFeature is the first @ContributesRemoteFeature in this module, so the missing
`ksp project(':anvil-ksp')` declaration only surfaced now — without it the
custom KSP processor never runs and Dagger / Metro both fail with
MissingBinding for the feature interface.
@catalinradoiu catalinradoiu changed the title [WIP] Gate reinstaller variant override with privacy-config feature flag Gate reinstaller variant override with privacy-config feature flag May 29, 2026
Copy link
Copy Markdown
Contributor

@marcosholgado marcosholgado left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, left a minor comment

featureName = "reinstallerVariantProtection",
)
interface ReinstallerVariantProtectionFeature {
@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine but if you use a sub-feature you get more nice things like rollouts so consider doing a sub-feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants