Skip to content

Commit faec1c2

Browse files
Set up support for AppTP blocklist experiments (#5864)
Task/Issue URL: https://app.asana.com/0/72649045549333/1209746219895489/f ### Description Adds support for AppTP TDS A/B experiments via privacy configuration. ### Steps to test this PR Automated tests: - run the `AppTPBlockListInterceptorApiPluginTest.kt` file Manual check: 1. Using the remote debugger, add in the `features` for `appTrackerProtection` in `android-override.json`: ``` "atpTdsExperiment001": { "state": "enabled", "minSupportedVersion": 52240000, "rollout": { "steps": [ { "percent": 100 } ] }, "settings": { "controlUrl": "experiment/android-tds-a0.json", "treatmentUrl": "experiment/android-tds-a1.json" }, "cohorts": [ { "name": "control", "weight": 1 }, { "name": "treatment", "weight": 0 } ] }, ``` 2. Reload the config 3. in AppTP dev settings, force load the blocklist then leave the settings page 4. in logcat, filter to `AppTP`, you should see the URL being rewritten (similar to screenshot) 5. copy the entry to `atpTdsExperiment002`, change control value to 0 and treatment to 1. Change state to `disabled` for `atpTdsExperiment001`. Verify you're seeing the URL rewritten as treatment & experiment as 002 in the logs (after repeating steps 2-4) ![image](https://github.com/user-attachments/assets/cce1ee3f-7b77-490d-9759-862536faf42f) --------- Co-authored-by: Ana Capatina <anikiki@gmail.com>
1 parent d6485e5 commit faec1c2

File tree

9 files changed

+637
-2
lines changed

9 files changed

+637
-2
lines changed

app-tracking-protection/vpn-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ dependencies {
138138
testImplementation project(':common-test')
139139
testImplementation project(':vpn-api-test')
140140
testImplementation project(':feature-toggles-test')
141+
testImplementation project(':data-store-test')
141142

142143
coreLibraryDesugaring Android.tools.desugarJdkLibs
143144
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.mobile.android.vpn.blocklist
18+
19+
import com.duckduckgo.app.global.api.ApiInterceptorPlugin
20+
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin
21+
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory
24+
import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection
25+
import com.duckduckgo.mobile.android.vpn.feature.AppTpRemoteFeatures.Cohorts.CONTROL
26+
import com.duckduckgo.mobile.android.vpn.feature.AppTpRemoteFeatures.Cohorts.TREATMENT
27+
import com.duckduckgo.mobile.android.vpn.feature.activeAppTpTdsFlag
28+
import com.duckduckgo.mobile.android.vpn.pixels.DeviceShieldPixelNames
29+
import com.duckduckgo.mobile.android.vpn.pixels.DeviceShieldPixels
30+
import com.squareup.anvil.annotations.ContributesMultibinding
31+
import com.squareup.moshi.JsonAdapter
32+
import com.squareup.moshi.Moshi
33+
import com.squareup.moshi.Types
34+
import javax.inject.Inject
35+
import kotlinx.coroutines.runBlocking
36+
import logcat.logcat
37+
import okhttp3.Interceptor
38+
import okhttp3.Interceptor.Chain
39+
import okhttp3.Response
40+
import retrofit2.Invocation
41+
42+
@ContributesMultibinding(
43+
scope = AppScope::class,
44+
boundType = ApiInterceptorPlugin::class,
45+
)
46+
class AppTPBlockListInterceptorApiPlugin @Inject constructor(
47+
private val inventory: FeatureTogglesInventory,
48+
private val moshi: Moshi,
49+
private val pixel: DeviceShieldPixels,
50+
private val appTrackingProtection: AppTrackingProtection,
51+
) : Interceptor, ApiInterceptorPlugin {
52+
53+
private val jsonAdapter: JsonAdapter<Map<String, String>> by lazy {
54+
moshi.adapter(Types.newParameterizedType(Map::class.java, String::class.java, String::class.java))
55+
}
56+
override fun intercept(chain: Chain): Response {
57+
val request = chain.request().newBuilder()
58+
59+
val tdsRequired = chain.request().tag(Invocation::class.java)
60+
?.method()
61+
?.isAnnotationPresent(AppTPTdsRequired::class.java) == true
62+
63+
val shouldInterceptRequest = tdsRequired && runBlocking {
64+
appTrackingProtection.isEnabled()
65+
}
66+
67+
return if (shouldInterceptRequest) {
68+
logcat { "[AppTP]: Intercepted AppTP TDS Request: ${chain.request()}" }
69+
val activeExperiment = runBlocking {
70+
inventory.activeAppTpTdsFlag()
71+
}
72+
logcat { "[AppTP]: Active experiment: ${activeExperiment?.featureName()}" }
73+
logcat { "[AppTP]: Cohort: ${activeExperiment?.getCohort()}" }
74+
75+
activeExperiment?.let {
76+
val config = activeExperiment.getSettings()?.let {
77+
runCatching {
78+
jsonAdapter.fromJson(it)
79+
}.getOrDefault(emptyMap())
80+
} ?: emptyMap()
81+
val path = when {
82+
activeExperiment.isEnabled(TREATMENT) -> config["treatmentUrl"]
83+
activeExperiment.isEnabled(CONTROL) -> config["controlUrl"]
84+
else -> config["nextUrl"]
85+
} ?: return chain.proceed(request.build())
86+
val newURL = "$APPTP_TDS_BASE_URL$path"
87+
logcat { "[AppTP]: Rewrote TDS request URL to $newURL" }
88+
chain.proceed(request.url(newURL).build()).also { response ->
89+
if (!response.isSuccessful) {
90+
pixel.appTPBlocklistExperimentDownloadFailure(
91+
response.code,
92+
activeExperiment.featureName().name,
93+
activeExperiment.getCohort()?.name.toString(),
94+
)
95+
}
96+
}
97+
} ?: chain.proceed(request.build())
98+
} else {
99+
chain.proceed(request.build())
100+
}
101+
}
102+
103+
override fun getInterceptor(): Interceptor {
104+
return this
105+
}
106+
}
107+
108+
@ContributesMultibinding(
109+
scope = AppScope::class,
110+
boundType = PixelParamRemovalPlugin::class,
111+
)
112+
object MetricPixelRemovalInterceptor : PixelParamRemovalPlugin {
113+
override fun names(): List<Pair<String, Set<PixelParameter>>> {
114+
return listOf(
115+
DeviceShieldPixelNames.ATP_TDS_EXPERIMENT_DOWNLOAD_FAILED.pixelName to PixelParameter.removeAll(),
116+
)
117+
}
118+
}

app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/blocklist/AppTrackerListService.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ import retrofit2.http.GET
2424

2525
@ContributesServiceApi(AppScope::class)
2626
interface AppTrackerListService {
27-
@GET("https://staticcdn.duckduckgo.com/trackerblocking/appTP/2.1/android-tds.json")
27+
@GET("$APPTP_TDS_BASE_URL$APPTP_TDS_PATH")
28+
@AppTPTdsRequired
2829
fun appTrackerBlocklist(): Call<JsonAppBlockingList>
2930
}
31+
32+
/**
33+
* This annotation is used in interceptors to be able to intercept the annotated service calls
34+
*/
35+
@Target(AnnotationTarget.FUNCTION)
36+
@Retention(AnnotationRetention.RUNTIME)
37+
annotation class AppTPTdsRequired
38+
39+
const val APPTP_TDS_BASE_URL = "https://staticcdn.duckduckgo.com/trackerblocking/appTP/"
40+
const val APPTP_TDS_PATH = "2.1/android-tds.json"

app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/cohort/CohortPixelInterceptor.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.duckduckgo.mobile.android.vpn.cohort
1919
import androidx.annotation.VisibleForTesting
2020
import com.duckduckgo.common.utils.plugins.pixel.PixelInterceptorPlugin
2121
import com.duckduckgo.di.scopes.AppScope
22+
import com.duckduckgo.mobile.android.vpn.pixels.DeviceShieldPixelNames.ATP_TDS_EXPERIMENT_DOWNLOAD_FAILED
2223
import com.squareup.anvil.annotations.ContributesMultibinding
2324
import javax.inject.Inject
2425
import logcat.logcat
@@ -77,6 +78,7 @@ class CohortPixelInterceptor @Inject constructor(
7778
"m_atp_ev_cpu_usage_above_",
7879
"m_atp_unprotected_apps_bucket_",
7980
"m_atp_breakage_report",
81+
ATP_TDS_EXPERIMENT_DOWNLOAD_FAILED.pixelName,
8082
)
8183
}
8284
}

