Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
🤖 Prevent navigation outside the domain details webview
As documented in pc8HXX-1gR-p2:
Users should not be able to navigate to URLs outside of the domain details context. These URLs should be intercepted and opened in the device’s browser instead.

related PR: https://github.com/wordpress-mobile/WordPress-Android/pull/19447


Discussion hints:
nan

Pull request: 🤖 Prevent navigation outside the domain details webview enhancements
Fixes #19501

## Description
This PR enhances the implementation of https://github.com/wordpress-mobile/WordPress-Android/pull/19570 aligning with our internal discussion (ref p1699462442594229-slack-C05NS0YV7HS). With this:
* Navigation is only allowed in the `https://wordpress.com/domains/manage/all/some.domain/edit/` scope
* All other urls are loaded in the external browser
* Due to limitations with intercepting url loading (in `shouldOverrideUrlLoading`) when there is no actual page loading (e.g. with JS) it monitors url changes and redirects in those cases too https://github.com/wordpress-mobile/WordPress-Android/pull/19585/commits/715966fd42bb0e1d2862221dc13fe004e032a958

## To test:
1. Enable the `domain_management` feature flag
2. Open the domain management (`Me>Domains` or `My Site>More>Domains>ALL)
3. Tap on a domain
4. **Verify** that the details page opens
5. Try to browse outside this page
6. **Verify** that the link opens in an external browser

https://github.com/wordpress-mobile/WordPress-Android/assets/304044/84e998d5-2b4a-482b-8177-7564b8bf06e2

## Regression Notes
1. Potential unintended areas of impact
N/A

2. What I did to test those areas of impact (or what existing automated tests I relied on)
N/A

3. What automated tests I added (or what prevented me from doing so)
Updated existing tests and added `DomainManagementDetailsWebViewClientTest`

PR submission checklist:

- [ ] I have completed the Regression Notes.
- [ ] I have considered adding accessibility improvements for my changes.
- [ ] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

UI Changes testing checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)

Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.domains.management.details.DomainManagementDetailsWebViewClientTest" --tests "org.wordpress.android.ui.domains.management.DomainManagementViewModelTest" --tests "org.wordpress.android.ui.domains.management.details.DomainManagementDetailsWebViewNavigationDelegateTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementActivity.kt
index 6574c602cb0f..f439e6baf1db 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementActivity.kt
@@ -41,7 +41,13 @@ class DomainManagementActivity : AppCompatActivity() {
     private fun handleActionEvents(actionEvent: ActionEvent) {
         when (actionEvent) {
             is ActionEvent.DomainTapped -> {
-                startActivity(DomainManagementDetailsActivity.createIntent(this, actionEvent.detailUrl))
+                startActivity(
+                    DomainManagementDetailsActivity.createIntent(
+                        this,
+                        actionEvent.domain,
+                        actionEvent.detailUrl
+                    )
+                )
             }
             is ActionEvent.AddDomainTapped -> ActivityLauncher.openNewDomainSearch(this)
             is ActionEvent.NavigateBackTapped -> onBackPressedDispatcher.onBackPressed()
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementViewModel.kt
index ec08a99881d5..91f3cae374a1 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainManagementViewModel.kt
@@ -83,10 +83,10 @@ class DomainManagementViewModel @Inject constructor(
         }
     }
 
-    fun onDomainTapped(detailUrl: String) {
+    fun onDomainTapped(domain: String, detailUrl: String) {
         analyticsTracker.track(Stat.DOMAIN_MANAGEMENT_MY_DOMAINS_SCREEN_DOMAIN_TAPPED)
         launch {
-            _actionEvents.emit(ActionEvent.DomainTapped(detailUrl))
+            _actionEvents.emit(ActionEvent.DomainTapped(domain, detailUrl))
         }
     }
 
@@ -117,7 +117,7 @@ class DomainManagementViewModel @Inject constructor(
     }
 
     sealed class ActionEvent {
-        data class DomainTapped(val detailUrl: String): ActionEvent()
+        data class DomainTapped(val domain: String, val detailUrl: String): ActionEvent()
         object AddDomainTapped: ActionEvent()
         object NavigateBackTapped: ActionEvent()
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainsListCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainsListCard.kt
index 4c593f1bab72..2bc7ebab5e00 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainsListCard.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/DomainsListCard.kt
@@ -35,14 +35,14 @@ import java.util.Date
 @Composable
 fun DomainListCard(
     uiState: DomainCardUiState,
-    onDomainTapped: (detailUrl: String) -> Unit = {},
+    onDomainTapped: (domain: String, detailUrl: String) -> Unit = { _: String, _: String -> },
     ) {
     OutlinedCard(
         modifier = Modifier.fillMaxWidth(),
         border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
         onClick = {
             if (uiState is DomainCardUiState.Loaded && uiState.detailUrl != null) {
-                onDomainTapped(uiState.detailUrl)
+                onDomainTapped(uiState.domain ?: "", uiState.detailUrl)
             }
         },
     ) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/MyDomainsScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/MyDomainsScreen.kt
index dbc9347b68d3..28d247f1a482 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/MyDomainsScreen.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/MyDomainsScreen.kt
@@ -47,7 +47,7 @@ import org.wordpress.android.ui.domains.management.composable.PrimaryButton
 fun MyDomainsScreen(
     uiState: UiState,
     onSearchQueryChanged: (String) -> Unit,
-    onDomainTapped: (detailUrl: String) -> Unit,
+    onDomainTapped: (String, String) -> Unit,
     onAddDomainTapped: () -> Unit,
     onFindDomainTapped: () -> Unit,
     onBackTapped: () -> Unit,
@@ -158,7 +158,7 @@ fun MyDomainsSearchInput(
 fun MyDomainsList(
     listUiState: PopulatedList,
     listState: LazyListState,
-    onDomainTapped: (detailUrl: String) -> Unit,
+    onDomainTapped: (domain: String, detailUrl: String) -> Unit,
 ) {
     LazyColumn(
         verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -198,11 +198,10 @@ fun PreviewMyDomainsScreen() {
             uiState = PopulatedList.Initial,
             onSearchQueryChanged = {},
             onAddDomainTapped = {},
-            onDomainTapped = {},
+            onDomainTapped = { _, _ ->},
             onFindDomainTapped = {},
             onBackTapped = {},
-            onRefresh = {},
-        )
+        ) {}
     }
 }
 
@@ -215,11 +214,10 @@ fun PreviewMyDomainsScreenError() {
             uiState = Error,
             onSearchQueryChanged = {},
             onAddDomainTapped = {},
-            onDomainTapped = {},
+            onDomainTapped = { _, _ ->},
             onFindDomainTapped = {},
             onBackTapped = {},
-            onRefresh = {},
-        )
+        ) {}
     }
 }
 
@@ -232,10 +230,9 @@ fun PreviewMyDomainsScreenEmpty() {
             uiState = Empty,
             onSearchQueryChanged = {},
             onAddDomainTapped = {},
-            onDomainTapped = {},
+            onDomainTapped = { _, _ ->},
             onFindDomainTapped = {},
             onBackTapped = {},
-            onRefresh = {},
-        )
+        ) {}
     }
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsActivity.kt
index 2f43f918a0fc..657ce7e8217e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsActivity.kt
@@ -9,6 +9,8 @@ import org.wordpress.android.ui.WPWebViewActivity
 
 class DomainManagementDetailsActivity : WPWebViewActivity(),
     DomainManagementDetailsWebViewClient.DomainManagementWebViewClientListener {
+    private val domainArg: String get() = intent.getStringExtra(PICKED_DOMAIN_KEY) ?: error("Domain cannot be null.")
+
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         toggleNavbarVisibility(false)
@@ -19,18 +21,23 @@ class DomainManagementDetailsActivity : WPWebViewActivity(),
         return true
     }
 
-    override fun createWebViewClient(allowedURL: List<String>?) = DomainManagementDetailsWebViewClient(this)
+    override fun createWebViewClient(allowedURL: List<String>?) =
+        DomainManagementDetailsWebViewClient(DomainManagementDetailsWebViewNavigationDelegate(domainArg), this)
 
     override fun onRedirectToExternalBrowser(url: String) {
         ActivityLauncher.openUrlExternal(this, url)
+        onBackPressedDispatcher.onBackPressed()
     }
 
     companion object {
-        fun createIntent(context: Context, domainDetailUrl: String): Intent =
+        const val PICKED_DOMAIN_KEY: String = "picked_domain_key"
+
+        fun createIntent(context: Context, domain: String, domainDetailUrl: String): Intent =
             Intent(context, DomainManagementDetailsActivity::class.java).apply {
                 putExtra(USE_GLOBAL_WPCOM_USER, true)
                 putExtra(AUTHENTICATION_URL, WPCOM_LOGIN_URL)
                 putExtra(URL_TO_LOAD, domainDetailUrl)
+                putExtra(PICKED_DOMAIN_KEY, domain)
             }
     }
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClient.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClient.kt
index aa0638719a67..b613d899983b 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClient.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClient.kt
@@ -2,20 +2,25 @@ package org.wordpress.android.ui.domains.management.details
 
 import android.webkit.WebResourceRequest
 import android.webkit.WebView
-import org.wordpress.android.ui.domains.management.details.DomainManagementDetailsWebViewNavigationDelegate.toUrl
 import org.wordpress.android.util.ErrorManagedWebViewClient
 
 class DomainManagementDetailsWebViewClient(
+    private val navigationDelegate: DomainManagementDetailsWebViewNavigationDelegate,
     private val listener: DomainManagementWebViewClientListener
 ) : ErrorManagedWebViewClient(listener) {
-    private val navigationDelegate = DomainManagementDetailsWebViewNavigationDelegate
-
     interface DomainManagementWebViewClientListener : ErrorManagedWebViewClientListener {
         fun onRedirectToExternalBrowser(url: String)
     }
 
+    override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
+        super.doUpdateVisitedHistory(view, url, isReload)
+        if (url != null && url != "about:blank" && !navigationDelegate.canNavigateTo(url)) {
+            listener.onRedirectToExternalBrowser(url)
+        }
+    }
+
     override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) : Boolean {
-        if (navigationDelegate.canNavigateTo(request.url.toUrl())) return false
+        if (navigationDelegate.canNavigateTo(request.url)) return false
         listener.onRedirectToExternalBrowser(request.url.toString())
         return true
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegate.kt b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegate.kt
index 3d031e49b236..0b0569b476d9 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegate.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegate.kt
@@ -2,11 +2,12 @@ package org.wordpress.android.ui.domains.management.details
 
 import org.wordpress.android.ui.utils.AbstractAllowedUrlsWebViewNavigationDelegate
 
-object DomainManagementDetailsWebViewNavigationDelegate: AbstractAllowedUrlsWebViewNavigationDelegate() {
+class DomainManagementDetailsWebViewNavigationDelegate(domain: String) :
+    AbstractAllowedUrlsWebViewNavigationDelegate() {
     override val allowedUrls = listOf(
         UrlMatcher(
             "wordpress.com".toRegex(),
-            listOf("/domains.*".toRegex())
+            listOf("/domains/manage/all/$domain/edit/.*".toRegex())
         )
     )
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/utils/AbstractAllowedUrlsWebViewNavigationDelegate.kt b/WordPress/src/main/java/org/wordpress/android/ui/utils/AbstractAllowedUrlsWebViewNavigationDelegate.kt
index f2dfa32e885c..ec49ad9483da 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/utils/AbstractAllowedUrlsWebViewNavigationDelegate.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/utils/AbstractAllowedUrlsWebViewNavigationDelegate.kt
@@ -7,6 +7,10 @@ abstract class AbstractAllowedUrlsWebViewNavigationDelegate {
 
     fun canNavigateTo(url: Url) = allowedUrls.any { it.matches(url) }
 
+    fun canNavigateTo(uri: Uri) = canNavigateTo(uri.toUrl())
+
+    fun canNavigateTo(urlString: String) = canNavigateTo(Uri.parse(urlString))
+
     data class UrlMatcher(
         private val host: Regex,
         private val paths: List<Regex>


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/DomainManagementViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/DomainManagementViewModelTest.kt
index 310dc09d36f2..4650821f6a1f 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/DomainManagementViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/DomainManagementViewModelTest.kt
@@ -58,15 +58,15 @@ class DomainManagementViewModelTest : BaseUnitTest() {
     @Test
     fun `WHEN a domain is tapped THEN track DOMAIN_MANAGEMENT_MY_DOMAINS_SCREEN_DOMAIN_TAPPED event`() = test {
         initializeViewModel()
-        viewModel.onDomainTapped(testDomain)
+        viewModel.onDomainTapped(testDomain, testDomainDetailUrl)
         verify(analyticsTracker).track(DOMAIN_MANAGEMENT_MY_DOMAINS_SCREEN_DOMAIN_TAPPED)
     }
 
     @Test
     fun `WHEN a domain is tapped THEN send DomainTapped action event`() = testWithActionEvents { events ->
-        viewModel.onDomainTapped(testDomain)
+        viewModel.onDomainTapped(testDomain, testDomainDetailUrl)
         advanceUntilIdle()
-        assertThat(events.last()).isEqualTo(ActionEvent.DomainTapped(testDomain))
+        assertThat(events.last()).isEqualTo(ActionEvent.DomainTapped(testDomain, testDomainDetailUrl))
     }
 
     @Test
@@ -175,5 +175,6 @@ class DomainManagementViewModelTest : BaseUnitTest() {
 
     companion object {
         private const val testDomain = "domain"
+        private const val testDomainDetailUrl = "domainDetailUrl"
     }
 }
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClientTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClientTest.kt
new file mode 100644
index 000000000000..32f73268fdb1
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewClientTest.kt
@@ -0,0 +1,93 @@
+package org.wordpress.android.ui.domains.management.details
+
+import android.net.Uri
+import android.webkit.WebResourceRequest
+import android.webkit.WebView
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.any
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+
+@ExperimentalCoroutinesApi
+class DomainManagementDetailsWebViewClientTest : BaseUnitTest() {
+    @Mock
+    private lateinit var navigationDelegate: DomainManagementDetailsWebViewNavigationDelegate
+
+    @Mock
+    private lateinit var listener: DomainManagementDetailsWebViewClient.DomainManagementWebViewClientListener
+
+    @Mock
+    private lateinit var webView: WebView
+
+    @Mock
+    private lateinit var request: WebResourceRequest
+
+    @Mock
+    private lateinit var uri: Uri
+
+    private lateinit var client: DomainManagementDetailsWebViewClient
+
+    @Before
+    fun setup() {
+        MockitoAnnotations.openMocks(this)
+        client = DomainManagementDetailsWebViewClient(navigationDelegate, listener)
+    }
+
+    @Test
+    fun `WHEN browsing to a not allowed domain THEN redirect to the external browser`() {
+        val url = "https://some.domain"
+        whenever(navigationDelegate.canNavigateTo(url)).thenReturn(false)
+        client.doUpdateVisitedHistory(null, url, false)
+        verify(listener, times(1)).onRedirectToExternalBrowser(url)
+    }
+
+    @Test
+    fun `WHEN browsing to an allowed domain THEN do not redirect to the external browser`() {
+        val url = "https://some.domain"
+        whenever(navigationDelegate.canNavigateTo(url)).thenReturn(true)
+        client.doUpdateVisitedHistory(null, url, false)
+        verify(listener, times(0)).onRedirectToExternalBrowser(url)
+    }
+
+    @Test
+    fun `WHEN browsing to the blank domain THEN do not redirect to the external browser`() {
+        val url = "about:blank"
+        client.doUpdateVisitedHistory(null, url, false)
+        verify(listener, times(0)).onRedirectToExternalBrowser(url)
+    }
+
+    @Test
+    fun `WHEN browsing to a null domain THEN do not redirect to the external browser`() {
+        val url = null
+        client.doUpdateVisitedHistory(null, url, false)
+        verify(listener, times(0)).onRedirectToExternalBrowser(any())
+    }
+
+    @Test
+    fun `WHEN preparing to browse to a not allowed domain THEN redirect to the external browser`() {
+        val url = "https://some.domain"
+        whenever(uri.toString()).thenReturn(url)
+        whenever(request.url).thenReturn(uri)
+        whenever(navigationDelegate.canNavigateTo(uri)).thenReturn(false)
+        val actual = client.shouldOverrideUrlLoading(webView, request)
+        verify(listener, times(1)).onRedirectToExternalBrowser(url)
+        assertThat(actual).isTrue
+    }
+
+    @Test
+    fun `WHEN preparing to browse to an allowed domain THEN do not redirect to the external browser`() {
+        val url = "https://some.domain"
+        whenever(request.url).thenReturn(uri)
+        whenever(navigationDelegate.canNavigateTo(uri)).thenReturn(true)
+        val actual = client.shouldOverrideUrlLoading(webView, request)
+        verify(listener, times(0)).onRedirectToExternalBrowser(url)
+        assertThat(actual).isFalse
+    }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegateTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegateTest.kt
index f0db5052645f..f69352caa8ae 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegateTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/domains/management/details/DomainManagementDetailsWebViewNavigationDelegateTest.kt
@@ -8,14 +8,13 @@ import org.wordpress.android.ui.utils.AbstractAllowedUrlsWebViewNavigationDelega
 
 @ExperimentalCoroutinesApi
 class DomainManagementDetailsWebViewNavigationDelegateTest : BaseUnitTest() {
-    private val navigationDelegate = DomainManagementDetailsWebViewNavigationDelegate
+    private val navigationDelegate = DomainManagementDetailsWebViewNavigationDelegate("some.domain")
 
     @Test
-    fun `when browsing in the domains path, then the web view can navigate`() {
+    fun `when browsing in the domain details edit path, then the web view can navigate`() {
         Assertions.assertThat(
             buildUrls(
-                "/domains/manage/all/some.domain/edit/some.domain", // standard details page
-                "/domains/mapping/some.domain/setup/some.domain?step=&show-errors=false&firstVisit=false" // fix errors
+                "/domains/manage/all/some.domain/edit/some.slug", // standard details page
             )
         ).allMatch {
             navigationDelegate.canNavigateTo(it)
@@ -23,10 +22,12 @@ class DomainManagementDetailsWebViewNavigationDelegateTest : BaseUnitTest() {
     }
 
     @Test
-    fun `when browsing outside the domains path, then the web view cannot navigate`() {
+    fun `when browsing outside the domain details edit path, then the web view cannot navigate`() {
         Assertions.assertThat(
             buildUrls(
-                "/email/antonis.me/manage/some.domain", // setup email
+                "/domains/manage/all/some.domain/dns/some.slug", // dns page
+                "/domains/mapping/some.domain/setup/some.domain?step=&show-errors=false&firstVisit=false",// some errors
+                "/email/some.domain/manage/some.domain", // setup email
                 "/support/domains/https-ssl/" // support
             )
         ).noneMatch {