Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
76c032e
fix(android): inject the editor globals into the editor document only
dcalhoun Sep 24, 2026
80a1b4b
fix(android): match the REST API by its configured root, not by subst…
dcalhoun Sep 25, 2026
521f100
fix(android): match a rest_route API root by its query alone
dcalhoun Sep 25, 2026
b8e30a9
fix(android): match the API root path by whole path segments
dcalhoun Sep 25, 2026
9283d5d
fix(android): ignore an empty rest_route when matching the REST API
dcalhoun Sep 25, 2026
26b903d
test(android): assert nothing reaches a non-editor page on start
dcalhoun Sep 25, 2026
2a238d4
fix(android): match editor assets by the scheme the asset loader serves
dcalhoun Sep 25, 2026
9b40746
docs(android): note that only the editor document receives the globals
dcalhoun Sep 25, 2026
4a3ee13
fix(android): match the editor document by its exact asset path
dcalhoun Sep 25, 2026
baa02f5
fix(android): let a rest_route parameter decide over the API root path
dcalhoun Sep 25, 2026
39894ad
fix(android): open REST API navigations in the browser
dcalhoun Sep 25, 2026
deec3aa
test(android): explain how a site page still reaches the editor frame
dcalhoun Sep 25, 2026
27cb91b
test(android): assert a non-editor page leaves the upload server down
dcalhoun Sep 25, 2026
a219102
refactor(android): set the asset scheme before installing the WebView…
dcalhoun Sep 25, 2026
4c9b0cd
docs(android): correct why isAssetUrl matches one scheme
dcalhoun Sep 28, 2026
9125440
Merge remote-tracking branch 'origin/trunk' into fix/android-scope-co…
dcalhoun Sep 30, 2026
9c5d871
fix(android): compare origin hosts case-insensitively
dcalhoun Sep 30, 2026
6cff4cc
refactor(android): recognize the editor document by the URL it loaded
dcalhoun Sep 30, 2026
a211496
test(android): cover injection after a reload and on a local http site
dcalhoun Sep 30, 2026
83346fb
test(android): build one view per navigation test
dcalhoun Sep 30, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class GutenbergView : FrameLayout {
private var hasAutofocused = false
private lateinit var assetLoader: WebViewAssetLoader
private lateinit var assetAuthority: String
private lateinit var assetScheme: String
private val configuration: EditorConfiguration
private lateinit var dependencies: EditorDependencies

Expand Down Expand Up @@ -449,7 +450,7 @@ class GutenbergView : FrameLayout {

override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
onEditorPageStarted()
onEditorPageStarted(url)
}

override fun shouldInterceptRequest(
Expand Down Expand Up @@ -501,28 +502,17 @@ class GutenbergView : FrameLayout {
// Allow asset URLs (restrict to the asset path prefix so that
// arbitrary site pages don't load inside the WebView when the
// asset authority matches the site authority)
if (url.authority == assetAuthority && url.path?.startsWith("/assets/") == true) {
if (isAssetUrl(url)) {
return false
}

// Allow WordPress.com REST API
if (url.host == "public-api.wordpress.com") {
return false
}

// Allow WordPress REST API
if (url.authority == originAuthority(configuration.siteApiRoot)) {
if (url.path?.contains("/wp-json/") == true || url.query?.contains("rest_route=") == true) {
return false
}
}

// Allow local development server if configured
if (isDevServerUrl(url, BuildConfig.GUTENBERG_EDITOR_URL)) {
return false
}

// For all other URLs, open in external browser
// For all other URLs, open in external browser. This includes the site's
// REST API: the editor reaches it by fetch, which never passes through here.
val intent = Intent(Intent.ACTION_VIEW, url)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
view?.context?.startActivity(intent)
Expand Down Expand Up @@ -611,6 +601,15 @@ class GutenbergView : FrameLayout {
}
}

/**
* Whether [url] is an asset [assetLoader] serves over the scheme the editor loads
* with. On an https site, http on the same authority reaches the site instead.
*/
private fun isAssetUrl(url: Uri): Boolean =
url.scheme == assetScheme &&
url.authority == assetAuthority &&
url.path?.startsWith("/assets/") == true

/**
* Loads the editor with the given dependencies.
*
Expand Down Expand Up @@ -641,6 +640,7 @@ class GutenbergView : FrameLayout {
// avoid accidentally downgrading asset traffic for production sites.
val siteUri = Uri.parse(configuration.siteURL)
val isLocalHttpSite = siteUri.scheme == "http" && siteUri.host in LOCAL_HOSTS
assetScheme = if (isLocalHttpSite) "http" else "https"
assetLoader = WebViewAssetLoader.Builder()
.setDomain(assetAuthority)
.setHttpAllowed(isLocalHttpSite)
Expand All @@ -652,8 +652,7 @@ class GutenbergView : FrameLayout {

initializeWebView()

val scheme = if (isLocalHttpSite) "http" else "https"
val assetUrl = "$scheme://$assetAuthority$ASSET_PATH_INDEX"
val assetUrl = "$assetScheme://$assetAuthority$ASSET_PATH_INDEX"
val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty {
assetUrl
}
Expand All @@ -678,27 +677,53 @@ class GutenbergView : FrameLayout {
}

/**
* Invoked when the editor page begins loading. Starts the upload server once —
* capturing the [mediaUploadDelegate] provided before load — then advertises
* the editor globals (including the server's port and token) to the page.
* Invoked when any page begins loading in the main frame. Resets readiness for
* every page; for the editor document alone, starts the upload server once —
* capturing the [mediaUploadDelegate] provided before load — then advertises the
* editor globals (including the server's port and token).
*
* Starting the server here, on the UI thread, rather than from the
* [mediaUploadDelegate] setter keeps its whole lifecycle — start here, stop in
* [onDetachedFromWindow] — on the UI thread, so it can't race a
* background-thread delegate assignment.
*/
private fun onEditorPageStarted() {
private fun onEditorPageStarted(url: String?) {
// Readiness belongs to the page: a new page, including one a reload starts,
// is not ready until it reports `onEditorLoaded`.
isEditorLoaded = false
didFireEditorLoaded = false

// The globals carry the site credential and the upload server's token, so
// they go to the editor document alone. `shouldOverrideUrlLoading` admits
// other pages into this frame, and on Android the editor shares an origin
// with the site, so the destination is checked rather than assumed.
if (!isEditorUrl(url)) return

if (!hasStartedLoading) {
hasStartedLoading = true
startUploadServer()
}
setGlobalJavaScriptVariables()
}

/**
* Whether [url] is the editor document this view loaded.
*
* A configured dev server replaces the bundled assets as the editor, mirroring
* the URL [loadEditor] chooses, so only one of the two can match.
*/
private fun isEditorUrl(url: String?): Boolean {
if (url.isNullOrEmpty()) return false
val uri = Uri.parse(url)

if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) {
return isDevServerUrl(uri, BuildConfig.GUTENBERG_EDITOR_URL)
}

// The host app's own bundled pages are asset URLs too, but not the editor.
return isAssetUrl(uri) && uri.path == ASSET_PATH_INDEX
}

private fun setGlobalJavaScriptVariables() {
val gbKit = GBKitGlobal.fromConfiguration(
configuration,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package org.wordpress.gutenberg

import android.net.Uri
import android.webkit.WebResourceRequest
import kotlinx.coroutines.test.TestScope
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorDependencies

/**
* What the editor's main frame admits, and which document the editor globals reach.
*
* On Android the editor loads from the site's own origin, so the site's ordinary
* pages are one navigation away from the frame holding the site credential.
*/
@RunWith(RobolectricTestRunner::class)
class GutenbergViewNavigationTest {

private val testScope = TestScope()

/** A view whose configuration carries a recognizable credential. */
private fun configuredSiteView() = GutenbergView(
EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/")
.setAuthHeader("Bearer secret-credential")
.build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

/**
* The editor document for [siteUrl], mirroring the fallback in `loadEditor` so
* this holds whether or not a local `GUTENBERG_EDITOR_URL` dev server is set.
*/
private fun editorUrlFor(siteUrl: String) = BuildConfig.GUTENBERG_EDITOR_URL
.ifEmpty { "$siteUrl/assets/index.html" }

private fun opensExternally(view: GutenbergView, url: String): Boolean {
val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse(url))
return view.editorWebView.webViewClient.shouldOverrideUrlLoading(view.editorWebView, request)
}

@Test
fun `shouldOverrideUrlLoading opens REST API URLs externally`() {
// The editor reaches the API by fetch, which never navigates the frame.
listOf(
"https://example.com/wp-json/wp/v2/posts",
"https://example.com/?rest_route=/wp/v2/posts",
"https://public-api.wordpress.com/wp/v2/sites/123/posts"
).forEach { url ->
assertTrue("$url should open externally", opensExternally(configuredSiteView(), url))
}
}

@Test
fun `shouldOverrideUrlLoading opens site pages that resemble the REST API externally`() {
// WordPress serves each of these with the site's theme and plugins.
listOf(
"https://example.com/blog/wp-json/a-post",
"https://example.com/a-page/?utm_campaign=rest_route=x",
"https://example.com/wp-json/?rest_route=",
"https://example.com/a-page/?rest_route=/wp/v2/posts&rest_route=",
"http://example.com/wp-json/wp/v2/posts"
).forEach { url ->
assertTrue("$url should open externally", opensExternally(configuredSiteView(), url))
}
}

@Test
fun `shouldOverrideUrlLoading blocks asset paths over a scheme the asset loader does not serve`() {
// An https site's assets are served over https alone, so the same path over
// http goes to the site over the network.
val result = opensExternally(configuredSiteView(), "http://example.com/assets/index.html")

assertTrue("an asset path over the other scheme should open externally", result)
}

@Test
fun `onPageStarted injects the configuration into the editor document`() {
val siteView = configuredSiteView()
val webView = siteView.editorWebView

webView.webViewClient.onPageStarted(webView, editorUrlFor("https://example.com"), null)

assertTrue(
"the editor document should receive the globals it boots from",
shadowOf(webView).lastEvaluatedJavascript.orEmpty().contains("window.GBKit")
)
}

@Test
fun `onPageStarted withholds the configuration from a non-editor page`() {
// Some loads never pass `shouldOverrideUrlLoading` (POST forms, history, a
// host's `loadUrl`), so a site page can still reach this frame. It must not
// receive the credential.
val siteView = configuredSiteView()
val webView = siteView.editorWebView

webView.webViewClient.onPageStarted(webView, "https://example.com/wp-json/wp/v2/posts", null)

// Nothing evaluated at all, so an injection followed by another script still fails.
assertNull(
"a non-editor page must not receive the site credential",
shadowOf(webView).lastEvaluatedJavascript
)
}

@Test
fun `onPageStarted withholds the configuration from another bundled asset page`() {
// The asset loader serves every page the host app bundles, not only the editor.
val siteView = configuredSiteView()
val webView = siteView.editorWebView

webView.webViewClient.onPageStarted(webView, "https://example.com/assets/support.html", null)

assertNull(
"a bundled page other than the editor must not receive the site credential",
shadowOf(webView).lastEvaluatedJavascript
)
}

@Test
fun `onPageStarted withholds the configuration from an asset path the network served`() {
val siteView = configuredSiteView()
val webView = siteView.editorWebView

webView.webViewClient.onPageStarted(webView, "http://example.com/assets/index.html", null)

assertNull(
"a network-served page must not receive the site credential",
shadowOf(webView).lastEvaluatedJavascript
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -332,82 +332,6 @@ class GutenbergViewTest {
assertFalse(GutenbergView.isDevServerUrl(Uri.parse("tel:5551234"), "10.0.2.2:5173"))
}

// ===== REST API navigation =====

@Test
fun `shouldOverrideUrlLoading allows REST API URLs on the site's API root`() {
// Callers pass a full API root with a path, e.g. WordPress-Android's
// `site.wpApiRestUrl ?: "${site.url}/wp-json/"`.
val siteView = GutenbergView(
EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/")
.build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse("https://example.com/wp-json/wp/v2/posts"))

val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request)
assertFalse("REST API URLs on the site's API root should load in the WebView", result)
}

@Test
fun `shouldOverrideUrlLoading allows REST API URLs for a rest_route API root`() {
val siteView = GutenbergView(
EditorConfiguration.builder(
"https://example.com",
"https://example.com/index.php?rest_route=/"
).build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(
Uri.parse("https://example.com/index.php?rest_route=/wp/v2/posts")
)

val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request)
assertFalse("rest_route REST API URLs should load in the WebView", result)
}

@Test
fun `shouldOverrideUrlLoading blocks REST API URLs on a different host`() {
val siteView = GutenbergView(
EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/")
.build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse("https://other.example.net/wp-json/wp/v2/posts"))

val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request)
assertTrue("REST API URLs on another host should open externally", result)
}

@Test
fun `shouldOverrideUrlLoading allows REST API URLs when the API root has a port`() {
val siteView = GutenbergView(
EditorConfiguration.builder("http://10.0.2.2:8888", "http://10.0.2.2:8888/wp-json/")
.build(),
EditorDependencies.empty,
testScope,
RuntimeEnvironment.getApplication()
)

val request = mock(WebResourceRequest::class.java)
`when`(request.url).thenReturn(Uri.parse("http://10.0.2.2:8888/wp-json/wp/v2/posts"))

val result = siteView.editorWebView.webViewClient.shouldOverrideUrlLoading(siteView.editorWebView, request)
assertFalse("REST API URLs on a port-bearing API root should load in the WebView", result)
}

@Test
fun `shouldOverrideUrlLoading allows asset URLs when the site URL has an explicit default port`() {
val siteView = GutenbergView(
Expand Down
Loading
Loading