From 8d1fcbabd7f915f4d7fd4bff6ea943f15cfb8de2 Mon Sep 17 00:00:00 2001 From: juandiana Date: Thu, 20 Aug 2026 15:55:05 -0300 Subject: [PATCH 1/6] Add redirect suggestion view and string resource --- app/src/main/res/layout/include_error_view.xml | 17 +++++++++++++++++ app/src/main/res/values/donottranslate.xml | 1 + 2 files changed, 18 insertions(+) diff --git a/app/src/main/res/layout/include_error_view.xml b/app/src/main/res/layout/include_error_view.xml index f017a1ce76e2..fdf1187c2285 100644 --- a/app/src/main/res/layout/include_error_view.xml +++ b/app/src/main/res/layout/include_error_view.xml @@ -71,5 +71,22 @@ app:textType="secondary" tools:text="@string/webViewErrorBadUrl" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 811afcd666a8..92ff5a0fd1f4 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -24,6 +24,7 @@ The internet connection appears to be offline. A server with the specified hostname could not be found. %1$s which could put your confidential information at risk.]]> + Did you mean ^1? What site are you signing in to? (required) From 0a9fd949911e4f5943d907d1a82aa7d6904ffc2f Mon Sep 17 00:00:00 2001 From: juandiana Date: Fri, 21 Aug 2026 03:46:39 -0300 Subject: [PATCH 2/6] Disable view state saving for redirectSuggestionMessage --- .../main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 6e0be90cbf39..f14e35c24e60 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1314,6 +1314,7 @@ class BrowserTabFragment : errorView.yetiIcon.isSaveEnabled = false errorView.errorTitle.isSaveEnabled = false errorView.errorMessage.isSaveEnabled = false + errorView.redirectSuggestionMessage.isSaveEnabled = false omnibar.disableViewStateSaving() sslErrorView.disableViewStateSaving() From ff0197257366b2e8edd5df5844052d81947b1d34 Mon Sep 17 00:00:00 2001 From: juandiana Date: Thu, 20 Aug 2026 17:08:19 -0300 Subject: [PATCH 3/6] Add ViewModel method to handle user tap on the suggested link --- .../app/browser/BrowserTabViewModel.kt | 5 +++++ .../app/browser/BrowserTabViewModelTest.kt | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) 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 7c713d6efca1..c2e56eedb472 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -5797,6 +5797,11 @@ class BrowserTabViewModel @Inject constructor( } } + fun onRedirectSuggestionClicked(url: String) { + resetBrowserError() + command.value = NavigationCommand.Navigate(url, getUrlHeaders(url)) + } + private fun trackersCount(): String = siteLiveData.value?.trackerCount?.takeIf { it > 0 }?.toString() ?: "" diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 57d1672e9766..ff61f90e0ca0 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -8452,6 +8452,26 @@ class BrowserTabViewModelTest { } @Test + fun whenRedirectSuggestionClickedThenResetsBrowserErrorResetAndNavigateCommandIssuedWithSuggestedUrl() = + runTest { + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + + testee.onRedirectSuggestionClicked("http://www.example.com") + + assertEquals(OMITTED, browserViewState().browserError) + assertCommandIssued { + assertEquals("http://www.example.com", url) + } + } + + @Test + fun whenRedirectSuggestionClickedThenSearchCountNotIncremented() = + runTest { + testee.onRedirectSuggestionClicked("http://www.example.com") + + verify(mockSearchCountDao, never()).incrementSearchCount() + } + @Test fun whenUserSelectedAutocompleteWithAutoCompleteSwitchToTabSuggestionThenSwitchToTabCommandSentWithTabId() = runTest { val tabId = "tabId" From 3e5768f63e54865c0e1278fb4fb1f4f353ba36c9 Mon Sep 17 00:00:00 2001 From: juandiana Date: Thu, 20 Aug 2026 17:11:18 -0300 Subject: [PATCH 4/6] Render redirect suggestion based on the view state --- .../app/browser/BrowserTabFragment.kt | 23 +++++++++++++++++++ .../app/browser/viewstate/BrowserViewState.kt | 2 ++ 2 files changed, 25 insertions(+) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index f14e35c24e60..8d3ce574b433 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -40,6 +40,8 @@ import android.print.PrintDocumentAdapter import android.print.PrintManager import android.provider.MediaStore import android.text.Spanned +import android.text.SpannedString +import android.text.TextUtils import android.view.ContextMenu import android.view.ContextThemeWrapper import android.view.LayoutInflater @@ -179,6 +181,7 @@ import com.duckduckgo.app.browser.print.PrintDocumentAdapterFactory import com.duckduckgo.app.browser.print.PrintInjector import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.shortcut.ShortcutBuilder +import com.duckduckgo.app.browser.suggestredirect.RedirectSuggestion import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator import com.duckduckgo.app.browser.tabpreview.WebViewPreviewPersister import com.duckduckgo.app.browser.ui.dialogs.AutomaticFireproofDialogOptions @@ -293,6 +296,7 @@ import com.duckduckgo.common.ui.menu.PopupMenu import com.duckduckgo.common.ui.store.BrowserAppTheme import com.duckduckgo.common.ui.tabs.SwipingTabsFeatureProvider import com.duckduckgo.common.ui.view.DaxDialog +import com.duckduckgo.common.ui.view.addClickableLink import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog import com.duckduckgo.common.ui.view.dialog.CustomAlertDialogBuilder import com.duckduckgo.common.ui.view.dialog.DaxAlertDialog @@ -2374,6 +2378,7 @@ class BrowserTabFragment : newBrowserTab.newTabRootLayout.gone() sslErrorView.gone() maliciousWarningView.gone() + renderRedirectSuggestion(viewModel.browserViewState.value?.redirectSuggestion) hidePdf() omnibar.setViewMode(ViewMode.Error) webView?.onPause() @@ -2389,6 +2394,21 @@ class BrowserTabFragment : browserNavigationBarIntegration.configureBrowserViewMode() } + private fun renderRedirectSuggestion(suggestion: RedirectSuggestion?) { + if (suggestion == null) { + errorView.redirectSuggestionMessage.gone() + return + } + // Use expandTemplate(), getString() would otherwise strip the annotation altogether + val message = SpannedString(TextUtils.expandTemplate(getText(R.string.webViewErrorRedirectSuggestionMessage), suggestion.domain)) + with(errorView.redirectSuggestionMessage) { + addClickableLink("redirect_link", message) { + viewModel.onRedirectSuggestionClicked(suggestion.url) + } + show() + } + } + private fun showDuckAI(browserViewState: BrowserViewState) { renderBrowserMenu(viewState = browserViewState, omnibarViewMode = ViewMode.DuckAI) omnibar.setViewMode(ViewMode.DuckAI) @@ -6105,6 +6125,7 @@ class BrowserTabFragment : val browserShowing = viewState.browserShowing val browserShowingChanged = viewState.browserShowing != lastSeenBrowserViewState?.browserShowing val errorChanged = viewState.browserError != lastSeenBrowserViewState?.browserError + val redirectSuggestionChanged = viewState.redirectSuggestion != lastSeenBrowserViewState?.redirectSuggestion val sslErrorChanged = viewState.sslError != lastSeenBrowserViewState?.sslError lastSeenBrowserViewState = viewState @@ -6132,6 +6153,8 @@ class BrowserTabFragment : showHome() } } + } else if (redirectSuggestionChanged) { + renderRedirectSuggestion(viewState.redirectSuggestion) } omnibar.renderBrowserViewState(viewState) diff --git a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt index 0b4804b50857..faf557e0c166 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/viewstate/BrowserViewState.kt @@ -21,6 +21,7 @@ import com.duckduckgo.app.browser.SSLErrorType import com.duckduckgo.app.browser.SpecialUrlDetector import com.duckduckgo.app.browser.WebViewErrorResponse import com.duckduckgo.app.browser.omnibar.QueryOrigin +import com.duckduckgo.app.browser.suggestredirect.RedirectSuggestion import com.duckduckgo.app.global.model.MaliciousSiteStatus import com.duckduckgo.browser.ui.browsermenu.VpnMenuState import com.duckduckgo.savedsites.api.models.SavedSite @@ -55,6 +56,7 @@ data class BrowserViewState( val isPrinting: Boolean = false, val showAutofill: Boolean = false, val browserError: WebViewErrorResponse = WebViewErrorResponse.OMITTED, + val redirectSuggestion: RedirectSuggestion? = null, val sslError: SSLErrorType = SSLErrorType.NONE, val maliciousSiteBlocked: Boolean = false, val maliciousSiteStatus: MaliciousSiteStatus? = null, From 185a5ee94bd16fdbe44e8f4176a88c63fd9ec3d3 Mon Sep 17 00:00:00 2001 From: juandiana Date: Fri, 21 Aug 2026 03:01:05 -0300 Subject: [PATCH 5/6] Add SuggestRedirectEvaluator component It determines when to show a redirect suggestion --- .../SuggestRedirectEvaluator.kt | 81 +++++++++++++ .../SuggestRedirectEvaluatorTest.kt | 114 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluator.kt create mode 100644 app/src/test/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluatorTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluator.kt b/app/src/main/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluator.kt new file mode 100644 index 000000000000..3134a7d597cb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluator.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 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.suggestredirect + +import android.net.Uri +import androidx.core.net.toUri +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.extensions.toTldPlusOne +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class RedirectSuggestion( + val domain: String, + val url: String, +) + +interface SuggestRedirectEvaluator { + /** + * Decides whether the error page for a failed [url] due to unresolved host should suggest + * redirecting to the "www." prefixed variant of its host. + * + * @return the suggestion to offer when [url] points at a bare domain (no "www." prefix) + * whose "www." variant resolves; `null` otherwise. + */ + suspend fun suggestRedirect(url: String): RedirectSuggestion? +} + +@ContributesBinding(AppScope::class) +class RealSuggestRedirectEvaluator @Inject constructor( + private val hostnameResolver: HostnameResolver, + private val dispatcherProvider: DispatcherProvider, +) : SuggestRedirectEvaluator { + override suspend fun suggestRedirect(url: String): RedirectSuggestion? = withContext(dispatcherProvider.io()) { + val uri = url.toUri() + val hostname = uri.host ?: return@withContext null + + if (hostname.startsWith(WWW_PREFIX)) { + return@withContext null + } + + if (hostname != hostname.toTldPlusOne()) { + // Non-registrable (eTLD+1) domains are rejected (e.g. localhost, a.b.domain.com) + return@withContext null + } + + val suggestedDomain = "${WWW_PREFIX}$hostname" + if (!hostnameResolver.resolves(suggestedDomain)) { + return@withContext null + } + + return@withContext RedirectSuggestion( + domain = suggestedDomain, + url = uri.replaceHost(suggestedDomain).toString(), + ) + } + + private fun Uri.replaceHost(host: String): Uri { + val authority = if (this.port != -1) "$host:${this.port}" else host + return this.buildUpon().encodedAuthority(authority).build() + } + + companion object { + private const val WWW_PREFIX = "www." + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluatorTest.kt b/app/src/test/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluatorTest.kt new file mode 100644 index 000000000000..93c617e19893 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/suggestredirect/SuggestRedirectEvaluatorTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 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.suggestredirect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class SuggestRedirectEvaluatorTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val hostnameResolver: HostnameResolver = mock() + + private val testee: SuggestRedirectEvaluator = RealSuggestRedirectEvaluator(hostnameResolver, coroutineRule.testDispatcherProvider) + + @Test + fun `when URL has no host, then returns null and performs no lookups`() = runTest { + listOf( + "", + " ", + "not a url", + "example.com", // Missing scheme in the URL + ).forEach { url -> + assertNull("Expected suggestRedirect(\"$url\") to be null", testee.suggestRedirect(url)) + } + verifyNoInteractions(hostnameResolver) + } + + @Test + fun `when host already has a www prefix, then returns null and performs no lookups`() = runTest { + assertNull(testee.suggestRedirect("https://www.example.com")) + verifyNoInteractions(hostnameResolver) + } + + @Test + fun `when host is not a registrable domain, then returns null and performs no lookups`() = runTest { + listOf( + "https://localhost", + "https://a.b.example.com", + "https://.com", + "https://com", + "https://127.0.0.1", + "https://62.62.62.62", + "https://::1", + "https://2001:db8::1", + "https://fe80::1%eth0", + "https://[2001:db8::1]", + ).forEach { url -> + assertNull("Expected suggestRedirect(\"$url\") to be null", testee.suggestRedirect(url)) + } + verifyNoInteractions(hostnameResolver) + } + + @Test + fun `when www variant of the host resolves, then returns the suggestion`() = runTest { + whenever(hostnameResolver.resolves("www.example.com")).thenReturn(true) + + assertEquals( + RedirectSuggestion(domain = "www.example.com", url = "https://www.example.com"), + testee.suggestRedirect("https://example.com"), + ) + } + + @Test + fun `when www variant of the host does not resolve, then returns null`() = runTest { + whenever(hostnameResolver.resolves("www.example.com")).thenReturn(false) + + assertNull(testee.suggestRedirect("https://example.com")) + } + + @Test + fun `when redirect is suggested, then the suggested URL keeps the original scheme, path and query`() = runTest { + whenever(hostnameResolver.resolves("www.example.com")).thenReturn(true) + + assertEquals( + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com/path?q=1"), + testee.suggestRedirect("http://example.com/path?q=1"), + ) + } + + @Test + fun `when redirect is suggested, then the suggested URL keeps the original port`() = runTest { + whenever(hostnameResolver.resolves("www.example.com")).thenReturn(true) + + assertEquals( + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com:8080/path"), + testee.suggestRedirect("http://example.com:8080/path"), + ) + } +} From 349de5e93102fa5a441dce0710ebf696a3b28730 Mon Sep 17 00:00:00 2001 From: juandiana Date: Fri, 21 Aug 2026 03:19:00 -0300 Subject: [PATCH 6/6] Add ViewModel logic to determine and show the redirect suggestion - Conditioned to the FF being enabled as well - Note that OMITTED should not alter any state --- .../app/browser/BrowserTabViewModel.kt | 24 ++- .../app/browser/BrowserTabViewModelTest.kt | 161 +++++++++++++++++- 2 files changed, 182 insertions(+), 3 deletions(-) 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 c2e56eedb472..5ca583a10665 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -63,6 +63,7 @@ import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.AppLink import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.NonHttpAppLink import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.ShouldLaunchDuckChatLink import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.ShouldLaunchSubscriptionLink +import com.duckduckgo.app.browser.WebViewErrorResponse.BAD_URL import com.duckduckgo.app.browser.WebViewErrorResponse.LOADING import com.duckduckgo.app.browser.WebViewErrorResponse.OMITTED import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector @@ -222,6 +223,8 @@ import com.duckduckgo.app.browser.progressbar.ProgressBarUpgradeFeature import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender import com.duckduckgo.app.browser.santize.NonHttpAppLinkChecker import com.duckduckgo.app.browser.session.WebViewSessionStorage +import com.duckduckgo.app.browser.suggestredirect.SuggestRedirectEvaluator +import com.duckduckgo.app.browser.suggestredirect.SuggestRedirectOnUnresolvedErrorFeature import com.duckduckgo.app.browser.tabs.TabManager import com.duckduckgo.app.browser.uilock.BROWSER_UI_LOCK_FEATURE_NAME import com.duckduckgo.app.browser.uilock.BrowserUiLockFeature @@ -591,6 +594,8 @@ class BrowserTabViewModel @Inject constructor( private val adBlockingOmnibarAnimationProvider: AdBlockingOmnibarAnimationProvider, private val newTabPageModalPresenterRegistry: NewTabPageModalPresenterRegistry, private val newTabPageModalTrigger: NewTabPageModalTrigger, + private val suggestRedirectOnUnresolvedErrorFeature: SuggestRedirectOnUnresolvedErrorFeature, + private val suggestRedirectEvaluator: SuggestRedirectEvaluator, ) : ViewModel(), WebViewClientListener, EditSavedSiteListener, @@ -742,6 +747,7 @@ class BrowserTabViewModel @Inject constructor( private var autoCompleteJob = ConflatedJob() private var serpLogoJob = ConflatedJob() private var pdfDownloadJob = ConflatedJob() + private var suggestRedirectJob = ConflatedJob() private var site: Site? = null set(value) { @@ -1588,10 +1594,12 @@ class BrowserTabViewModel @Inject constructor( queryOrFullUrl = if (isDuckChatUrl) "" else trimmedInput, forceExpand = true, ) + suggestRedirectJob.cancel() browserViewState.value = currentBrowserViewState().copy( browserShowing = true, browserError = OMITTED, + redirectSuggestion = null, sslError = NONE, maliciousSiteBlocked = false, maliciousSiteStatus = null, @@ -1943,6 +1951,7 @@ class BrowserTabViewModel @Inject constructor( duckChat.endVoiceChatSession(tabId) } pdfDownloadJob.cancel() + suggestRedirectJob.cancel() site = null onSiteChanged() webNavigationState = null @@ -4451,14 +4460,16 @@ class BrowserTabViewModel @Inject constructor( } fun resetBrowserError() { - browserViewState.value = currentBrowserViewState().copy(browserError = OMITTED) + suggestRedirectJob.cancel() + browserViewState.value = currentBrowserViewState().copy(browserError = OMITTED, redirectSuggestion = null) // Catches the race where pageFinished fired while browserError was still LOADING. onDuckAiOnboardingPageFinishedIfApplicable() } fun refreshBrowserError() { + suggestRedirectJob.cancel() if (currentBrowserViewState().browserError != OMITTED && currentBrowserViewState().browserError != LOADING) { - browserViewState.value = currentBrowserViewState().copy(browserError = LOADING) + browserViewState.value = currentBrowserViewState().copy(browserError = LOADING, redirectSuggestion = null) } if (currentBrowserViewState().sslError != NONE) { browserViewState.value = currentBrowserViewState().copy(browserShowing = true, sslError = NONE) @@ -4548,12 +4559,21 @@ class BrowserTabViewModel @Inject constructor( browserViewState.value = currentBrowserViewState().copy( browserError = errorType, + redirectSuggestion = null, showPrivacyShield = HighlightableButton.Visible(enabled = false), ) if (androidBrowserConfig.errorPagePixel().isEnabled()) { pixel.enqueueFire(AppPixelName.ERROR_PAGE_SHOWN) } command.value = WebViewError(errorType, url) + suggestRedirectJob.cancel() // Cancel previous in-flight job as the new errorType might not be BAD_URL + } + if (errorType == BAD_URL && suggestRedirectOnUnresolvedErrorFeature.suggestRedirect().isEnabled()) { + suggestRedirectJob += viewModelScope.launch { + suggestRedirectEvaluator.suggestRedirect(url)?.let { suggestion -> + browserViewState.value = currentBrowserViewState().copy(redirectSuggestion = suggestion) + } + } } if (androidBrowserConfig.errorCodePixel().isEnabled()) { pixel.enqueueFire(AppPixelName.ERROR_CODE_PIXEL, mapOf("error_code" to errorCode)) diff --git a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index ff61f90e0ca0..10804a2a5219 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -74,6 +74,7 @@ import com.duckduckgo.app.browser.SSLErrorType.NONE import com.duckduckgo.app.browser.SSLErrorType.UNTRUSTED_HOST import com.duckduckgo.app.browser.SSLErrorType.WRONG_HOST import com.duckduckgo.app.browser.WebViewErrorResponse.BAD_URL +import com.duckduckgo.app.browser.WebViewErrorResponse.CONNECTION import com.duckduckgo.app.browser.WebViewErrorResponse.LOADING import com.duckduckgo.app.browser.WebViewErrorResponse.OMITTED import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector @@ -149,6 +150,9 @@ import com.duckduckgo.app.browser.progressbar.ProgressBarUpgradeFeature import com.duckduckgo.app.browser.refreshpixels.RefreshPixelSender import com.duckduckgo.app.browser.santize.NonHttpAppLinkChecker import com.duckduckgo.app.browser.session.WebViewSessionStorage +import com.duckduckgo.app.browser.suggestredirect.RedirectSuggestion +import com.duckduckgo.app.browser.suggestredirect.SuggestRedirectEvaluator +import com.duckduckgo.app.browser.suggestredirect.SuggestRedirectOnUnresolvedErrorFeature import com.duckduckgo.app.browser.tabs.TabManager import com.duckduckgo.app.browser.trafficquality.AndroidFeaturesHeaderPlugin.Companion.X_DUCKDUCKGO_ANDROID_HEADER import com.duckduckgo.app.browser.uilock.BROWSER_UI_LOCK_FEATURE_NAME @@ -357,6 +361,7 @@ import com.duckduckgo.subscriptions.api.SubscriptionsJSHelper import com.duckduckgo.sync.api.favicons.FaviconsFetchingPrompt import com.duckduckgo.voice.api.VoiceSearchAvailabilityPixelLogger import dagger.Lazy +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -401,6 +406,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.reset @@ -416,6 +422,7 @@ import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.time.Duration.Companion.seconds import com.duckduckgo.mobile.android.R as CommonR @SuppressLint("DenyListedApi") @@ -719,6 +726,8 @@ class BrowserTabViewModelTest { private val fakeAutocompleteHistoryDeleteFeature = FakeFeatureToggleFactory.create(AutocompleteHistoryDeleteFeature::class.java) private val mockDesktopModeSettings: DesktopModeSettings = mock() private val fakeRememberDesktopModeFeature = FakeFeatureToggleFactory.create(RememberDesktopModeFeature::class.java) + private val fakeSuggestRedirectFeature = FakeFeatureToggleFactory.create(SuggestRedirectOnUnresolvedErrorFeature::class.java) + private val mockSuggestRedirectEvaluator: SuggestRedirectEvaluator = mock() private val mockInlinePdfHandler: InlinePdfHandler = mock() private val mockPdfDownloadTooltipDataStore: PdfDownloadTooltipDataStore = mock() private val mockCachedFileDownloader: CachedFileDownloader = mock() @@ -1058,6 +1067,8 @@ class BrowserTabViewModelTest { adBlockingOmnibarAnimationProvider = mockAdBlockingOmnibarAnimationProvider, newTabPageModalPresenterRegistry = NewTabPageModalPresenterRegistry(), newTabPageModalTrigger = mockNewTabPageModalTrigger, + suggestRedirectOnUnresolvedErrorFeature = fakeSuggestRedirectFeature, + suggestRedirectEvaluator = mockSuggestRedirectEvaluator, ) testee.loadData("abc", null, false, false) @@ -8452,7 +8463,7 @@ class BrowserTabViewModelTest { } @Test - fun whenRedirectSuggestionClickedThenResetsBrowserErrorResetAndNavigateCommandIssuedWithSuggestedUrl() = + fun whenRedirectSuggestionClickedThenBrowserErrorResetAndNavigateCommandIssuedWithSuggestedUrl() = runTest { testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") @@ -8471,6 +8482,154 @@ class BrowserTabViewModelTest { verify(mockSearchCountDao, never()).incrementSearchCount() } + + @Test + fun givenSuggestRedirectEnabledWhenBadUrlErrorReceivedAndRedirectShouldBeSuggestedThenRedirectSuggestionSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + val redirectSuggestion = RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com/path?q=1") + whenever(mockSuggestRedirectEvaluator.suggestRedirect("http://example.com/path?q=1")) + .thenReturn(redirectSuggestion) + + testee.onReceivedError(BAD_URL, "http://example.com/path?q=1", "ERROR_HOST_LOOKUP") + + assertEquals(redirectSuggestion, browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectDisabledWhenBadUrlErrorReceivedThenRedirectSuggestionNotSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = false)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())) + .thenReturn(RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com")) + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEnabledWhenBadUrlErrorReceivedAndRedirectShouldNotBeSuggestedThenRedirectSuggestionNotSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())) + .thenReturn(null) + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEnabledWhenNonBadUrlErrorReceivedThenRedirectSuggestionNotSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())) + .thenReturn(RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com")) + + testee.onReceivedError(CONNECTION, "http://example.com", "ERROR_CONNECT") + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEvaluationInFlightWhenBrowserErrorResetThenRedirectSuggestionNotSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())) + .doSuspendableAnswer { + delay(1.seconds) + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com") + } + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + testee.resetBrowserError() + @OptIn(ExperimentalCoroutinesApi::class) + advanceUntilIdle() + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEvaluationInFlightWhenNewErrorReceivedThenPreviousRedirectSuggestionNotSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())) + .doSuspendableAnswer { + delay(1.seconds) + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com") + } + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + testee.onReceivedError(CONNECTION, "http://example.com", "ERROR_CONNECT") + @OptIn(ExperimentalCoroutinesApi::class) + advanceUntilIdle() + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEvaluationInFlightWhenBrowserErrorRefreshedThenRedirectSuggestionNotSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())).doSuspendableAnswer { + delay(1.seconds) + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com") + } + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + testee.refreshBrowserError() + @OptIn(ExperimentalCoroutinesApi::class) + advanceUntilIdle() + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEvaluationInFlightWhenUserSubmittedQueryThenRedirectSuggestionNotSetInViewState() = runTest { + whenever(mockOmnibarConverter.convertQueryToUrl("http://another-site.com", null)).thenReturn("http://another-site.com") + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())).doSuspendableAnswer { + delay(1.seconds) + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com") + } + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + testee.onUserSubmittedQuery("http://another-site.com") + @OptIn(ExperimentalCoroutinesApi::class) + advanceUntilIdle() + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEvaluationInFlightWhenUserNavigatesHomeThenRedirectSuggestionNotSetInViewState() = + runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())).doSuspendableAnswer { + delay(1.seconds) + RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com") + } + setupNavigation(isBrowsing = true) + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + testee.onUserPressedBack() + @OptIn(ExperimentalCoroutinesApi::class) + advanceUntilIdle() + + assertNull(browserViewState().redirectSuggestion) + } + + @Test + fun givenSuggestRedirectEvaluationInFlightWhenOmittedErrorReceivedThenRedirectSuggestionStillSetInViewState() = runTest { + fakeSuggestRedirectFeature.suggestRedirect().setRawStoredState(State(enable = true)) + val redirectSuggestion = RedirectSuggestion(domain = "www.example.com", url = "http://www.example.com") + whenever(mockSuggestRedirectEvaluator.suggestRedirect(any())).doSuspendableAnswer { + delay(1.seconds) + redirectSuggestion + } + + testee.onReceivedError(BAD_URL, "http://example.com", "ERROR_HOST_LOOKUP") + testee.onReceivedError(OMITTED, "http://example.com", "ERROR_UNKNOWN") + @OptIn(ExperimentalCoroutinesApi::class) + advanceUntilIdle() + + assertEquals(redirectSuggestion, browserViewState().redirectSuggestion) + } + @Test fun whenUserSelectedAutocompleteWithAutoCompleteSwitchToTabSuggestionThenSwitchToTabCommandSentWithTabId() = runTest {