Skip to content

Commit

Permalink
Traffic Quality Feature Flag (#5328)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1174433894299346/1208848089144698/f

### Description
Implement the header request that sends one enabled feature

### Steps to test this PR
See Test scenarios section in the description of the Asana task ->
https://app.asana.com/0/1174433894299346/1208865134645715/f
  • Loading branch information
malmstein authored Dec 2, 2024
1 parent 23b34a0 commit facdf85
Show file tree
Hide file tree
Showing 10 changed files with 518 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ import com.duckduckgo.app.autocomplete.api.AutoCompleteApi
import com.duckduckgo.app.autocomplete.api.AutoCompleteScorer
import com.duckduckgo.app.autocomplete.api.AutoCompleteService
import com.duckduckgo.app.autocomplete.impl.AutoCompleteRepository
import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.TEST_VALUE
import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER
import com.duckduckgo.app.browser.LongPressHandler.RequiredAction
import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.DownloadFile
import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.OpenInNewTab
Expand Down Expand Up @@ -113,6 +111,7 @@ import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP
import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender
import com.duckduckgo.app.browser.remotemessage.RemoteMessagingModel
import com.duckduckgo.app.browser.session.WebViewSessionStorage
import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER
import com.duckduckgo.app.browser.viewstate.BrowserViewState
import com.duckduckgo.app.browser.viewstate.CtaViewState
import com.duckduckgo.app.browser.viewstate.FindInPageViewState
Expand Down Expand Up @@ -3335,7 +3334,7 @@ class BrowserTabViewModelTest {
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())

val command = commandCaptor.lastValue as Navigate
assertEquals(TEST_VALUE, command.headers[X_DUCKDUCKGO_ANDROID_HEADER])
assertEquals("TEST_VALUE", command.headers[X_DUCKDUCKGO_ANDROID_HEADER])
}

@Test
Expand Down Expand Up @@ -5867,7 +5866,7 @@ class BrowserTabViewModelTest {
}

private fun givenCustomHeadersProviderReturnsAndroidFeaturesHeader() {
fakeCustomHeadersPlugin.headers = mapOf(X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE)
fakeCustomHeadersPlugin.headers = mapOf(X_DUCKDUCKGO_ANDROID_HEADER to "TEST_VALUE")
}

private suspend fun givenFireButtonPulsing() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList
import com.duckduckgo.app.browser.pageloadpixel.PageLoadedHandler
import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedHandler
import com.duckduckgo.app.browser.print.PrintInjector
import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin
import com.duckduckgo.app.browser.uriloaded.UriLoadedManager
import com.duckduckgo.app.global.model.Site
import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.autoconsent.api.Autoconsent
import com.duckduckgo.autofill.api.BrowserAutofill
Expand Down Expand Up @@ -148,7 +150,8 @@ class BrowserWebViewClientTest {
private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock()
private val openInNewTabFlow: MutableSharedFlow<OpenDuckPlayerInNewTab> = MutableSharedFlow()
private val mockUriLoadedManager: UriLoadedManager = mock()
private val mockAndroidFeaturesHeaderPlugin = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mock())
private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = mock()
private val mockAndroidFeaturesHeaderPlugin = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature, mock())

@UiThreadTest
@Before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import com.duckduckgo.app.browser.navigation.safeCopyBackForwardList
import com.duckduckgo.app.browser.pageloadpixel.PageLoadedHandler
import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedHandler
import com.duckduckgo.app.browser.print.PrintInjector
import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin
import com.duckduckgo.app.browser.uriloaded.UriLoadedManager
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.statistics.pixels.Pixel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
* limitations under the License.
*/

package com.duckduckgo.app.browser
package com.duckduckgo.app.browser.trafficquality

