From d13de5b7e5a391ea66add4d5d1a207a332492075 Mon Sep 17 00:00:00 2001 From: Ana Capatina Date: Wed, 13 Nov 2024 21:14:10 +0000 Subject: [PATCH] [Implementation]: Android - Send a custom test header on search request (#5266) Task/Issue URL: https://app.asana.com/0/1200581511062568/1208723599553913/f ### Description Added a custom test header. ### Steps to test this PR See **Test scenarios** section in the description of the Asana task -> https://app.asana.com/0/1200581511062568/1208723599553913/f ### NO UI changes --- .../app/browser/BrowserTabViewModelTest.kt | 120 ++++++------------ .../app/browser/BrowserWebViewClientTest.kt | 2 + .../app/browser/AndroidFeaturesHeader.kt | 47 +++++++ .../app/browser/BrowserTabViewModel.kt | 9 +- .../app/browser/BrowserWebViewClient.kt | 14 ++ .../AndroidBrowserConfigFeature.kt | 8 ++ .../AndroidFeaturesHeaderPluginTest.kt | 77 +++++++++++ .../plugins/headers/CustomHeadersProvider.kt | 62 +++++++++ .../impl/features/gpc/GpcHeaderPlugin.kt | 33 +++++ .../impl/features/gpc/GpcHeaderPluginTest.kt | 28 ++++ 10 files changed, 311 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt create mode 100644 app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt create mode 100644 common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt create mode 100644 privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPlugin.kt create mode 100644 privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPluginTest.kt diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 0f0cb0adf66f..2bd56dd64447 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -58,6 +58,8 @@ 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 @@ -190,6 +192,7 @@ import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider import com.duckduckgo.downloads.api.DownloadStateListener import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.api.FileDownloader.PendingFileDownload @@ -206,7 +209,6 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory -import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.history.api.HistoryEntry.VisitedPage @@ -215,14 +217,9 @@ import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking -import com.duckduckgo.privacy.config.api.GpcException -import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.TrackingParameters -import com.duckduckgo.privacy.config.api.UnprotectedTemporary -import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE -import com.duckduckgo.privacy.config.store.features.gpc.GpcRepository import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels @@ -252,7 +249,6 @@ import java.security.cert.X509Certificate import java.security.interfaces.RSAPublicKey import java.time.LocalDateTime import java.util.UUID -import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel @@ -377,12 +373,6 @@ class BrowserTabViewModelTest { private val mockAppLinksHandler: AppLinksHandler = mock() - private val mockFeatureToggle: FeatureToggle = mock() - - private val mockGpcRepository: GpcRepository = mock() - - private val mockUnprotectedTemporary: UnprotectedTemporary = mock() - private val mockAmpLinks: AmpLinks = mock() private val mockTrackingParameters: TrackingParameters = mock() @@ -499,6 +489,7 @@ class BrowserTabViewModelTest { private val protectionTogglePluginPoint = FakePluginPoint(protectionTogglePlugin) private var fakeAndroidConfigBrowserFeature = FakeFeatureToggleFactory.create(AndroidBrowserConfigFeature::class.java) private val mockAutocompleteTabsFeature: AutocompleteTabsFeature = mock() + private val fakeCustomHeadersPlugin = FakeCustomHeadersProvider(emptyMap()) @Before fun before() = runTest { @@ -625,7 +616,6 @@ class BrowserTabViewModelTest { navigationAwareLoginDetector = mockNavigationAwareLoginDetector, userEventsStore = mockUserEventsStore, fileDownloader = mockFileDownloader, - gpc = RealGpc(mockFeatureToggle, mockGpcRepository, mockUnprotectedTemporary, mockUserAllowListRepository), fireproofDialogsEventHandler = fireproofDialogsEventHandler, emailManager = mockEmailManager, appCoroutineScope = TestScope(), @@ -664,6 +654,7 @@ class BrowserTabViewModelTest { highlightsOnboardingExperimentManager = mockHighlightsOnboardingExperimentManager, privacyProtectionTogglePlugin = protectionTogglePluginPoint, showOnAppLaunchOptionHandler = mockShowOnAppLaunchHandler, + customHeadersProvider = fakeCustomHeadersPlugin, ) testee.loadData("abc", null, false, false) @@ -3243,7 +3234,7 @@ class BrowserTabViewModelTest { @Test fun whenUserSubmittedQueryIfGpcIsEnabledAndUrlIsValidThenAddHeaderToUrl() { - givenUrlCanUseGpc() + givenCustomHeadersProviderReturnsGpcHeader() whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") testee.onUserSubmittedQuery("foo") @@ -3254,9 +3245,9 @@ class BrowserTabViewModelTest { } @Test - fun whenUserSubmittedQueryIfGpcIsEnabledAndUrlIsNotValidThenDoNotAddHeaderToUrl() { + fun whenUserSubmittedQueryIfGpcReturnsNoHeaderThenDoNotAddHeaderToUrl() { val url = "foo.com" - givenUrlCannotUseGpc(url) + givenCustomHeadersProviderReturnsNoHeaders() whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn(url) testee.onUserSubmittedQuery("foo") @@ -3267,20 +3258,8 @@ class BrowserTabViewModelTest { } @Test - fun whenUserSubmittedQueryIfGpcIsDisabledThenDoNotAddHeaderToUrl() { - givenGpcIsDisabled() - whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") - - testee.onUserSubmittedQuery("foo") - verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - - val command = commandCaptor.lastValue as Navigate - assertTrue(command.headers.isEmpty()) - } - - @Test - fun whenOnDesktopSiteModeToggledIfGpcIsEnabledAndUrlIsValidThenAddHeaderToUrl() { - givenUrlCanUseGpc() + fun whenOnDesktopSiteModeToggledIfGpcReturnsHeaderThenAddHeaderToUrl() { + givenCustomHeadersProviderReturnsGpcHeader() loadUrl("http://m.example.com") setDesktopBrowsingMode(false) testee.onChangeBrowserModeClicked() @@ -3291,32 +3270,8 @@ class BrowserTabViewModelTest { } @Test - fun whenOnDesktopSiteModeToggledIfGpcIsEnabledAndUrlIsNotValidThenDoNotAddHeaderToUrl() { - givenUrlCannotUseGpc("example.com") - loadUrl("http://m.example.com") - setDesktopBrowsingMode(false) - testee.onChangeBrowserModeClicked() - verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - - val command = commandCaptor.lastValue as Navigate - assertTrue(command.headers.isEmpty()) - } - - @Test - fun whenOnDesktopSiteModeToggledIfGpcIsDisabledThenDoNotAddHeaderToUrl() { - givenGpcIsDisabled() - loadUrl("http://m.example.com") - setDesktopBrowsingMode(false) - testee.onChangeBrowserModeClicked() - verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - - val command = commandCaptor.lastValue as Navigate - assertTrue(command.headers.isEmpty()) - } - - @Test - fun whenExternalAppLinkClickedIfGpcIsEnabledAndUrlIsValidThenAddHeaderToUrl() { - givenUrlCanUseGpc() + fun whenExternalAppLinkClickedIfGpcReturnsHeaderThenAddHeaderToUrl() { + givenCustomHeadersProviderReturnsGpcHeader() val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), "fallback") testee.nonHttpAppLinkClicked(intentType) @@ -3327,8 +3282,8 @@ class BrowserTabViewModelTest { } @Test - fun whenExternalAppLinkClickedIfGpcIsEnabledAndFallbackUrlIsNullThenDoNotAddHeaderToUrl() { - givenUrlCanUseGpc() + fun whenExternalAppLinkClickedIfGpcReturnsNoHeaderThenDoNotAddHeaderToUrl() { + givenCustomHeadersProviderReturnsNoHeaders() val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), null) testee.nonHttpAppLinkClicked(intentType) @@ -3339,27 +3294,26 @@ class BrowserTabViewModelTest { } @Test - fun whenExternalAppLinkClickedIfGpcIsEnabledAndUrlIsNotValidThenDoNotAddHeaderToUrl() { - val url = "fallback" - givenUrlCannotUseGpc(url) - val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), url) + fun whenUserSubmittedQueryIfAndroidFeaturesReturnsHeaderThenAddHeaderToUrl() { + givenCustomHeadersProviderReturnsAndroidFeaturesHeader() + whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") - testee.nonHttpAppLinkClicked(intentType) + testee.onUserSubmittedQuery("foo") verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val command = commandCaptor.lastValue as Command.HandleNonHttpAppLink - assertTrue(command.headers.isEmpty()) + val command = commandCaptor.lastValue as Navigate + assertEquals(TEST_VALUE, command.headers[X_DUCKDUCKGO_ANDROID_HEADER]) } @Test - fun whenExternalAppLinkClickedIfGpcIsDisabledThenDoNotAddHeaderToUrl() { - givenGpcIsDisabled() - val intentType = SpecialUrlDetector.UrlType.NonHttpAppLink("query", mock(), "fallback") + fun whenUserSubmittedQueryIfAndroidFeaturesReturnsNoHeaderThenDoNotAddHeaderToUrl() { + givenCustomHeadersProviderReturnsNoHeaders() + whenever(mockOmnibarConverter.convertQueryToUrl("foo", null)).thenReturn("foo.com") - testee.nonHttpAppLinkClicked(intentType) + testee.onUserSubmittedQuery("foo") verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) - val command = commandCaptor.lastValue as Command.HandleNonHttpAppLink + val command = commandCaptor.lastValue as Navigate assertTrue(command.headers.isEmpty()) } @@ -5809,22 +5763,16 @@ class BrowserTabViewModelTest { testee.navigationStateChanged(buildWebNavigation(navigationHistory = history)) } - private fun givenUrlCanUseGpc() { - whenever(mockFeatureToggle.isFeatureEnabled(any(), any())).thenReturn(true) - whenever(mockGpcRepository.isGpcEnabled()).thenReturn(true) - whenever(mockGpcRepository.exceptions).thenReturn(CopyOnWriteArrayList()) + private fun givenCustomHeadersProviderReturnsGpcHeader() { + fakeCustomHeadersPlugin.headers = mapOf(GPC_HEADER to GPC_HEADER_VALUE) } - private fun givenUrlCannotUseGpc(url: String) { - val exceptions = CopyOnWriteArrayList().apply { add(GpcException(url)) } - whenever(mockFeatureToggle.isFeatureEnabled(eq(PrivacyFeatureName.GpcFeatureName.value), any())).thenReturn(true) - whenever(mockGpcRepository.isGpcEnabled()).thenReturn(true) - whenever(mockGpcRepository.exceptions).thenReturn(exceptions) + private fun givenCustomHeadersProviderReturnsNoHeaders() { + fakeCustomHeadersPlugin.headers = emptyMap() } - private fun givenGpcIsDisabled() { - whenever(mockFeatureToggle.isFeatureEnabled(any(), any())).thenReturn(true) - whenever(mockGpcRepository.isGpcEnabled()).thenReturn(false) + private fun givenCustomHeadersProviderReturnsAndroidFeaturesHeader() { + fakeCustomHeadersPlugin.headers = mapOf(X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE) } private suspend fun givenFireButtonPulsing() { @@ -6058,4 +6006,10 @@ class BrowserTabViewModelTest { toggleOn++ } } + + class FakeCustomHeadersProvider(var headers: Map) : CustomHeadersProvider { + override fun getCustomHeaders(url: String): Map { + return headers + } + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt index 76846f2a8a4e..aa51829145a6 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserWebViewClientTest.kt @@ -148,6 +148,7 @@ class BrowserWebViewClientTest { private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() private val openInNewTabFlow: MutableSharedFlow = MutableSharedFlow() private val mockUriLoadedManager: UriLoadedManager = mock() + private val mockAndroidFeaturesHeaderPlugin = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mock()) @UiThreadTest @Before @@ -184,6 +185,7 @@ class BrowserWebViewClientTest { mockDuckPlayer, mockDuckDuckGoUrlDetector, mockUriLoadedManager, + mockAndroidFeaturesHeaderPlugin, ) testee.webViewClientListener = listener whenever(webResourceRequest.url).thenReturn(Uri.EMPTY) diff --git a/app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt b/app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt new file mode 100644 index 000000000000..1f080123e6fc --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/AndroidFeaturesHeader.kt @@ -0,0 +1,47 @@ +/* + * 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 + +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature +import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider.CustomHeadersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(scope = AppScope::class) +class AndroidFeaturesHeaderPlugin @Inject constructor( + private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, +) : CustomHeadersPlugin { + + override fun getHeaders(url: String): Map { + if (androidBrowserConfigFeature.self().isEnabled() && + androidBrowserConfigFeature.featuresRequestHeader().isEnabled() && + duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url) + ) { + return mapOf( + X_DUCKDUCKGO_ANDROID_HEADER to TEST_VALUE, + ) + } + return emptyMap() + } + + companion object { + internal const val X_DUCKDUCKGO_ANDROID_HEADER = "x-duckduckgo-android" + internal const val TEST_VALUE = "test" + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index abab324aaaa2..bf77ed639b95 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -263,6 +263,7 @@ import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.common.utils.extensions.asLocationPermissionOrigin import com.duckduckgo.common.utils.isMobileSite import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider import com.duckduckgo.common.utils.toDesktopUri import com.duckduckgo.di.scopes.FragmentScope import com.duckduckgo.downloads.api.DownloadCommand @@ -277,7 +278,6 @@ import com.duckduckgo.newtabpage.impl.pixels.NewTabPixels import com.duckduckgo.privacy.config.api.AmpLinkInfo import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.ContentBlocking -import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.TrackingParameters import com.duckduckgo.privacy.dashboard.api.PrivacyProtectionTogglePlugin import com.duckduckgo.privacy.dashboard.api.PrivacyToggleOrigin @@ -387,7 +387,6 @@ class BrowserTabViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val userEventsStore: UserEventsStore, private val fileDownloader: FileDownloader, - private val gpc: Gpc, private val fireproofDialogsEventHandler: FireproofDialogsEventHandler, private val emailManager: EmailManager, private val accessibilitySettingsDataStore: AccessibilitySettingsDataStore, @@ -425,6 +424,7 @@ class BrowserTabViewModel @Inject constructor( private val highlightsOnboardingExperimentManager: HighlightsOnboardingExperimentManager, private val privacyProtectionTogglePlugin: PluginPoint, private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, + private val customHeadersProvider: CustomHeadersProvider, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -1061,10 +1061,7 @@ class BrowserTabViewModel @Inject constructor( } private fun getUrlHeaders(url: String?): Map { - url?.let { - return gpc.getHeaders(url) - } - return emptyMap() + return url?.let { customHeadersProvider.getCustomHeaders(it) } ?: emptyMap() } private fun extractVerticalParameter(currentUrl: String?): String? { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt index e4bec49296f7..717ee910bb86 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserWebViewClient.kt @@ -115,6 +115,7 @@ class BrowserWebViewClient @Inject constructor( private val duckPlayer: DuckPlayer, private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val uriLoadedManager: UriLoadedManager, + private val androidFeaturesHeaderPlugin: AndroidFeaturesHeaderPlugin, ) : WebViewClient() { var webViewClientListener: WebViewClientListener? = null @@ -345,6 +346,11 @@ class BrowserWebViewClient @Inject constructor( webViewClientListener?.openLinkInNewTab(url) return true } else { + val headers = androidFeaturesHeaderPlugin.getHeaders(url.toString()) + if (headers.isNotEmpty()) { + loadUrl(webView, url.toString(), headers) + return true + } return false } } @@ -382,6 +388,14 @@ class BrowserWebViewClient @Inject constructor( } } + private fun loadUrl( + webView: WebView, + url: String, + headers: Map, + ) { + webView.loadUrl(url, headers) + } + @UiThread override fun onPageStarted( webView: WebView, diff --git a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt index 8c9c22a45b62..cc6328142d2d 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt @@ -75,4 +75,12 @@ interface AndroidBrowserConfigFeature { */ @Toggle.DefaultValue(true) fun errorPagePixel(): Toggle + + /** + * @return `true` when the remote config has the global "featuresRequestHeader" androidBrowserConfig + * sub-feature flag enabled + * If the remote feature is not present defaults to `false` + */ + @Toggle.DefaultValue(false) + fun featuresRequestHeader(): Toggle } diff --git a/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt b/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt new file mode 100644 index 000000000000..098a0b6bb611 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/AndroidFeaturesHeaderPluginTest.kt @@ -0,0 +1,77 @@ +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.pixels.remoteconfig.AndroidBrowserConfigFeature +import com.duckduckgo.feature.toggles.api.Toggle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class AndroidFeaturesHeaderPluginTest { + + private lateinit var testee: AndroidFeaturesHeaderPlugin + + private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock() + 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 } + + @Before + fun setup() { + testee = AndroidFeaturesHeaderPlugin(mockDuckDuckGoUrlDetector, mockAndroidBrowserConfigFeature) + } + + @Test + fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureEnabledThenReturnCorrectHeader() { + val url = "duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle) + + val headers = testee.getHeaders(url) + + assertEquals(TEST_VALUE, headers[X_DUCKDUCKGO_ANDROID_HEADER]) + } + + @Test + fun whenGetHeadersCalledWithDuckDuckGoUrlAndFeatureDisabledThenReturnEmptyMap() { + val url = "duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(true) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockDisabledToggle) + + val headers = testee.getHeaders(url) + + assertTrue(headers.isEmpty()) + } + + @Test + fun whenGetHeadersCalledWithNonDuckDuckGoUrlAndFeatureEnabledThenReturnEmptyMap() { + val url = "non_duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockEnabledToggle) + + val headers = testee.getHeaders(url) + + assertTrue(headers.isEmpty()) + } + + @Test + fun whenGetHeadersCalledWithNonDuckDuckGoUrlAndFeatureDisabledThenReturnEmptyMap() { + val url = "non_duckduckgo_search_url" + whenever(mockDuckDuckGoUrlDetector.isDuckDuckGoQueryUrl(any())).thenReturn(false) + whenever(mockAndroidBrowserConfigFeature.self()).thenReturn(mockEnabledToggle) + whenever(mockAndroidBrowserConfigFeature.featuresRequestHeader()).thenReturn(mockDisabledToggle) + + val headers = testee.getHeaders(url) + + assertTrue(headers.isEmpty()) + } +} diff --git a/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt new file mode 100644 index 000000000000..27a598877e9f --- /dev/null +++ b/common/common-utils/src/main/java/com/duckduckgo/common/utils/plugins/headers/CustomHeadersProvider.kt @@ -0,0 +1,62 @@ +/* + * 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.common.utils.plugins.headers + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface CustomHeadersProvider { + + /** + * Returns a [Map] of custom headers that should be added to the request. + * @param url The url of the request. + * @return A [Map] of headers. + */ + fun getCustomHeaders(url: String): Map + + /** + * A plugin point for custom headers that should be added to all requests. + */ + @ContributesPluginPoint(AppScope::class) + interface CustomHeadersPlugin { + + /** + * Returns a [Map] of headers that should be added to the request IF the url passed allows for them to be + * added. + * @param url The url of the request. + * @return A [Map] of headers. + */ + fun getHeaders(url: String): Map + } +} + +@ContributesBinding(AppScope::class) +class RealCustomHeadersProvider @Inject constructor( + private val customHeadersPluginPoint: PluginPoint, +) : CustomHeadersProvider { + + override fun getCustomHeaders(url: String): Map { + val customHeaders = mutableMapOf() + customHeadersPluginPoint.getPlugins().forEach { + customHeaders.putAll(it.getHeaders(url)) + } + return customHeaders.toMap() + } +} diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPlugin.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPlugin.kt new file mode 100644 index 000000000000..b3ddacf8fa73 --- /dev/null +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPlugin.kt @@ -0,0 +1,33 @@ +/* + * 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.privacy.config.impl.features.gpc + +import com.duckduckgo.common.utils.plugins.headers.CustomHeadersProvider.CustomHeadersPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.Gpc +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(scope = AppScope::class) +class GpcHeaderPlugin @Inject constructor( + private val gpc: Gpc, +) : CustomHeadersPlugin { + + override fun getHeaders(url: String): Map { + return gpc.getHeaders(url) + } +} diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPluginTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPluginTest.kt new file mode 100644 index 000000000000..4f651047d2cb --- /dev/null +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/features/gpc/GpcHeaderPluginTest.kt @@ -0,0 +1,28 @@ +package com.duckduckgo.privacy.config.impl.features.gpc + +import com.duckduckgo.privacy.config.api.Gpc +import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER +import com.duckduckgo.privacy.config.impl.features.gpc.RealGpc.Companion.GPC_HEADER_VALUE +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class GpcHeaderPluginTest { + + private lateinit var testee: GpcHeaderPlugin + + private val mockGpc: Gpc = mock() + + @Test + fun whenGetHeadersCalledWithUrlThenGpcGetHeadersIsCalledWithTheSameUrlAndHeadersReturned() { + val url = "url" + val gpcHeaders = mapOf(GPC_HEADER to GPC_HEADER_VALUE) + whenever(mockGpc.getHeaders(url)).thenReturn(gpcHeaders) + testee = GpcHeaderPlugin(mockGpc) + + val headers = testee.getHeaders(url) + + assertEquals(gpcHeaders, headers) + } +}