Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -6131,6 +6153,8 @@ class BrowserTabFragment :
showHome()
}
}
} else if (redirectSuggestionChanged) {
renderRedirectSuggestion(viewState.redirectSuggestion)
}

omnibar.renderBrowserViewState(viewState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1943,6 +1951,7 @@ class BrowserTabViewModel @Inject constructor(
duckChat.endVoiceChatSession(tabId)
}
pdfDownloadJob.cancel()
suggestRedirectJob.cancel()
site = null
onSiteChanged()
webNavigationState = null
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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() ?: ""

Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/res/layout/include_error_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,22 @@
app:textType="secondary"
tools:text="@string/webViewErrorBadUrl" />

<com.duckduckgo.common.ui.view.text.DaxTextView
android:id="@+id/redirect_suggestion_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"
android:gravity="center"
android:paddingBottom="@dimen/keyline_5"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/error_message"
app:typography="body1"
app:textType="secondary"
tools:text="@string/webViewErrorRedirectSuggestionMessage"
tools:visibility="visible" />

</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
1 change: 1 addition & 0 deletions app/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<string name="webViewErrorNoConnection">The internet connection appears to be offline.</string>
<string name="webViewErrorBadUrl">A server with the specified hostname could not be found.</string>
<string name="webViewErrorSslProtocol"><![CDATA[The certificate for this server is invalid. You might be connecting to a server that is pretending to be <b>%1$s</b> which could put your confidential information at risk.]]></string>
<string name="webViewErrorRedirectSuggestionMessage" instruction="Shown when a domain fails to load, offering a link to the www. version of the address. ^1 is the suggested address and is displayed as a tappable link.">Did you mean <annotation type="redirect_link">^1</annotation>?</string>

<!-- Broken Sites-->
<string name="brokenSitesLoginHint">What site are you signing in to? (required)</string>
Expand Down
Loading
Loading