import com.duckduckgo.app.browser.DuckDuckGoUrlDetector
import com.duckduckgo.app.browser.trafficquality.remote.AndroidFeaturesHeaderProvider
import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider.CustomHeadersPlugin
import com.duckduckgo.di.scopes.AppScope
Expand All @@ -26,22 +28,22 @@ import javax.inject.Inject
class AndroidFeaturesHeaderPlugin @Inject constructor(
private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector,
private val androidBrowserConfigFeature: AndroidBrowserConfigFeature,
private val androidFeaturesHeaderProvider: AndroidFeaturesHeaderProvider,
) : CustomHeadersPlugin {

override fun getHeaders(url: String): Map<String, String> {
if (androidBrowserConfigFeature.self().isEnabled() &&
androidBrowserConfigFeature.featuresRequestHeader().isEnabled() &&
duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url)
) {
return mapOf(
X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE,
)
androidFeaturesHeaderProvider.provide()?.let { headerValue ->
return mapOf(X_DUCKDUCKGO_ANDROID_HEADER to headerValue)
}
}
return emptyMap()
}

companion object {
internal const val X_DUCKDUCKGO_ANDROID_HEADER = "x-duckduckgo-android"
internal const val TEST_VALUE = "test"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ interface QualityAppVersionProvider {
}

@ContributesBinding(AppScope::class)
class RealQualityAppVersionProvider @Inject constructor(private val appBuildConfig: AppBuildConfig) : QualityAppVersionProvider {
class RealQualityAppVersionProvider @Inject constructor(
private val appBuildConfig: AppBuildConfig,
) : QualityAppVersionProvider {
override fun provide(): String {
val appBuildDateMillis = appBuildConfig.buildDateTimeMillis

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.browser.trafficquality.remote

import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.autoconsent.api.Autoconsent
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection
import com.duckduckgo.networkprotection.api.NetworkProtectionState
import com.duckduckgo.privacy.config.api.Gpc
import com.squareup.anvil.annotations.ContributesBinding
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import javax.inject.Inject
import kotlinx.coroutines.runBlocking

interface AndroidFeaturesHeaderProvider {
fun provide(): String?
}

@ContributesBinding(AppScope::class)
class RealAndroidFeaturesHeaderProvider @Inject constructor(
private val appBuildConfig: AppBuildConfig,
private val featuresRequestHeaderStore: FeaturesRequestHeaderStore,
private val autoconsent: Autoconsent,
private val gpc: Gpc,
private val appTrackingProtection: AppTrackingProtection,
private val networkProtectionState: NetworkProtectionState,
) : AndroidFeaturesHeaderProvider {

override fun provide(): String? {
val config = featuresRequestHeaderStore.getConfig()
val versionConfig = config.find { it.appVersion == appBuildConfig.versionCode }
return if (versionConfig != null && shouldLogValue(versionConfig)) {
logFeature(versionConfig)
} else {
null
}
}

private fun shouldLogValue(versionConfig: TrafficQualityAppVersion): Boolean {
val appBuildDateMillis = appBuildConfig.buildDateTimeMillis
if (appBuildDateMillis == 0L) {
return false
}

val appBuildDate = LocalDateTime.ofEpochSecond(appBuildDateMillis / 1000, 0, ZoneOffset.UTC)
val now = LocalDateTime.now(ZoneOffset.UTC)

val daysSinceBuild = ChronoUnit.DAYS.between(appBuildDate, now)
val daysUntilLoggingStarts = versionConfig.daysUntilLoggingStarts
val daysForAppVersionLogging = versionConfig.daysUntilLoggingStarts + versionConfig.daysLogging

return daysSinceBuild in daysUntilLoggingStarts..daysForAppVersionLogging
}

private fun logFeature(versionConfig: TrafficQualityAppVersion): String? {
val listOfFeatures = mutableListOf<String>()
if (versionConfig.featuresLogged.cpm) {
listOfFeatures.add(CPM_HEADER)
}
if (versionConfig.featuresLogged.gpc) {
listOfFeatures.add(GPC_HEADER)
}

if (versionConfig.featuresLogged.appTP) {
listOfFeatures.add(APP_TP_HEADER)
}

if (versionConfig.featuresLogged.netP) {
listOfFeatures.add(NET_P_HEADER)
}

return if (listOfFeatures.isEmpty()) {
null
} else {
val randomIndex = (0..<listOfFeatures.size).random()
listOfFeatures[randomIndex].plus("=").plus(mapFeature(listOfFeatures[randomIndex]))
}
}

private fun mapFeature(feature: String): String? {
return runBlocking {
when (feature) {
CPM_HEADER -> {
autoconsent.isAutoconsentEnabled().toString()
}

GPC_HEADER -> {
gpc.isEnabled().toString()
}

APP_TP_HEADER -> {
appTrackingProtection.isEnabled().toString()
}

NET_P_HEADER -> {
networkProtectionState.isEnabled().toString()
}

else -> null
}
}
}

companion object {
private const val CPM_HEADER = "cpm_enabled"
private const val GPC_HEADER = "gpc_enabled"
private const val APP_TP_HEADER = "atp_enabled"
private const val NET_P_HEADER = "vpn_enabled"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.browser.trafficquality.remote

import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import javax.inject.Inject

interface FeaturesRequestHeaderStore {
fun getConfig(): List<TrafficQualityAppVersion>
}

data class TrafficQualitySettingsJson(
val versions: List<TrafficQualityAppVersion>,
)

data class TrafficQualityAppVersion(
val appVersion: Int,
val daysUntilLoggingStarts: Int,
val daysLogging: Int,
val featuresLogged: TrafficQualityAppVersionFeatures,
)

data class TrafficQualityAppVersionFeatures(
val gpc: Boolean,
val cpm: Boolean,
val appTP: Boolean,
val netP: Boolean,
)

@ContributesBinding(AppScope::class)
class FeaturesRequestHeaderSettingStore @Inject constructor(
private val androidBrowserConfigFeature: AndroidBrowserConfigFeature,
private val moshi: Moshi,
) : FeaturesRequestHeaderStore {

private val jsonAdapter: JsonAdapter<TrafficQualitySettingsJson> by lazy {
moshi.adapter(TrafficQualitySettingsJson::class.java)
}

override fun getConfig(): List<TrafficQualityAppVersion> {
val config = androidBrowserConfigFeature.featuresRequestHeader().getSettings()?.let {
runCatching {
val configJson = jsonAdapter.fromJson(it)
configJson?.versions
}.getOrDefault(emptyList())
} ?: emptyList()
return config
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.duckduckgo.app.browser

import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.TEST_VALUE
import com.duckduckgo.app.browser.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER
import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin
import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER
import com.duckduckgo.app.browser.trafficquality.remote.AndroidFeaturesHeaderProvider
import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature
import com.duckduckgo.feature.toggles.api.Toggle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
Expand All @@ -21,22 +23,39 @@ class AndroidFeaturesHeaderPluginTest {
private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = mock()
private val mockEnabledToggle: Toggle = mock { on { it.isEnabled() } doReturn true }
private val mockDisabledToggle: Toggle = mock { on { it.isEnabled() } doReturn false }
private val mockAndroidFeaturesHeaderProvider: AndroidFeaturesHeaderProvider = mock()

private val SAMPLE_HEADER = "header"

@Before
fun setup() {
testee = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature)
testee = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature, mockAndroidFeaturesHeaderProvider)
}

@Test
fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledAndHeaderProvidedThenReturnCorrectHeader() = runTest {
val url = "duckduckgo_search_url"
whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true)
whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle)
whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle)
whenever(mockAndroidFeaturesHeaderProvider.provide()).thenReturn(SAMPLE_HEADER)

val headers = testee.getHeaders(url)

assertEquals(SAMPLE_HEADER, headers[X_DUCKDUCKGO_ANDROID_HEADER])
}

@Test
fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledThenReturnCorrectHeader() {
fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledAndHeaderNotProvidedThenReturnEmptyMap() = runTest {
val url = "duckduckgo_search_url"
whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true)
whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle)
whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle)
whenever(mockAndroidFeaturesHeaderProvider.provide()).thenReturn(null)

val headers = testee.getHeaders(url)

assertEquals(TEST_VALUE, headers[X_DUCKDUCKGO_ANDROID_HEADER])
assertTrue(headers.isEmpty())
}

@Test
Expand Down
Loading

0 comments on commit facdf85

Please sign in to comment.