app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/feature/AppTpRemoteFeatures.kt

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,22 @@ import android.content.SharedPreferences.OnSharedPreferenceChangeListener
2121
import androidx.core.content.edit
2222
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
2323
import com.duckduckgo.app.di.AppCoroutineScope
24+
import com.duckduckgo.common.utils.DefaultDispatcherProvider
2425
import com.duckduckgo.common.utils.DispatcherProvider
2526
import com.duckduckgo.data.store.api.SharedPreferencesProvider
2627
import com.duckduckgo.di.scopes.AppScope
28+
import com.duckduckgo.feature.toggles.api.ConversionWindow
29+
import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory
30+
import com.duckduckgo.feature.toggles.api.MetricsPixel
31+
import com.duckduckgo.feature.toggles.api.MetricsPixelPlugin
2732
import com.duckduckgo.feature.toggles.api.RemoteFeatureStoreNamed
2833
import com.duckduckgo.feature.toggles.api.Toggle
2934
import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue
3035
import com.duckduckgo.feature.toggles.api.Toggle.State
36+
import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName
3137
import com.duckduckgo.mobile.android.vpn.feature.settings.ExceptionListsSettingStore
3238
import com.squareup.anvil.annotations.ContributesBinding
39+
import com.squareup.anvil.annotations.ContributesMultibinding
3340
import com.squareup.moshi.JsonAdapter
3441
import com.squareup.moshi.Moshi
3542
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
@@ -55,6 +62,45 @@ interface AppTpRemoteFeatures {
5562

5663
@DefaultValue(true)
5764
fun setSearchDomains(): Toggle // kill switch
65+
66+
@DefaultValue(false)
67+
fun atpTdsExperiment001(): Toggle
68+
69+
@DefaultValue(false)
70+
fun atpTdsExperiment002(): Toggle
71+
72+
@DefaultValue(false)
73+
fun atpTdsExperiment003(): Toggle
74+
75+
@DefaultValue(false)
76+
fun atpTdsExperiment004(): Toggle
77+
78+
@DefaultValue(false)
79+
fun atpTdsExperiment005(): Toggle
80+
81+
@DefaultValue(false)
82+
fun atpTdsExperiment006(): Toggle
83+
84+
@DefaultValue(false)
85+
fun atpTdsExperiment007(): Toggle
86+
87+
@DefaultValue(false)
88+
fun atpTdsExperiment008(): Toggle
89+
90+
@DefaultValue(false)
91+
fun atpTdsExperiment009(): Toggle
92+
93+
@DefaultValue(false)
94+
fun atpTdsExperiment010(): Toggle
95+
96+
enum class Cohorts(override val cohortName: String) : CohortName {
97+
CONTROL("control"),
98+
TREATMENT("treatment"),
99+
}
100+
101+
companion object {
102+
internal const val EXPERIMENT_PREFIX = "atpTds"
103+
}
58104
}
59105

