diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index 9bea9520c045..38b09730920f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -484,4 +484,48 @@ class BrowserViewModelTest { assertEquals("", testee.viewState.value!!.findInPage.searchTerm) } + @Test + fun whenUserSelectsDesktopSiteThenDesktopModeStateUpdated() { + testee.desktopSiteModeToggled("http://example.com", desktopSiteRequested = true) + verify(mockCommandObserver, Mockito.atLeastOnce()).onChanged(commandCaptor.capture()) + assertTrue(testee.viewState.value!!.isDesktopBrowsingMode) + } + + @Test + fun whenUserSelectsMobileSiteThenMobileModeStateUpdated() { + testee.desktopSiteModeToggled("http://example.com", desktopSiteRequested = false) + assertFalse(testee.viewState.value!!.isDesktopBrowsingMode) + } + + @Test + fun whenUserSelectsDesktopSiteWhenOnMobileSpecificSiteThenUrlModified() { + testee.desktopSiteModeToggled("http://m.example.com", desktopSiteRequested = true) + verify(mockCommandObserver, Mockito.atLeastOnce()).onChanged(commandCaptor.capture()) + val ultimateCommand = commandCaptor.lastValue as Navigate + assertEquals("http://example.com", ultimateCommand.url) + } + + @Test + fun whenUserSelectsDesktopSiteWhenNotOnMobileSpecificSiteThenUrlNotModified() { + testee.desktopSiteModeToggled("http://example.com", desktopSiteRequested = true) + verify(mockCommandObserver, Mockito.atLeastOnce()).onChanged(commandCaptor.capture()) + val ultimateCommand = commandCaptor.lastValue + assertTrue(ultimateCommand == Command.Refresh) + } + + @Test + fun whenUserSelectsMobileSiteWhenOnMobileSpecificSiteThenUrlNotModified() { + testee.desktopSiteModeToggled("http://m.example.com", desktopSiteRequested = false) + verify(mockCommandObserver, Mockito.atLeastOnce()).onChanged(commandCaptor.capture()) + val ultimateCommand = commandCaptor.lastValue + assertTrue(ultimateCommand == Command.Refresh) + } + + @Test + fun whenUserSelectsMobileSiteWhenNotOnMobileSpecificSiteThenUrlNotModified() { + testee.desktopSiteModeToggled("http://example.com", desktopSiteRequested = false) + verify(mockCommandObserver, Mockito.atLeastOnce()).onChanged(commandCaptor.capture()) + val ultimateCommand = commandCaptor.lastValue + assertTrue(ultimateCommand == Command.Refresh) + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/userAgent/UserAgentProviderTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/userAgent/UserAgentProviderTest.kt new file mode 100644 index 000000000000..32fd3d5c208d --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/userAgent/UserAgentProviderTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2018 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.useragent + +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val CHROME_UA_MOBILE = + "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" + +// Some values will be dynamic based on OS/Architecture/Software versions, so use Regex to match around dynamic values +private val CHROME_UA_DESKTOP_REGEX = Regex( + "Mozilla/5.0 \\(X11; Linux .*?\\) AppleWebKit\\/[.0-9]+ \\(KHTML, like Gecko\\) Chrome\\/[.0-9]+ Safari/[.0-9]+" +) +private val CHROME_UA_MOBILE_REGEX = Regex( + "Mozilla/5.0 \\(Linux; Android .*?\\) AppleWebKit\\/[.0-9]+ \\(KHTML, like Gecko\\) Chrome\\/[.0-9]+ Mobile Safari/[.0-9]+" +) + +private val CHROME_UA_MOBILE_REGEX_MISSING_APPLE_WEBKIT_DETAILS = Regex( + "Mozilla/5.0 \\(Linux; Android .*?\\)" +) + +class UserAgentProviderTest { + + private lateinit var testee: UserAgentProvider + + @Test + fun whenMobileUaRetrievedThenDeviceStrippedFromReturnedUa() { + testee = UserAgentProvider(CHROME_UA_MOBILE) + val actual = testee.getUserAgent(desktopSiteRequested = false) + assertTrue(CHROME_UA_MOBILE_REGEX.matches(actual)) + } + + @Test + fun whenDesktopUaRetrievedThenDeviceStrippedFromReturnedUa() { + testee = UserAgentProvider(CHROME_UA_MOBILE) + val actual = testee.getUserAgent(desktopSiteRequested = true) + assertTrue(CHROME_UA_DESKTOP_REGEX.matches(actual)) + } + + @Test + fun whenMissingAppleWebKitStringThenSimplyReturnsNothing() { + 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" + testee = UserAgentProvider(missingAppleWebKitPart) + val actual = testee.getUserAgent(desktopSiteRequested = false) + assertTrue(CHROME_UA_MOBILE_REGEX_MISSING_APPLE_WEBKIT_DETAILS.matches(actual)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/duckduckgo/app/global/UriExtensionTest.kt b/app/src/androidTest/java/com/duckduckgo/app/global/UriExtensionTest.kt index 89af8f1cfa35..5737fe3b9de7 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/global/UriExtensionTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/global/UriExtensionTest.kt @@ -102,4 +102,30 @@ class UriExtensionTest { assertFalse(Uri.parse("http://example.com").hasIpHost) } + @Test + fun whenUrlStartsMDotThenIdentifiedAsMobileSite() { + assertTrue(Uri.parse("https://m.example.com").isMobileSite) + } + + @Test + fun whenUrlSubdomainEndsWithMThenNotIdentifiedAsMobileSite() { + assertFalse(Uri.parse("https://adam.example.com").isMobileSite) + } + + @Test + fun whenUrlDoesNotStartWithMDotThenNotIdentifiedAsMobileSite() { + assertFalse(Uri.parse("https://example.com").isMobileSite) + } + + @Test + fun whenConvertingMobileSiteToDesktopSiteThenMobilePrefixStripped() { + val converted = Uri.parse("https://m.example.com").toDesktopUri() + assertEquals("https://example.com", converted.toString()) + } + + @Test + fun whenConvertingDesktopSiteToDesktopSiteThenUrlUnchanged() { + val converted = Uri.parse("https://example.com").toDesktopUri() + assertEquals("https://example.com", converted.toString()) + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 9d041de6c8f5..ff31f2a846f2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -50,6 +50,7 @@ import com.duckduckgo.app.bookmarks.ui.BookmarksActivity import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.autoComplete.BrowserAutoCompleteSuggestionsAdapter import com.duckduckgo.app.browser.omnibar.OnBackKeyListener +import com.duckduckgo.app.browser.useragent.UserAgentProvider import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.ViewModelFactory import com.duckduckgo.app.global.view.* @@ -78,6 +79,8 @@ class BrowserActivity : DuckDuckGoActivity(), BookmarkDialogCreationListener, We @Inject lateinit var viewModelFactory: ViewModelFactory + lateinit var userAgentProvider: UserAgentProvider + private lateinit var popupMenu: BrowserPopupMenu private lateinit var autoCompleteSuggestionsAdapter: BrowserAutoCompleteSuggestionsAdapter @@ -129,6 +132,9 @@ class BrowserActivity : DuckDuckGoActivity(), BookmarkDialogCreationListener, We enableMenuOption(view.addBookmarksPopupMenuItem) { addBookmark() } enableMenuOption(view.settingsPopupMenuItem) { launchSettings() } enableMenuOption(view.findInPageMenuItem) { viewModel.userRequestingToFindInPage() } + enableMenuOption(view.requestDesktopSiteCheckMenuItem) { + viewModel.desktopSiteModeToggled(urlString = webView.url, desktopSiteRequested = view.requestDesktopSiteCheckMenuItem.isChecked) + } } } @@ -222,6 +228,8 @@ class BrowserActivity : DuckDuckGoActivity(), BookmarkDialogCreationListener, We false -> webView.hide() } + toggleDesktopSiteMode(viewState.isDesktopBrowsingMode) + when (viewState.isLoading) { true -> pageLoadingIndicator.show() false -> pageLoadingIndicator.hide() @@ -401,10 +409,13 @@ class BrowserActivity : DuckDuckGoActivity(), BookmarkDialogCreationListener, We @SuppressLint("SetJavaScriptEnabled") private fun configureWebView() { webView = layoutInflater.inflate(R.layout.include_duckduckgo_browser_webview, webViewContainer, true).findViewById(R.id.browserWebView) as WebView + userAgentProvider = UserAgentProvider(webView.settings.userAgentString) + webView.webViewClient = webViewClient webView.webChromeClient = webChromeClient webView.settings.apply { + userAgentString = userAgentProvider.getUserAgent() javaScriptEnabled = true domStorageEnabled = true loadWithOverviewMode = true @@ -435,6 +446,10 @@ class BrowserActivity : DuckDuckGoActivity(), BookmarkDialogCreationListener, We viewModel.registerWebViewListener(webViewClient, webChromeClient) } + private fun toggleDesktopSiteMode(isDesktopSiteMode: Boolean) { + webView.settings.userAgentString = userAgentProvider.getUserAgent(isDesktopSiteMode) + } + private fun downloadFileWithPermissionCheck() { if (hasWriteStoragePermission()) { downloadFile() diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 1184b38f17de..0a53a3438e15 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -35,6 +35,10 @@ import com.duckduckgo.app.browser.BrowserViewModel.Command.Navigate import com.duckduckgo.app.browser.LongPressHandler.RequiredAction import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.global.SingleLiveEvent +import com.duckduckgo.app.global.db.AppConfigurationDao +import com.duckduckgo.app.global.db.AppConfigurationEntity +import com.duckduckgo.app.global.isMobileSite +import com.duckduckgo.app.global.toDesktopUri import com.duckduckgo.app.privacymonitor.SiteMonitor import com.duckduckgo.app.privacymonitor.db.NetworkLeaderboardDao import com.duckduckgo.app.privacymonitor.db.NetworkLeaderboardEntry @@ -44,8 +48,6 @@ import com.duckduckgo.app.privacymonitor.model.improvedGrade import com.duckduckgo.app.privacymonitor.store.PrivacyMonitorRepository import com.duckduckgo.app.privacymonitor.store.TermsOfServiceStore import com.duckduckgo.app.privacymonitor.ui.PrivacyDashboardActivity.Companion.RELOAD_RESULT_CODE -import com.duckduckgo.app.global.db.AppConfigurationDao -import com.duckduckgo.app.global.db.AppConfigurationEntity import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.trackerdetection.model.TrackerNetworks @@ -82,7 +84,9 @@ class BrowserViewModel( val canAddBookmarks: Boolean = false, val isFullScreen: Boolean = false, val autoComplete: AutoCompleteViewState = AutoCompleteViewState(), - val findInPage: FindInPage = FindInPage(canFindInPage = false) + val findInPage: FindInPage = FindInPage(canFindInPage = false), + val webViewScale: Int = 0, + val isDesktopBrowsingMode: Boolean = false ) sealed class Command { @@ -147,7 +151,6 @@ class BrowserViewModel( viewState.value = currentViewState.copy(autoComplete = searchResultViewState.copy(searchResults = AutoCompleteResult(result.query, results))) } - @VisibleForTesting public override fun onCleared() { super.onCleared() @@ -391,6 +394,23 @@ class BrowserViewModel( viewState.value = currentViewState().copy(findInPage = findInPage) } + + fun desktopSiteModeToggled(urlString: String?, desktopSiteRequested: Boolean) { + viewState.value = currentViewState().copy(isDesktopBrowsingMode = desktopSiteRequested) + + if (urlString == null) { + return + } + val url = Uri.parse(urlString) + if (desktopSiteRequested && url.isMobileSite) { + val desktopUrl = url.toDesktopUri() + Timber.i("Original URL $urlString - attempting $desktopUrl with desktop site UA string") + command.value = Navigate(desktopUrl.toString()) + } else { + command.value = Command.Refresh + } + } + data class FindInPage( val visible: Boolean = false, val showNumberMatches: Boolean = false, diff --git a/app/src/main/java/com/duckduckgo/app/browser/userAgent/UserAgentProvider.kt b/app/src/main/java/com/duckduckgo/app/browser/userAgent/UserAgentProvider.kt new file mode 100644 index 000000000000..34d3e5e864b0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/userAgent/UserAgentProvider.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018 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.useragent + +import android.net.Uri +import android.os.Build + + +/** + * Example Default User Agent (From Chrome): + * 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 + * + * Example Default Desktop User Agent (From Chrome): + * Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.137 Safari/537.36 + */ +class UserAgentProvider constructor(private val defaultUserAgent: String) { + + /** + * Returns a modified UA string which omits the user's device make and model + * If the user is requesting a desktop site, we add generic X11 Linux indicator, but include the real architecture + * If the user is requesting a mobile site, we add Linux Android indicator, and include the real Android OS version + * + * We include everything from the original UA string from AppleWebKit onwards (omitting if missing) + */ + fun getUserAgent(desktopSiteRequested: Boolean = false) : String{ + + val platform = if(desktopSiteRequested) desktopUaPrefix() else mobileUaPrefix() + val userAgentStringSuffix = getWebKitVersionOnwards(desktopSiteRequested) + + return "$MOZILLA_PREFIX ($platform)$userAgentStringSuffix" + } + + private fun mobileUaPrefix() = "Linux; Android ${Build.VERSION.RELEASE}" + + private fun desktopUaPrefix() = "X11; Linux ${System.getProperty("os.arch")}" + + private fun getWebKitVersionOnwards(desktopSiteRequested: Boolean): String { + val matches = WEB_KIT_REGEX.find(defaultUserAgent) ?: return "" + var result = matches.groupValues[0] + if(desktopSiteRequested) { + result = result.replace(" Mobile ", " ") + } + return " $result" + } + + companion object { + private val WEB_KIT_REGEX = Regex("AppleWebKit/.*") + + private const val MOZILLA_PREFIX = "Mozilla/5.0" + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt b/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt index 756d7856971b..fab50c14a83b 100644 --- a/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt +++ b/app/src/main/java/com/duckduckgo/app/global/UriExtension.kt @@ -45,3 +45,14 @@ val Uri.hasIpHost: Boolean val ipRegex = Regex("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$") return baseHost?.matches(ipRegex) ?: false } + +val Uri.isMobileSite: Boolean + get() = authority.startsWith("m.") + +fun Uri.toDesktopUri(): Uri { + return if (isMobileSite) { + Uri.parse(toString().replaceFirst("m.", "")) + } else { + this + } +} diff --git a/app/src/main/res/layout/popup_window_browser_menu.xml b/app/src/main/res/layout/popup_window_browser_menu.xml index a71d7c7f17c9..a5f484c93c2c 100644 --- a/app/src/main/res/layout/popup_window_browser_menu.xml +++ b/app/src/main/res/layout/popup_window_browser_menu.xml @@ -95,6 +95,20 @@ android:enabled="false" android:text="@string/find_in_page" /> + + Download Image Image Options Downloading requires storage permission - Find in page + Desktop Site + Find in page Find next Find previous Close find in page view