diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 22168f5b2..1607fb4db 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -111,6 +111,9 @@ class GutenbergView : FrameLayout { private var hasAutofocused = false private lateinit var assetLoader: WebViewAssetLoader private lateinit var assetAuthority: String + + /** The editor document [loadEditor] loaded, so its load failures can be told apart. */ + private var editorUri: Uri? = null private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -443,8 +446,30 @@ class GutenbergView : FrameLayout { request: WebResourceRequest?, error: WebResourceError? ) { - Log.e("GutenbergView", "Received web error: $error") + val url = request?.url + val description = error?.description?.toString() + Log.e("GutenbergView", "Received web error ${error?.errorCode} ($description) for $url") super.onReceivedError(view, request, error) + + if (request?.isForMainFrame == true && url != null && isEditorDocument(url)) { + showEditorLoadErrorPhase(url, description) + } + } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + super.onReceivedHttpError(view, request, errorResponse) + + // A missing bundled `index.html` also lands here, as a 404 from the asset loader. + val url = request?.url + if (request?.isForMainFrame == true && url != null && isEditorDocument(url)) { + val status = "HTTP ${errorResponse?.statusCode}" + Log.e("GutenbergView", "Received $status for the editor document $url") + showEditorLoadErrorPhase(url, status) + } } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { @@ -576,6 +601,23 @@ class GutenbergView : FrameLayout { } } + /** Whether [url] is the editor document, ignoring an explicit default port or empty path. */ + private fun isEditorDocument(url: Uri): Boolean { + val editorUri = editorUri ?: return false + return url.scheme == editorUri.scheme && + originAuthority(url.toString()) == originAuthority(editorUri.toString()) && + url.path.orEmpty().ifEmpty { "/" } == editorUri.path.orEmpty().ifEmpty { "/" } + } + + /** + * Shows why the editor document at [url] failed to load. Otherwise the editor + * never signals readiness and the spinner never ends. + */ + private fun showEditorLoadErrorPhase(url: Uri, reason: String?) { + val isDevServer = BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty() + showErrorPhase(Exception(editorLoadErrorMessage(url, reason, isDevServer))) + } + /** * Fetches all required dependencies and then loads the editor. * @@ -657,6 +699,7 @@ class GutenbergView : FrameLayout { val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { assetUrl } + editorUri = Uri.parse(editorUrl) WebStorage.getInstance().deleteAllData() webView.clearCache(true) @@ -1410,6 +1453,52 @@ class GutenbergView : FrameLayout { return url.authority == devServerAuthority } + /** + * Why the editor document at [url] failed to load, with a hint keyed to + * [description] (Chromium's `net::ERR_CONNECTION_REFUSED`, or `HTTP 404`) + * when it is a dev server. Null for the bundled editor, so the view shows + * its localized text. + */ + internal fun editorLoadErrorMessage(url: Uri, description: String?, isDevServer: Boolean): String? { + if (!isDevServer) return null + val reason = description.orEmpty() + val hint = when { + "ERR_CONNECTION_REFUSED" in reason && url.host in DEVICE_LOOPBACK_HOSTS && url.port != -1 -> + "Is the dev server running? Start it with \"make serve-dev\". " + + "On a physical device, also run \"adb reverse tcp:${url.port} tcp:${url.port}\"." + "ERR_CONNECTION_REFUSED" in reason -> + "Is the dev server running? Start it with \"make serve-dev\"." + "ERR_CLEARTEXT_NOT_PERMITTED" in reason -> + "Allow cleartext traffic to ${url.host} in the app's network security config." + UNREACHABLE_HOST_ERRORS.any { it in reason } && url.host == EMULATOR_HOST_ALIAS -> + "$EMULATOR_HOST_ALIAS reaches your computer only from the emulator. " + + "On a physical device, use your computer's LAN IP address instead." + UNREACHABLE_HOST_ERRORS.any { it in reason } -> + "Check that this device can reach ${url.host}: its IP address, network, and firewall." + reason.startsWith("HTTP ") -> + "Check that GUTENBERG_EDITOR_URL in local.properties points at the dev server." + else -> null + } + val summary = "Couldn't load the editor from $url (${description ?: "unknown error"})." + return listOfNotNull(summary, hint).joinToString(" ") + } + + /** Hosts that resolve to the device itself, which reaches the dev machine only through `adb reverse`. */ + private val DEVICE_LOOPBACK_HOSTS = setOf("localhost", "127.0.0.1") + + /** The emulator's alias for the dev machine; a physical device can't reach it. */ + private const val EMULATOR_HOST_ALIAS = "10.0.2.2" + + private val UNREACHABLE_HOST_ERRORS = listOf( + "ERR_CONNECTION_TIMED_OUT", + "ERR_CONNECTION_FAILED", + "ERR_TIMED_OUT", + "ERR_ADDRESS_UNREACHABLE", + "ERR_NAME_NOT_RESOLVED", + "ERR_INTERNET_DISCONNECTED", + "ERR_NETWORK_CHANGED" + ) + private const val ASSET_LOADING_TIMEOUT_MS = 5000L /** diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewLoadErrorTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewLoadErrorTest.kt new file mode 100644 index 000000000..13ac48f34 --- /dev/null +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewLoadErrorTest.kt @@ -0,0 +1,176 @@ +package org.wordpress.gutenberg + +import android.net.Uri +import android.os.Looper +import android.view.View +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 + +/** How a failure to load the editor document reaches the user instead of an endless spinner. */ +@RunWith(RobolectricTestRunner::class) +class GutenbergViewLoadErrorTest { + + private val testScope = TestScope() + + private val devServer = Uri.parse("http://10.0.2.2:5173/") + + private val lanDevServer = Uri.parse("http://192.168.1.5:5173/") + + private fun siteView() = GutenbergView( + EditorConfiguration.builder("https://example.com", "https://example.com/wp-json/").build(), + EditorDependencies.empty, + testScope, + RuntimeEnvironment.getApplication() + ) + + /** The editor document, mirroring the fallback in `loadEditor`. */ + private val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL + .ifEmpty { "https://example.com/assets/index.html" } + + private fun failMainFrameLoad(view: GutenbergView, url: String) { + val error = mock(WebResourceError::class.java) + `when`(error.description).thenReturn("net::ERR_CONNECTION_REFUSED") + view.editorWebView.webViewClient.onReceivedError(view.editorWebView, request(url, isForMainFrame = true), error) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun respondWithNotFound(view: GutenbergView, url: String, isForMainFrame: Boolean) { + val response = WebResourceResponse(null, null, 404, "Not Found", null, null) + view.editorWebView.webViewClient.onReceivedHttpError(view.editorWebView, request(url, isForMainFrame), response) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun request(url: String, isForMainFrame: Boolean): WebResourceRequest { + val request = mock(WebResourceRequest::class.java) + `when`(request.url).thenReturn(Uri.parse(url)) + `when`(request.isForMainFrame).thenReturn(isForMainFrame) + return request + } + + @Test + fun `a failed editor document load replaces the spinner with the error view`() { + val view = siteView() + shadowOf(Looper.getMainLooper()).idle() + + failMainFrameLoad(view, editorUrl) + + assertEquals( + "the failed editor must give way to the error view", + View.INVISIBLE, + view.editorWebView.visibility + ) + } + + @Test + fun `a failed load of another page leaves the editor as it is`() { + val view = siteView() + shadowOf(Looper.getMainLooper()).idle() + + failMainFrameLoad(view, "https://example.com/wp-json/wp/v2/posts") + + assertEquals(View.VISIBLE, view.editorWebView.visibility) + } + + @Test + fun `an HTTP error for the editor document replaces the spinner with the error view`() { + val view = siteView() + shadowOf(Looper.getMainLooper()).idle() + + respondWithNotFound(view, editorUrl, isForMainFrame = true) + + assertEquals( + "the missing editor must give way to the error view", + View.INVISIBLE, + view.editorWebView.visibility + ) + } + + @Test + fun `an HTTP error for a subresource leaves the editor as it is`() { + val view = siteView() + shadowOf(Looper.getMainLooper()).idle() + + respondWithNotFound(view, "https://example.com/wp-content/uploads/missing.jpg", isForMainFrame = false) + + assertEquals(View.VISIBLE, view.editorWebView.visibility) + } + + @Test + fun `editorLoadErrorMessage suggests checking the editor URL for an HTTP error`() { + val message = GutenbergView.editorLoadErrorMessage(devServer, "HTTP 404", true) + + assertTrue(message.orEmpty().contains("GUTENBERG_EDITOR_URL")) + } + + @Test + fun `editorLoadErrorMessage suggests starting a refused dev server`() { + val message = GutenbergView.editorLoadErrorMessage(devServer, "net::ERR_CONNECTION_REFUSED", true) + + assertTrue(message.orEmpty().contains("make serve-dev")) + assertFalse(message.orEmpty().contains("adb reverse")) + } + + @Test + fun `editorLoadErrorMessage suggests forwarding the port for a refused loopback dev server`() { + val message = GutenbergView.editorLoadErrorMessage( + Uri.parse("http://localhost:5173/"), + "net::ERR_CONNECTION_REFUSED", + true + ) + + assertTrue(message.orEmpty().contains("make serve-dev")) + assertTrue(message.orEmpty().contains("adb reverse tcp:5173 tcp:5173")) + } + + @Test + fun `editorLoadErrorMessage suggests allowing cleartext to the dev server's host`() { + val message = GutenbergView.editorLoadErrorMessage(devServer, "net::ERR_CLEARTEXT_NOT_PERMITTED", true) + + assertTrue(message.orEmpty().contains("cleartext traffic to 10.0.2.2")) + } + + @Test + fun `editorLoadErrorMessage suggests checking reachability of an unreachable host`() { + val message = GutenbergView.editorLoadErrorMessage(lanDevServer, "net::ERR_CONNECTION_TIMED_OUT", true) + + assertTrue(message.orEmpty().contains("can reach 192.168.1.5")) + } + + @Test + fun `editorLoadErrorMessage suggests checking reachability of a disconnected device`() { + val message = GutenbergView.editorLoadErrorMessage(lanDevServer, "net::ERR_INTERNET_DISCONNECTED", true) + + assertTrue(message.orEmpty().contains("can reach 192.168.1.5")) + } + + @Test + fun `editorLoadErrorMessage suggests a LAN IP address for an unreachable emulator alias`() { + val message = GutenbergView.editorLoadErrorMessage(devServer, "net::ERR_CONNECTION_TIMED_OUT", true) + + assertTrue(message.orEmpty().contains("only from the emulator")) + assertTrue(message.orEmpty().contains("LAN IP address")) + } + + @Test + fun `editorLoadErrorMessage leaves the bundled editor to the localized message`() { + val bundledEditor = Uri.parse("https://example.com/assets/index.html") + + assertNull(GutenbergView.editorLoadErrorMessage(bundledEditor, "net::ERR_FAILED", false)) + assertNull(GutenbergView.editorLoadErrorMessage(bundledEditor, "HTTP 404", false)) + } +}