Skip to content

Commit f3fdeb7

Browse files
authored
Update user agent to fix breakages (#853)
1 parent c9698c2 commit f3fdeb7

File tree

5 files changed

+200
-61
lines changed

5 files changed

+200
-61
lines changed

app/src/androidTest/java/com/duckduckgo/app/browser/useragent/UserAgentProviderTest.kt

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,6 @@ import org.junit.Assert.assertTrue
2323
import org.junit.Before
2424
import org.junit.Test
2525

26-
private const val CHROME_MOBILE_UA =
27-
"Mozilla/5.0 (Linux; Android 8.1.0; Nexus 6P Build/OPM3.171019.014) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.137 Mobile Safari/537.36"
28-
29-
// Some values will be dynamic based on OS/Architecture/Software versions, so use Regex to match around dynamic values
30-
private val DESKTOP_UA_REGEX = Regex(
31-
"Mozilla/5.0 \\(X11; Linux .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Chrome/[.0-9]+ Safari/[.0-9]+ DuckDuckGo/5"
32-
)
33-
private val MOBILE_UA_REGEX = Regex(
34-
"Mozilla/5.0 \\(Linux; Android .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Chrome/[.0-9]+ Mobile Safari/[.0-9]+ DuckDuckGo/5"
35-
)
36-
37-
private val MOBILE_UA_REGEX_MISSING_APPLE_WEBKIT_DETAILS = Regex(
38-
"Mozilla/5.0 \\(Linux; Android .*?\\) DuckDuckGo/5"
39-
)
40-
4126
class UserAgentProviderTest {
4227

4328
private lateinit var testee: UserAgentProvider
@@ -50,24 +35,127 @@ class UserAgentProviderTest {
5035
}
5136

5237
@Test
53-
fun whenMobileUaRetrievedThenDeviceStrippedAndDuckDuckGoSuffixAddedToUA() {
54-
testee = UserAgentProvider(CHROME_MOBILE_UA, deviceInfo)
55-
val actual = testee.getUserAgent(desktopSiteRequested = false)
56-
assertTrue(MOBILE_UA_REGEX.matches(actual))
38+
fun whenUaRetrievedWithNoParamsThenDeviceStrippedAndApplicationComponentAddedBeforeSafari() {
39+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
40+
val actual = testee.userAgent()
41+
assertTrue("$actual does not match expected regex", ValidationRegex.converted.matches(actual))
42+
}
43+
44+
@Test
45+
fun whenMobileUaRetrievedThenDeviceStrippedAndApplicationComponentAddedBeforeSafari() {
46+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
47+
val actual = testee.userAgent(isDesktop = false)
48+
assertTrue("$actual does not match expected regex", ValidationRegex.converted.matches(actual))
49+
}
50+
51+
@Test
52+
fun whenDesktopUaRetrievedThenDeviceStrippedAndApplicationComponentAddedBeforeSafari() {
53+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
54+
val actual = testee.userAgent(isDesktop = true)
55+
assertTrue("$actual does not match expected regex", ValidationRegex.desktop.matches(actual))
56+
}
57+
58+
@Test
59+
fun whenMissingAppleWebKitComponentThenUaContainsMozillaAndApplicationAndSafariComponents() {
60+
testee = UserAgentProvider(Agent.NO_WEBKIT, deviceInfo)
61+
val actual = testee.userAgent(isDesktop = false)
62+
assertTrue("$actual does not match expected regex", ValidationRegex.missingWebKit.matches(actual))
63+
}
64+
65+
@Test
66+
fun whenMissingSafariComponentThenUaContainsMozillaAndVersionAndApplicationComponents() {
67+
testee = UserAgentProvider(Agent.NO_SAFARI, deviceInfo)
68+
val actual = testee.userAgent(isDesktop = false)
69+
assertTrue("$actual does not match expected result", ValidationRegex.missingSafari.matches(actual))
70+
}
71+
72+
@Test
73+
fun whenMissingVersionComponentThenUaContainsMozillaAndApplicationAndSafariComponents() {
74+
testee = UserAgentProvider(Agent.NO_VERSION, deviceInfo)
75+
val actual = testee.userAgent(isDesktop = false)
76+
assertTrue("$actual does not match expected result", ValidationRegex.noVersion.matches(actual))
5777
}
5878

5979
@Test
60-
fun whenDesktopUaRetrievedThenDeviceStrippedAndDuckDuckGoSuffixAddedToUA() {
61-
testee = UserAgentProvider(CHROME_MOBILE_UA, deviceInfo)
62-
val actual = testee.getUserAgent(desktopSiteRequested = true)
63-
assertTrue(DESKTOP_UA_REGEX.matches(actual))
80+
fun whenDomainDoesNotSupportApplicationThenUaOmitsApplicationComponent() {
81+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
82+
val actual = testee.userAgent(NO_APPLICATION_DOMAIN)
83+
assertTrue("$actual does not match expected regex", ValidationRegex.noApplication.matches(actual))
6484
}
6585

6686
@Test
67-
fun whenMissingAppleWebKitStringThenUAContainsOnlyMozillaAndDuckDuckGoProducts() {
68-
val missingAppleWebKitPart = "Mozilla/5.0 (Linux; Android 8.1.0; Nexus 6P Build/OPM3.171019.014) Chrome/64.0.3282.137 Mobile Safari/537.36"
69-
testee = UserAgentProvider(missingAppleWebKitPart, deviceInfo)
70-
val actual = testee.getUserAgent(desktopSiteRequested = false)
71-
assertTrue(MOBILE_UA_REGEX_MISSING_APPLE_WEBKIT_DETAILS.matches(actual))
87+
fun whenSubdomsinDoesNotSupportApplicationThenUaOmitsApplicationComponent() {
88+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
89+
val actual = testee.userAgent(NO_APPLICATION_SUBDOMAIN)
90+
assertTrue("$actual does not match expected regex", ValidationRegex.noApplication.matches(actual))
91+
}
92+
93+
@Test
94+
fun whenDomainSupportsApplicationThenUaAddsApplicationComponentBeforeSafari() {
95+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
96+
val actual = testee.userAgent(DOMAIN)
97+
assertTrue("$actual does not match expected regex", ValidationRegex.converted.matches(actual))
98+
}
99+
100+
@Test
101+
fun whenDomainDoesNotSupportVersionThenUaOmitsVersionComponent() {
102+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
103+
val actual = testee.userAgent(NO_VERSION_DOMAIN)
104+
assertTrue("$actual does not match expected regex", ValidationRegex.noVersion.matches(actual))
105+
}
106+
107+
@Test
108+
fun whenSubdomainDoesNotSupportVersionThenUaOmitsVersionComponent() {
109+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
110+
val actual = testee.userAgent(NO_VERSION_SUBDOMAIN)
111+
assertTrue("$actual does not match expected regex", ValidationRegex.noVersion.matches(actual))
112+
}
113+
114+
@Test
115+
fun whenDomainSupportsVersionThenUaIncludesVersionComponentInUsualLocation() {
116+
testee = UserAgentProvider(Agent.DEFAULT, deviceInfo)
117+
val actual = testee.userAgent(DOMAIN)
118+
assertTrue("$actual does not match expected regex", ValidationRegex.converted.matches(actual))
119+
}
120+
121+
companion object {
122+
const val DOMAIN = "example.com"
123+
const val NO_APPLICATION_DOMAIN = "cvs.com"
124+
const val NO_APPLICATION_SUBDOMAIN = "subdomain.cvs.com"
125+
const val NO_VERSION_DOMAIN = "ing.nl"
126+
const val NO_VERSION_SUBDOMAIN = "subdomain.ing.nl"
127+
}
128+
129+
private object Agent {
130+
const val DEFAULT =
131+
"Mozilla/5.0 (Linux; Android 8.1.0; Nexus 6P Build/OPM3.171019.014) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/64.0.3282.137 Mobile Safari/537.36"
132+
const val NO_WEBKIT =
133+
"Mozilla/5.0 (Linux; Android 8.1.0; Nexus 6P Build/OPM3.171019.014) Version/4.0 Chrome/64.0.3282.137 Mobile Safari/537.36"
134+
const val NO_SAFARI =
135+
"Mozilla/5.0 (Linux; Android 8.1.0; Nexus 6P Build/OPM3.171019.014) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/64.0.3282.137 Mobile"
136+
const val NO_VERSION =
137+
"Mozilla/5.0 (Linux; Android 8.1.0; Nexus 6P Build/OPM3.171019.014) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.137 Mobile Safari/537.36"
138+
}
139+
140+
// Some values will be dynamic based on OS/Architecture/Software versions, so we use Regex to validate values
141+
private object ValidationRegex {
142+
val converted = Regex(
143+
"Mozilla/5.0 \\(Linux; Android .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Version/[.0-9]+ Chrome/[.0-9]+ Mobile DuckDuckGo/5 Safari/[.0-9]+"
144+
)
145+
val desktop = Regex(
146+
"Mozilla/5.0 \\(X11; Linux .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Version/[.0-9]+ Chrome/[.0-9]+ DuckDuckGo/5 Safari/[.0-9]+"
147+
)
148+
val noApplication = Regex(
149+
"Mozilla/5.0 \\(Linux; Android .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Version/[.0-9]+ Chrome/[.0-9]+ Mobile Safari/[.0-9]+"
150+
)
151+
val noVersion = Regex(
152+
"Mozilla/5.0 \\(Linux; Android .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Chrome/[.0-9]+ Mobile DuckDuckGo/5 Safari/[.0-9]+"
153+
)
154+
val missingWebKit = Regex(
155+
"Mozilla/5.0 \\(Linux; Android .*?\\) DuckDuckGo/5 Safari/[.0-9]+"
156+
)
157+
val missingSafari = Regex(
158+
"Mozilla/5.0 \\(Linux; Android .*?\\) AppleWebKit/[.0-9]+ \\(KHTML, like Gecko\\) Version/[.0-9]+ Chrome/[.0-9]+ Mobile DuckDuckGo/5"
159+
)
72160
}
73161
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ import com.duckduckgo.app.cta.ui.*
8383
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity
8484
import com.duckduckgo.app.fire.fireproofwebsite.data.website
8585
import com.duckduckgo.app.global.ViewModelFactory
86-
import com.duckduckgo.app.global.device.DeviceInfo
8786
import com.duckduckgo.app.global.model.orderedTrackingEntities
8887
import com.duckduckgo.app.global.view.*
8988
import com.duckduckgo.app.privacy.renderer.icon
@@ -147,9 +146,6 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
147146
@Inject
148147
lateinit var viewModelFactory: ViewModelFactory
149148

150-
@Inject
151-
lateinit var deviceInfo: DeviceInfo
152-
153149
@Inject
154150
lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder
155151

@@ -188,6 +184,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
188184

189185
val tabId get() = requireArguments()[TAB_ID_ARG] as String
190186

187+
@Inject
191188
lateinit var userAgentProvider: UserAgentProvider
192189

193190
var messageFromPreviousTab: Message? = null
@@ -573,6 +570,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
573570
is Command.DaxCommand.HideDaxDialog -> showHideTipsDialog(it.cta)
574571
is Command.HideWebContent -> webView?.hide()
575572
is Command.ShowWebContent -> webView?.show()
573+
is Command.RefreshUserAgent -> refreshUserAgent(it.host, it.isDesktop)
576574
}
577575
}
578576

@@ -802,13 +800,11 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
802800
).findViewById(R.id.browserWebView) as WebView
803801

804802
webView?.let {
805-
userAgentProvider = UserAgentProvider(it.settings.userAgentString, deviceInfo)
806-
807803
it.webViewClient = webViewClient
808804
it.webChromeClient = webChromeClient
809805

810806
it.settings.apply {
811-
userAgentString = userAgentProvider.getUserAgent()
807+
userAgentString = userAgentProvider.userAgent()
812808
javaScriptEnabled = true
813809
domStorageEnabled = true
814810
loadWithOverviewMode = true
@@ -970,6 +966,15 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
970966
}
971967
}
972968

969+
private fun refreshUserAgent(host: String?, isDesktop: Boolean) {
970+
val currentAgent = webView?.settings?.userAgentString
971+
val newAgent = userAgentProvider.userAgent(host, isDesktop)
972+
if (newAgent != currentAgent) {
973+
Timber.d("User Agent Changed, new ${if (isDesktop) "Desktop" else "Mobile"} UA is $newAgent")
974+
webView?.settings?.userAgentString = newAgent
975+
}
976+
}
977+
973978
/**
974979
* Attempting to save the WebView's state can result in a TransactionTooLargeException being thrown.
975980
* This will only happen if the bundle size is too large - but the exact size is undefined.
@@ -1037,7 +1042,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
10371042
url = url,
10381043
contentDisposition = contentDisposition,
10391044
mimeType = mimeType,
1040-
userAgent = userAgentProvider.getUserAgent(),
1045+
userAgent = userAgentProvider.userAgent(),
10411046
subfolder = Environment.DIRECTORY_DOWNLOADS
10421047
)
10431048

@@ -1051,7 +1056,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
10511056
private fun requestImageDownload(url: String, requestUserConfirmation: Boolean) {
10521057
pendingFileDownload = PendingFileDownload(
10531058
url = url,
1054-
userAgent = userAgentProvider.getUserAgent(),
1059+
userAgent = userAgentProvider.userAgent(),
10551060
subfolder = Environment.DIRECTORY_PICTURES
10561061
)
10571062

@@ -1511,7 +1516,6 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
15111516
}
15121517
}
15131518

1514-
toggleDesktopSiteMode(viewState.isDesktopBrowsingMode)
15151519
renderToolbarMenus(viewState)
15161520
renderPopupMenus(browserShowing, viewState)
15171521
renderFullscreenMode(viewState)
@@ -1541,6 +1545,7 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
15411545
whitelistPopupMenuItem?.text = getText(if (viewState.isWhitelisted) R.string.whitelistRemove else R.string.whitelistAdd)
15421546
brokenSitePopupMenuItem?.isEnabled = viewState.canReportSite
15431547
requestDesktopSiteCheckMenuItem?.isEnabled = viewState.canChangeBrowsingMode
1548+
requestDesktopSiteCheckMenuItem?.isChecked = viewState.isDesktopBrowsingMode
15441549

15451550
addToHome?.let {
15461551
it.visibility = if (viewState.addToHomeVisible) VISIBLE else GONE
@@ -1716,10 +1721,6 @@ class BrowserTabFragment : Fragment(), FindListener, CoroutineScope, DaxDialogLi
17161721
}
17171722
}
17181723

1719-
private fun toggleDesktopSiteMode(isDesktopSiteMode: Boolean) {
1720-
webView?.settings?.userAgentString = userAgentProvider.getUserAgent(isDesktopSiteMode)
1721-
}
1722-
17231724
private fun goFullScreen() {
17241725
Timber.i("Entering full screen")
17251726
webViewFullScreenContainer.show()

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ class BrowserTabViewModel(
235235
object LaunchTabSwitcher : Command()
236236
object HideWebContent : Command()
237237
object ShowWebContent : Command()
238+
class RefreshUserAgent(val host: String?, val isDesktop: Boolean) : Command()
238239

239240
class ShowErrorWithAction(val action: () -> Unit) : Command()
240241
sealed class DaxCommand : Command() {
@@ -570,6 +571,7 @@ class BrowserTabViewModel(
570571
private fun pageChanged(url: String, title: String?) {
571572
Timber.v("Page changed: $url")
572573
buildSiteFactory(url, title)
574+
command.value = RefreshUserAgent(site?.uri?.host, currentBrowserViewState().isDesktopBrowsingMode)
573575

574576
val currentOmnibarViewState = currentOmnibarViewState()
575577
omnibarViewState.value = currentOmnibarViewState.copy(omnibarText = omnibarTextForUrl(url), shouldMoveCaretToEnd = false)
@@ -1008,6 +1010,7 @@ class BrowserTabViewModel(
10081010
fun onDesktopSiteModeToggled(desktopSiteRequested: Boolean) {
10091011
val currentBrowserViewState = currentBrowserViewState()
10101012
browserViewState.value = currentBrowserViewState.copy(isDesktopBrowsingMode = desktopSiteRequested)
1013+
command.value = RefreshUserAgent(site?.uri?.host, desktopSiteRequested)
10111014

10121015
val uri = site?.uri ?: return
10131016

app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.duckduckgo.app.browser.di
1919
import android.content.ClipboardManager
2020
import android.content.Context
2121
import android.webkit.CookieManager
22+
import android.webkit.WebSettings
2223
import com.duckduckgo.app.browser.*
2324
import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector
2425
import com.duckduckgo.app.browser.addtohome.AddToHomeSystemCapabilityDetector
@@ -31,10 +32,12 @@ import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewGenerator
3132
import com.duckduckgo.app.browser.tabpreview.FileBasedWebViewPreviewPersister
3233
import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator
3334
import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister
35+
import com.duckduckgo.app.browser.useragent.UserAgentProvider
3436
import com.duckduckgo.app.fire.*
3537
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao
3638
import com.duckduckgo.app.global.AppUrl
3739
import com.duckduckgo.app.global.DispatcherProvider
40+
import com.duckduckgo.app.global.device.DeviceInfo
3841
import com.duckduckgo.app.global.exception.UncaughtExceptionRepository
3942
import com.duckduckgo.app.global.file.FileDeleter
4043
import com.duckduckgo.app.global.install.AppInstallStore
@@ -131,6 +134,12 @@ class BrowserModule {
131134
@Provides
132135
fun specialUrlDetector(): SpecialUrlDetector = SpecialUrlDetectorImpl()
133136

137+
@Provides
138+
@Singleton
139+
fun userAgentProvider(context: Context, deviceInfo: DeviceInfo): UserAgentProvider {
140+
return UserAgentProvider(WebSettings.getDefaultUserAgent(context), deviceInfo)
141+
}
142+
134143
@Provides
135144
fun webViewRequestInterceptor(
136145
resourceSurrogates: ResourceSurrogates,

0 commit comments

Comments
 (0)