60106
@ContributesBinding(AppScope::class)
@@ -134,3 +180,91 @@ class AppTpRemoteFeaturesStore @Inject constructor(
134180
private const val PREFS_FILENAME = "com.duckduckgo.vpn.atp.remote.features.v1"
135181
}
136182
}
183+
184+
@ContributesMultibinding(AppScope::class)
185+
class AppTpTDSPixelsPlugin @Inject constructor(private val inventory: FeatureTogglesInventory) : MetricsPixelPlugin {
186+
private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()
187+
188+
override suspend fun getMetrics(): List<MetricsPixel> = withContext(dispatchers.io()) {
189+
val activeToggle = inventory.activeAppTpTdsFlag() ?: return@withContext emptyList()
190+
191+
return@withContext listOf(
192+
MetricsPixel(
193+
metric = "selectedRemoveTrackingProtectionFeature",
194+
value = "1",
195+
toggle = activeToggle,
196+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
197+
),
198+
MetricsPixel(
199+
metric = "selectedDisableProtection",
200+
value = "1",
201+
toggle = activeToggle,
202+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
203+
),
204+
MetricsPixel(
205+
metric = "selectedDisableAppProtection",
206+
value = "1",
207+
toggle = activeToggle,
208+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
209+
),
210+
MetricsPixel(
211+
metric = "protectionDisabledAppFromDetail",
212+
value = "1",
213+
toggle = activeToggle,
214+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
215+
),
216+
MetricsPixel(
217+
metric = "protectionDisabledAppFromAll",
218+
value = "1",
219+
toggle = activeToggle,
220+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
221+
),
222+
MetricsPixel(
223+
metric = "disabledProtectionForApp",
224+
value = "1",
225+
toggle = activeToggle,
226+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
227+
),
228+
MetricsPixel(
229+
metric = "didFailToDownloadTDS",
230+
value = "1",
231+
toggle = activeToggle,
232+
conversionWindow = (0..5).map { ConversionWindow(lowerWindow = 0, upperWindow = it) },
233+
),
234+
)
235+
}
236+
}
237+
238+
internal suspend fun AppTpTDSPixelsPlugin.getSelectedRemoveAppTP(): MetricsPixel? {
239+
return this.getMetrics().firstOrNull { it.metric == "selectedRemoveTrackingProtectionFeature" }
240+
}
241+
242+
internal suspend fun AppTpTDSPixelsPlugin.getSelectedDisableProtection(): MetricsPixel? {
243+
return this.getMetrics().firstOrNull { it.metric == "selectedDisableProtection" }
244+
}
245+
246+
internal suspend fun AppTpTDSPixelsPlugin.getSelectedDisableAppProtection(): MetricsPixel? {
247+
return this.getMetrics().firstOrNull { it.metric == "selectedDisableAppProtection" }
248+
}
249+
250+
internal suspend fun AppTpTDSPixelsPlugin.getProtectionDisabledAppFromDetail(): MetricsPixel? {
251+
return this.getMetrics().firstOrNull { it.metric == "protectionDisabledAppFromDetail" }
252+
}
253+
254+
internal suspend fun AppTpTDSPixelsPlugin.getProtectionDisabledAppFromAll(): MetricsPixel? {
255+
return this.getMetrics().firstOrNull { it.metric == "protectionDisabledAppFromAll" }
256+
}
257+
258+
internal suspend fun AppTpTDSPixelsPlugin.getDisabledProtectionForApp(): MetricsPixel? {
259+
return this.getMetrics().firstOrNull { it.metric == "disabledProtectionForApp" }
260+
}
261+
262+
internal suspend fun AppTpTDSPixelsPlugin.didFailToDownloadTDS(): MetricsPixel? {
263+
return this.getMetrics().firstOrNull { it.metric == "didFailToDownloadTDS" }
264+
}
265+
266+
suspend fun FeatureTogglesInventory.activeAppTpTdsFlag(): Toggle? {
267+
return this.getAllTogglesForParent("appTrackerProtection").firstOrNull {
268+
it.featureName().name.startsWith(AppTpRemoteFeatures.EXPERIMENT_PREFIX) && it.isEnabled()
269+
}
270+
}

app-tracking-protection/vpn-impl/src/main/java/com/duckduckgo/mobile/android/vpn/pixels/DeviceShieldPixelNames.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ enum class DeviceShieldPixelNames(override val pixelName: String, val enqueue: B
202202
ATP_REPORT_UNPROTECTED_APPS_BUCKET("m_atp_unprotected_apps_bucket_%d_c"),
203203
ATP_REPORT_UNPROTECTED_APPS_BUCKET_DAILY("m_atp_unprotected_apps_bucket_%d_d"),
204204

205+
ATP_TDS_EXPERIMENT_DOWNLOAD_FAILED("m_atp_tds_experiment_download_failed"),
206+
205207
ATP_DID_PRESS_APPTP_ENABLED_CTA_BUTTON("m_atp_ev_apptp_enabled_cta_button_press"),
206208

207209
ATP_REPORT_VPN_NETWORK_STACK_CREATE_ERROR("m_atp_ev_apptp_create_network_stack_error_c"),

0 commit comments

Comments
 (0)