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..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
@@ -1314,6 +1318,7 @@ class BrowserTabFragment :
errorView.yetiIcon.isSaveEnabled = false
errorView.errorTitle.isSaveEnabled = false
errorView.errorMessage.isSaveEnabled = false
+ errorView.redirectSuggestionMessage.isSaveEnabled = false
omnibar.disableViewStateSaving()
sslErrorView.disableViewStateSaving()
@@ -2373,6 +2378,7 @@ class BrowserTabFragment :
newBrowserTab.newTabRootLayout.gone()
sslErrorView.gone()
maliciousWarningView.gone()
+ renderRedirectSuggestion(viewModel.browserViewState.value?.redirectSuggestion)
hidePdf()
omnibar.setViewMode(ViewMode.Error)
webView?.onPause()
@@ -2388,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)
@@ -6104,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
@@ -6131,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/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt
index 7c713d6efca1..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))
@@ -5797,6 +5817,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/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/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,
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)
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..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)
@@ -8451,6 +8462,174 @@ class BrowserTabViewModelTest {
assertCommandIssued()
}
+ @Test
+ fun whenRedirectSuggestionClickedThenBrowserErrorResetAndNavigateCommandIssuedWithSuggestedUrl() =
+ 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 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 {
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"),
+ )
+ }
+}