From d7d3c3fd7086480133db8460a357d26f6dbf3f05 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:10:59 -0700 Subject: [PATCH 01/23] refactor: Move editor loading UI into GutenbergView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GutenbergView previously extended WebView directly and delegated all loading UI (progress bar, spinner, error states) to consumers via the EditorLoadingListener interface. This forced every app embedding the editor to implement its own loading UI boilerplate. This change makes GutenbergView extend FrameLayout instead, containing an internal WebView plus overlay views for loading states: - EditorProgressView (progress bar + label) during dependency fetching - ProgressBar (circular/indeterminate) during WebView initialization - EditorErrorView (new) for error states The view manages its own state transitions with 200ms fade animations, matching the iOS EditorViewController pattern. The EditorLoadingListener interface is removed entirely — consumers no longer need loading UI code. Changes: - GutenbergView: WebView -> FrameLayout with internal WebView child - New EditorErrorView for displaying load failures - Delete EditorLoadingListener (no longer needed) - Simplify demo EditorActivity by removing ~90 lines of loading UI - Update tests to use editorWebView accessor for WebView properties - Delete unused activity_editor.xml layout Co-Authored-By: Claude Opus 4.6 --- .../gutenberg/EditorLoadingListener.kt | 62 ----- .../org/wordpress/gutenberg/GutenbergView.kt | 238 +++++++++++++----- .../gutenberg/views/EditorErrorView.kt | 84 +++++++ .../wordpress/gutenberg/GutenbergViewTest.kt | 10 +- .../example/gutenbergkit/EditorActivity.kt | 117 +-------- .../src/main/res/layout/activity_editor.xml | 19 -- 6 files changed, 263 insertions(+), 267 deletions(-) delete mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt delete mode 100644 android/app/src/main/res/layout/activity_editor.xml diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt deleted file mode 100644 index 978cc5fc8..000000000 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorLoadingListener.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.wordpress.gutenberg - -import org.wordpress.gutenberg.model.EditorProgress - -/** - * Callback interface for monitoring editor loading state. - * - * Implement this interface to receive updates about the editor's loading progress, - * allowing you to display appropriate UI (progress bar, spinner, etc.) while the - * editor initializes. - * - * ## Loading Flow - * - * When dependencies are **not provided** to `GutenbergView.start()`: - * 1. `onDependencyLoadingStarted()` - Begin showing progress bar - * 2. `onDependencyLoadingProgress()` - Update progress bar (called multiple times) - * 3. `onDependencyLoadingFinished()` - Hide progress bar, show spinner - * 4. `onEditorReady()` - Hide spinner, editor is usable - * - * When dependencies **are provided** to `GutenbergView.start()`: - * 1. `onDependencyLoadingFinished()` - Show spinner (no progress phase) - * 2. `onEditorReady()` - Hide spinner, editor is usable - */ -interface EditorLoadingListener { - /** - * Called when dependency loading begins. - * - * This is the appropriate time to show a progress bar to the user. - * Only called when dependencies were not provided to `start()`. - */ - fun onDependencyLoadingStarted() - - /** - * Called periodically with progress updates during dependency loading. - * - * @param progress The current loading progress with completed/total counts. - */ - fun onDependencyLoadingProgress(progress: EditorProgress) - - /** - * Called when dependency loading completes. - * - * This is the appropriate time to hide the progress bar and show a spinner - * while the WebView loads and parses the editor JavaScript. - */ - fun onDependencyLoadingFinished() - - /** - * Called when the editor has fully loaded and is ready for use. - * - * This is the appropriate time to hide all loading indicators and reveal - * the editor. The editor APIs are safe to call after this callback. - */ - fun onEditorReady() - - /** - * Called if dependency loading fails. - * - * @param error The exception that caused the failure. - */ - fun onDependencyLoadingFailed(error: Throwable) -} 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 348c32365..b74111a18 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -8,8 +8,8 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper -import android.util.AttributeSet import android.util.Log +import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.webkit.ConsoleMessage import android.webkit.CookieManager @@ -22,16 +22,12 @@ import android.webkit.WebResourceResponse import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope +import android.widget.FrameLayout +import android.widget.ProgressBar import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewAssetLoader.AssetsPathHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONException @@ -40,6 +36,8 @@ import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.GBKitGlobal import org.wordpress.gutenberg.services.EditorService +import org.wordpress.gutenberg.views.EditorErrorView +import org.wordpress.gutenberg.views.EditorProgressView import java.util.Locale const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" @@ -47,6 +45,10 @@ const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" /** * A WebView-based Gutenberg block editor for Android. * + * This view manages its own loading UI internally (progress bar during dependency + * fetching, spinner during WebView initialization, error state on failure). + * Consumers do not need to implement loading UI — it is handled automatically. + * * ## Creating a GutenbergView * * This view must be created programmatically - XML layout inflation is not supported. @@ -82,12 +84,11 @@ const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" * - If `dependencies` is provided, the editor loads immediately (fast path) * - If `dependencies` is null, dependencies are fetched asynchronously before loading */ -class GutenbergView : WebView { +class GutenbergView : FrameLayout { + private val webView: WebView private var isEditorLoaded = false private var didFireEditorLoaded = false - private var assetLoader = WebViewAssetLoader.Builder() - .addPathHandler("/assets/", AssetsPathHandler(this.context)) - .build() + private lateinit var assetLoader: WebViewAssetLoader private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -107,7 +108,6 @@ class GutenbergView : WebView { private var autocompleterTriggeredListener: AutocompleterTriggeredListener? = null private var modalDialogStateListener: ModalDialogStateListener? = null private var networkRequestListener: NetworkRequestListener? = null - private var loadingListener: EditorLoadingListener? = null private var latestContentProvider: LatestContentProvider? = null /** @@ -118,12 +118,36 @@ class GutenbergView : WebView { private val coroutineScope: CoroutineScope + // Internal loading overlay views + private val progressView: EditorProgressView + private val spinnerView: ProgressBar + private val errorView: EditorErrorView + + /** + * Internal loading states for the editor. + */ + private enum class LoadingState { + /** Dependencies are being loaded from the network */ + PROGRESS, + /** Dependencies loaded, waiting for WebView to initialize */ + SPINNER, + /** Editor is fully ready */ + READY, + /** Loading failed with an error */ + ERROR + } + + /** + * Provides access to the internal WebView for tests and advanced use cases. + */ + val editorWebView: WebView get() = webView + var textEditorEnabled: Boolean = false set(value) { field = value val mode = if (value) "text" else "visual" handler.post { - this.evaluateJavascript("editor.switchEditorMode('$mode');", null) + webView.evaluateJavascript("editor.switchEditorMode('$mode');", null) } } @@ -171,10 +195,6 @@ class GutenbergView : WebView { editorDidBecomeAvailableListener = listener } - fun setEditorLoadingListener(listener: EditorLoadingListener?) { - loadingListener = listener - } - /** * Creates a new GutenbergView with the specified configuration. * @@ -190,37 +210,134 @@ class GutenbergView : WebView { this.configuration = configuration this.coroutineScope = coroutineScope + // Initialize the asset loader now that context is available + assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/", AssetsPathHandler(context)) + .build() + + // Create the internal WebView as first child (behind overlays) + webView = WebView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + alpha = 0f + } + addView(webView) + + // Create loading overlay views + progressView = EditorProgressView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + loadingText = "Loading Editor..." + visibility = GONE + } + addView(progressView) + + spinnerView = ProgressBar(context).apply { + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + isIndeterminate = true + visibility = GONE + } + addView(spinnerView) + + errorView = EditorErrorView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER) + visibility = GONE + } + addView(errorView) + if (dependencies != null) { this.dependencies = dependencies // FAST PATH: Dependencies were provided - load immediately + showSpinnerPhase() loadEditor(dependencies) } else { // ASYNC FLOW: No dependencies - fetch them asynchronously + showProgressPhase() prepareAndLoadEditor() } } + /** + * Transitions to the progress bar phase (dependency fetching). + */ + private fun showProgressPhase() { + handler.post { + progressView.visibility = VISIBLE + spinnerView.visibility = GONE + errorView.visibility = GONE + webView.alpha = 0f + } + } + + /** + * Transitions to the spinner phase (WebView initialization). + */ + private fun showSpinnerPhase() { + handler.post { + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + spinnerView.alpha = 0f + spinnerView.visibility = VISIBLE + spinnerView.animate().alpha(1f).setDuration(200).start() + errorView.visibility = GONE + webView.alpha = 0f + } + } + + /** + * Transitions to the ready phase (editor visible). + */ + private fun showReadyPhase() { + handler.post { + spinnerView.animate().alpha(0f).setDuration(200).withEndAction { + spinnerView.visibility = GONE + }.start() + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + errorView.visibility = GONE + webView.animate().alpha(1f).setDuration(200).start() + } + } + + /** + * Transitions to the error phase (loading failed). + */ + private fun showErrorPhase(error: Throwable) { + handler.post { + progressView.animate().alpha(0f).setDuration(200).withEndAction { + progressView.visibility = GONE + }.start() + spinnerView.animate().alpha(0f).setDuration(200).withEndAction { + spinnerView.visibility = GONE + }.start() + errorView.setError(error) + errorView.alpha = 0f + errorView.visibility = VISIBLE + errorView.animate().alpha(1f).setDuration(200).start() + webView.alpha = 0f + } + } + @SuppressLint("SetJavaScriptEnabled") // Without JavaScript we have no Gutenberg private fun initializeWebView() { - this.settings.javaScriptCanOpenWindowsAutomatically = true - this.settings.javaScriptEnabled = true - this.settings.domStorageEnabled = true - + webView.settings.javaScriptCanOpenWindowsAutomatically = true + webView.settings.javaScriptEnabled = true + webView.settings.domStorageEnabled = true + // Set custom user agent - val defaultUserAgent = this.settings.userAgentString - this.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" - - this.addJavascriptInterface(this, "editorDelegate") - this.visibility = GONE + val defaultUserAgent = webView.settings.userAgentString + webView.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" - this.webViewClient = object : WebViewClient() { + webView.addJavascriptInterface(this, "editorDelegate") + + webView.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, error: WebResourceError? ) { - Log.e("GutenbergView", error.toString()) + Log.e("GutenbergView", "Received web error: $error") super.onReceivedError(view, request, error) } @@ -300,7 +417,7 @@ class GutenbergView : WebView { } } - this.webChromeClient = object : WebChromeClient() { + webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { if (consoleMessage != null) { Log.i("GutenbergView", consoleMessage.message()) @@ -347,8 +464,6 @@ class GutenbergView : WebView { * This method is the entry point for the async flow when no dependencies were provided. */ private fun prepareAndLoadEditor() { - loadingListener?.onDependencyLoadingStarted() - Log.i("GutenbergView", "Fetching dependencies...") coroutineScope.launch { @@ -362,7 +477,7 @@ class GutenbergView : WebView { ) Log.i("GutenbergView", "Created editor service") val fetchedDependencies = editorService.prepare { progress -> - loadingListener?.onDependencyLoadingProgress(progress) + progressView.setProgress(progress) Log.i("GutenbergView", "Progress: $progress") } @@ -373,7 +488,7 @@ class GutenbergView : WebView { loadEditor(fetchedDependencies) } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) - loadingListener?.onDependencyLoadingFailed(e) + showErrorPhase(e) } } } @@ -392,8 +507,8 @@ class GutenbergView : WebView { configuration.cachedAssetHosts ) - // Notify that dependency loading is complete (spinner phase begins) - loadingListener?.onDependencyLoadingFinished() + // Transition to spinner phase (WebView initialization) + showSpinnerPhase() initializeWebView() @@ -402,10 +517,10 @@ class GutenbergView : WebView { } WebStorage.getInstance().deleteAllData() - this.clearCache(true) + webView.clearCache(true) // All cookies are third-party cookies because the root of this document // lives under `https://appassets.androidplatform.net` - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) // Erase all local cookies before loading the URL – we don't want to persist // anything between uses – otherwise we might send the wrong cookies @@ -414,7 +529,7 @@ class GutenbergView : WebView { for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } - this.loadUrl(editorUrl) + webView.loadUrl(editorUrl) Log.i("GutenbergView", "Startup Complete") } @@ -428,7 +543,7 @@ class GutenbergView : WebView { localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() - this.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript(gbKitConfig, null) } @@ -438,7 +553,7 @@ class GutenbergView : WebView { localStorage.removeItem('GBKit'); """.trimIndent() - this.evaluateJavascript(jsCode, null) + webView.evaluateJavascript(jsCode, null) } fun setContent(newContent: String) { @@ -447,7 +562,7 @@ class GutenbergView : WebView { return } val encodedContent = newContent.encodeForEditor() - this.evaluateJavascript("editor.setContent('$encodedContent');", null) + webView.evaluateJavascript("editor.setContent('$encodedContent');", null) } fun setTitle(newTitle: String) { @@ -456,7 +571,7 @@ class GutenbergView : WebView { return } val encodedTitle = newTitle.encodeForEditor() - this.evaluateJavascript("editor.setTitle('$encodedTitle');", null) + webView.evaluateJavascript("editor.setTitle('$encodedTitle');", null) } interface TitleAndContentCallback { @@ -545,7 +660,7 @@ class GutenbergView : WebView { return } handler.post { - this.evaluateJavascript("editor.getTitleAndContent($completeComposition);") { result -> + webView.evaluateJavascript("editor.getTitleAndContent($completeComposition);") { result -> var lastUpdatedTitle: CharSequence? = null var lastUpdatedContent: CharSequence? = null var changed = false @@ -571,19 +686,19 @@ class GutenbergView : WebView { fun undo() { handler.post { - this.evaluateJavascript("editor.undo();", null) + webView.evaluateJavascript("editor.undo();", null) } } fun redo() { handler.post { - this.evaluateJavascript("editor.redo();", null) + webView.evaluateJavascript("editor.redo();", null) } } fun dismissTopModal() { handler.post { - this.evaluateJavascript("editor.dismissTopModal();", null) + webView.evaluateJavascript("editor.dismissTopModal();", null) } } @@ -594,7 +709,7 @@ class GutenbergView : WebView { } val encodedText = text.encodeForEditor() handler.post { - this.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) + webView.evaluateJavascript("editor.appendTextAtCursor(decodeURIComponent('$encodedText'));", null) } } @@ -604,25 +719,19 @@ class GutenbergView : WebView { isEditorLoaded = true handler.post { if(!didFireEditorLoaded) { - loadingListener?.onEditorReady() editorDidBecomeAvailableListener?.onEditorAvailable(this) this.didFireEditorLoaded = true - this.visibility = VISIBLE - this.alpha = 0f - this.animate() - .alpha(1f) - .setDuration(300) - .start() + showReadyPhase() if (configuration.content.isEmpty()) { // Focus the editor content - this.evaluateJavascript("editor.focus();", null) + webView.evaluateJavascript("editor.focus();", null) // Request focus on the WebView and show the soft keyboard handler.postDelayed({ - this.requestFocus() + webView.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + imm?.showSoftInput(webView, InputMethodManager.SHOW_IMPLICIT) }, 100) } } @@ -709,7 +818,7 @@ class GutenbergView : WebView { } val escapedContextId = contextId.replace("'", "\\'") - this.evaluateJavascript("editor.setMediaUploadAttachment($media, '$escapedContextId');", null) + webView.evaluateJavascript("editor.setMediaUploadAttachment($media, '$escapedContextId');", null) currentMediaContextId = null } @@ -851,13 +960,12 @@ class GutenbergView : WebView { override fun onDetachedFromWindow() { super.onDetachedFromWindow() clearConfig() - this.stopLoading() + webView.stopLoading() FileCache.clearCache(context) contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null editorDidBecomeAvailableListener = null - loadingListener = null filePathCallback = null onFileChooserRequested = null autocompleterTriggeredListener = null @@ -866,7 +974,7 @@ class GutenbergView : WebView { requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null handler.removeCallbacksAndMessages(null) - this.destroy() + webView.destroy() } companion object { @@ -881,10 +989,10 @@ class GutenbergView : WebView { * Clean up warmup resources. */ private fun cleanupWarmup() { - warmupWebView?.let { webView -> - webView.stopLoading() - webView.clearConfig() - webView.destroy() + warmupWebView?.let { view -> + view.webView.stopLoading() + view.clearConfig() + view.webView.destroy() } warmupWebView = null warmupHandler = null diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt new file mode 100644 index 000000000..995845b76 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/views/EditorErrorView.kt @@ -0,0 +1,84 @@ +package org.wordpress.gutenberg.views + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.widget.TextViewCompat + +/** + * A view displaying an error state with an icon, title, and description. + * + * This view is used inside [org.wordpress.gutenberg.GutenbergView] to show + * an error when editor dependencies fail to load. + * + * ## Usage + * + * ```kotlin + * val errorView = EditorErrorView(context) + * errorView.setError(exception) + * ``` + */ +class EditorErrorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val icon: ImageView + private val titleText: TextView + private val descriptionText: TextView + + init { + orientation = VERTICAL + gravity = Gravity.CENTER + + // Create error icon + icon = ImageView(context).apply { + layoutParams = LayoutParams(dpToPx(48), dpToPx(48)) + setImageResource(android.R.drawable.ic_dialog_alert) + } + + // Create title + titleText = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dpToPx(16) + marginStart = dpToPx(16) + marginEnd = dpToPx(16) + } + gravity = Gravity.CENTER + TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Subhead) + text = "Failed to load editor" + } + + // Create description + descriptionText = TextView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { + topMargin = dpToPx(8) + marginStart = dpToPx(16) + marginEnd = dpToPx(16) + } + gravity = Gravity.CENTER + TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Body1) + } + + addView(icon) + addView(titleText) + addView(descriptionText) + } + + /** + * Updates the error view with the given error. + * + * @param error The exception that caused the failure. + */ + fun setError(error: Throwable) { + descriptionText.text = error.message ?: "Unknown error" + } + + private fun dpToPx(dp: Int): Int { + return (dp * context.resources.displayMetrics.density).toInt() + } +} diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index b4e15c0c4..d9ddc3bc8 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -65,7 +65,7 @@ class GutenbergViewTest { } // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -105,7 +105,7 @@ class GutenbergViewTest { // When `when`(mockFileChooserParams.mode).thenReturn(WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE) - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -131,7 +131,7 @@ class GutenbergViewTest { @Test fun `onShowFileChooser stores file path callback`() { // When - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -145,7 +145,7 @@ class GutenbergViewTest { @Test fun `resetFilePathCallback clears the callback`() { // Given - gutenbergView.webChromeClient?.onShowFileChooser( + gutenbergView.editorWebView.webChromeClient?.onShowFileChooser( mockWebView, mockFilePathCallback, mockFileChooserParams @@ -165,7 +165,7 @@ class GutenbergViewTest { // that was already set up in the @Before method // Then - val userAgent = gutenbergView.settings.userAgentString + val userAgent = gutenbergView.editorWebView.settings.userAgentString assertTrue("User agent should contain GutenbergKit identifier", userAgent.contains("GutenbergKit/")) assertTrue("User agent should contain version number", diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 78ed9dce4..9ff8ac71c 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -13,15 +13,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Redo @@ -38,14 +33,11 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme @@ -53,11 +45,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView -import org.wordpress.gutenberg.EditorLoadingListener import org.wordpress.gutenberg.RecordedNetworkRequest import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer -import org.wordpress.gutenberg.model.EditorProgress class EditorActivity : ComponentActivity() { @@ -126,20 +116,6 @@ class EditorActivity : ComponentActivity() { } } -/** - * Loading state for the editor. - */ -enum class EditorLoadingState { - /** Dependencies are being loaded from the network */ - LOADING_DEPENDENCIES, - /** Dependencies loaded, waiting for WebView to initialize */ - LOADING_EDITOR, - /** Editor is fully ready */ - READY, - /** Loading failed with an error */ - ERROR -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( @@ -156,16 +132,6 @@ fun EditorScreen( var isCodeEditorEnabled by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } - // Loading state - var loadingState by remember { - mutableStateOf( - if (dependencies != null) EditorLoadingState.LOADING_EDITOR - else EditorLoadingState.LOADING_DEPENDENCIES - ) - } - var loadingProgress by remember { mutableFloatStateOf(0f) } - var loadingError by remember { mutableStateOf(null) } - BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() } @@ -293,7 +259,7 @@ fun EditorScreen( }) setNetworkRequestListener(object : GutenbergView.NetworkRequestListener { override fun onNetworkRequest(request: RecordedNetworkRequest) { - Log.d("EditorActivity", "🌐 Network Request: ${request.method} ${request.url}") + Log.d("EditorActivity", "Network Request: ${request.method} ${request.url}") Log.d("EditorActivity", " Status: ${request.status} ${request.statusText}, Duration: ${request.duration}ms") // Log request headers @@ -321,29 +287,6 @@ fun EditorScreen( } } }) - setEditorLoadingListener(object : EditorLoadingListener { - override fun onDependencyLoadingStarted() { - loadingState = EditorLoadingState.LOADING_DEPENDENCIES - loadingProgress = 0f - } - - override fun onDependencyLoadingProgress(progress: EditorProgress) { - loadingProgress = progress.fractionCompleted.toFloat() - } - - override fun onDependencyLoadingFinished() { - loadingState = EditorLoadingState.LOADING_EDITOR - } - - override fun onEditorReady() { - loadingState = EditorLoadingState.READY - } - - override fun onDependencyLoadingFailed(error: Throwable) { - loadingState = EditorLoadingState.ERROR - loadingError = error.message ?: "Unknown error" - } - }) // Demo app has no persistence layer, so return null. // In a real app, return the persisted title and content from autosave. setLatestContentProvider(object : GutenbergView.LatestContentProvider { @@ -358,63 +301,5 @@ fun EditorScreen( .fillMaxSize() .padding(innerPadding) ) - - // Loading overlay - when (loadingState) { - EditorLoadingState.LOADING_DEPENDENCIES -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - LinearProgressIndicator( - progress = { loadingProgress }, - modifier = Modifier.fillMaxWidth(0.6f) - ) - Text("Loading Editor...") - } - } - } - EditorLoadingState.LOADING_EDITOR -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text("Starting Editor...") - } - } - } - EditorLoadingState.ERROR -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Failed to load editor") - loadingError?.let { Text(it) } - } - } - } - EditorLoadingState.READY -> { - // Editor is ready, no overlay needed - } - } } } diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml deleted file mode 100644 index c39ed4db4..000000000 --- a/android/app/src/main/res/layout/activity_editor.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file From 156f40bab908313c558395a22bd88b491b13b873 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:11:54 -0700 Subject: [PATCH 02/23] Don't try to load theme styles if the configuration has them disabled --- .../java/org/wordpress/gutenberg/services/EditorService.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 6daca23d6..243328b5a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -258,6 +258,10 @@ class EditorService( } private suspend fun prepareEditorSettings(): EditorSettings { + // Don't try to load theme styles if the configuration has them disabled. + if(!configuration.themeStyles) { + return EditorSettings.undefined + } val cachedSettings = restRepository.readEditorSettings() if (cachedSettings != null) { incrementProgress(DependencyWeights.EDITOR_SETTINGS) From 6c39897d302f33dde6b70f3f3de7c8d6d7c940be Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:01:27 -0700 Subject: [PATCH 03/23] Update AGP to match WPAndroid --- android/gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3f0d56c56..af15553bd 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.10.1" kotlin = "2.0.21" kotlinx-serialization = "1.7.3" coreKtx = "1.13.1" From bcfa18de916aab4cb89860d85966266682a5f061 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:21:18 -0700 Subject: [PATCH 04/23] Add debug logging --- .../org/wordpress/gutenberg/GutenbergView.kt | 117 +++++++++++++----- 1 file changed, 87 insertions(+), 30 deletions(-) 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 b74111a18..276a88182 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -247,10 +247,12 @@ class GutenbergView : FrameLayout { this.dependencies = dependencies // FAST PATH: Dependencies were provided - load immediately + Log.d(TAG, "Constructor: dependencies provided – using fast path") showSpinnerPhase() loadEditor(dependencies) } else { // ASYNC FLOW: No dependencies - fetch them asynchronously + Log.d(TAG, "Constructor: no dependencies provided – using async path") showProgressPhase() prepareAndLoadEditor() } @@ -260,6 +262,7 @@ class GutenbergView : FrameLayout { * Transitions to the progress bar phase (dependency fetching). */ private fun showProgressPhase() { + Log.d(TAG, "Phase transition -> PROGRESS (fetching dependencies)") handler.post { progressView.visibility = VISIBLE spinnerView.visibility = GONE @@ -272,6 +275,7 @@ class GutenbergView : FrameLayout { * Transitions to the spinner phase (WebView initialization). */ private fun showSpinnerPhase() { + Log.d(TAG, "Phase transition -> SPINNER (initializing WebView)") handler.post { progressView.animate().alpha(0f).setDuration(200).withEndAction { progressView.visibility = GONE @@ -288,6 +292,7 @@ class GutenbergView : FrameLayout { * Transitions to the ready phase (editor visible). */ private fun showReadyPhase() { + Log.d(TAG, "Phase transition -> READY (editor visible)") handler.post { spinnerView.animate().alpha(0f).setDuration(200).withEndAction { spinnerView.visibility = GONE @@ -304,6 +309,7 @@ class GutenbergView : FrameLayout { * Transitions to the error phase (loading failed). */ private fun showErrorPhase(error: Throwable) { + Log.d(TAG, "Phase transition -> ERROR: ${error.message}") handler.post { progressView.animate().alpha(0f).setDuration(200).withEndAction { progressView.visibility = GONE @@ -321,6 +327,7 @@ class GutenbergView : FrameLayout { @SuppressLint("SetJavaScriptEnabled") // Without JavaScript we have no Gutenberg private fun initializeWebView() { + Log.d(TAG, "initializeWebView: configuring WebView settings") webView.settings.javaScriptCanOpenWindowsAutomatically = true webView.settings.javaScriptEnabled = true webView.settings.domStorageEnabled = true @@ -328,7 +335,9 @@ class GutenbergView : FrameLayout { // Set custom user agent val defaultUserAgent = webView.settings.userAgentString webView.settings.userAgentString = "$defaultUserAgent GutenbergKit/${GutenbergKitVersion.VERSION}" + Log.d(TAG, "initializeWebView: user agent set to ${webView.settings.userAgentString}") + Log.d(TAG, "initializeWebView: registering JavaScript interface 'editorDelegate'") webView.addJavascriptInterface(this, "editorDelegate") webView.webViewClient = object : WebViewClient() { @@ -337,27 +346,48 @@ class GutenbergView : FrameLayout { request: WebResourceRequest?, error: WebResourceError? ) { - Log.e("GutenbergView", "Received web error: $error") + Log.e(TAG, "onReceivedError: url=${request?.url}" + + " isMainFrame=${request?.isForMainFrame} error=$error") super.onReceivedError(view, request, error) } + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + Log.e(TAG, "onReceivedHttpError: url=${request?.url}" + + " status=${errorResponse?.statusCode} reason=${errorResponse?.reasonPhrase}") + super.onReceivedHttpError(view, request, errorResponse) + } + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + Log.d(TAG, "onPageStarted: url=$url") super.onPageStarted(view, url, favicon) setGlobalJavaScriptVariables() } + override fun onPageFinished(view: WebView?, url: String?) { + Log.d(TAG, "onPageFinished: url=$url") + super.onPageFinished(view, url) + } + override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { if (request.url == null) { + Log.d(TAG, "shouldInterceptRequest: null URL – passing to super") return super.shouldInterceptRequest(view, request) } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + Log.d(TAG, "shouldInterceptRequest: asset URL – delegating to assetLoader: ${request.url}") return assetLoader.shouldInterceptRequest(request.url) } else if (requestInterceptor.canIntercept(request)) { + Log.d(TAG, "shouldInterceptRequest: interceptor handling: ${request.url}") return requestInterceptor.handleRequest(request) } + Log.d(TAG, "shouldInterceptRequest: passing through to WebView: ${request.url}") return super.shouldInterceptRequest(view, request) } @@ -366,37 +396,44 @@ class GutenbergView : FrameLayout { // Allow local file URLs if (url.scheme == "file") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing file:// URL") return false } // Allow blob URLs (used by block inserter) if (url.scheme == "blob") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing blob:// URL") return false } // Allow data URLs (used by block inserter) if (url.scheme == "data") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing data: URL") return false } // Allow about:blank URLs if (url.scheme == "about") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing about: URL") return false } // Allow asset URLs if (url.host == Uri.parse(ASSET_URL).host) { + Log.d(TAG, "shouldOverrideUrlLoading: allowing asset URL") return false } // Allow WordPress.com REST API if (url.host == "public-api.wordpress.com") { + Log.d(TAG, "shouldOverrideUrlLoading: allowing public-api.wordpress.com") return false } // Allow WordPress REST API if (url.host == configuration.siteApiRoot.removePrefix("https://").removePrefix("http://")) { if (url.path?.contains("/wp-json/") == true || url.query?.contains("rest_route=") == true) { + Log.d(TAG, "shouldOverrideUrlLoading: allowing site API request – $url") return false } } @@ -405,11 +442,13 @@ class GutenbergView : FrameLayout { if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { val editorUrl = Uri.parse(BuildConfig.GUTENBERG_EDITOR_URL) if (url.host == editorUrl.host) { + Log.d(TAG, "shouldOverrideUrlLoading: allowing dev server URL") return false } } // For all other URLs, open in external browser + Log.d(TAG, "shouldOverrideUrlLoading: opening in external browser – $url") val intent = Intent(Intent.ACTION_VIEW, url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) view?.context?.startActivity(intent) @@ -420,9 +459,9 @@ class GutenbergView : FrameLayout { webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { if (consoleMessage != null) { - Log.i("GutenbergView", consoleMessage.message()) + Log.i(TAG, consoleMessage.message()) } else { - Log.i("GutenbergView", "null message") + Log.i(TAG, "null message") } return super.onConsoleMessage(consoleMessage) } @@ -464,30 +503,32 @@ class GutenbergView : FrameLayout { * This method is the entry point for the async flow when no dependencies were provided. */ private fun prepareAndLoadEditor() { - Log.i("GutenbergView", "Fetching dependencies...") + val prepareStartTime = System.currentTimeMillis() + Log.d(TAG, "prepareAndLoadEditor: starting async dependency fetch") coroutineScope.launch { - Log.i("GutenbergView", "In coroutine scope") - Log.i("GutenbergView", "Fetching dependencies in IO context") try { + Log.d(TAG, "prepareAndLoadEditor: creating EditorService") val editorService = EditorService.create( context = context, configuration = configuration, coroutineScope = coroutineScope ) - Log.i("GutenbergView", "Created editor service") + Log.d(TAG, "prepareAndLoadEditor: EditorService created") + + Log.d(TAG, "prepareAndLoadEditor: calling EditorService.prepare()") val fetchedDependencies = editorService.prepare { progress -> progressView.setProgress(progress) - - Log.i("GutenbergView", "Progress: $progress") + Log.d(TAG, "prepareAndLoadEditor: progress ${progress.completed}/${progress.total}") } - Log.i("GutenbergView", "Finished fetching dependencies") + val elapsed = System.currentTimeMillis() - prepareStartTime + Log.d(TAG, "prepareAndLoadEditor: dependencies fetched in ${elapsed}ms") - // Store dependencies and load the editor loadEditor(fetchedDependencies) } catch (e: Exception) { - Log.e("GutenbergView", "Failed to load dependencies", e) + val elapsed = System.currentTimeMillis() - prepareStartTime + Log.e(TAG, "prepareAndLoadEditor: failed after ${elapsed}ms", e) showErrorPhase(e) } } @@ -499,9 +540,14 @@ class GutenbergView : FrameLayout { * This is the shared loading path used by both flows after dependencies are available. */ private fun loadEditor(dependencies: EditorDependencies) { + val loadStartTime = System.currentTimeMillis() + Log.d(TAG, "loadEditor: starting") + this.dependencies = dependencies // Set up asset caching + Log.d(TAG, "loadEditor: configuring CachedAssetRequestInterceptor" + + " (cachedAssetHosts=${configuration.cachedAssetHosts})") requestInterceptor = CachedAssetRequestInterceptor( dependencies.assetBundle, configuration.cachedAssetHosts @@ -510,32 +556,41 @@ class GutenbergView : FrameLayout { // Transition to spinner phase (WebView initialization) showSpinnerPhase() + Log.d(TAG, "loadEditor: initializing WebView") initializeWebView() + Log.d(TAG, "loadEditor: WebView initialized") val editorUrl = BuildConfig.GUTENBERG_EDITOR_URL.ifEmpty { ASSET_URL } + Log.d(TAG, "loadEditor: editor URL = $editorUrl") + Log.d(TAG, "loadEditor: clearing WebStorage and cache") WebStorage.getInstance().deleteAllData() webView.clearCache(true) + // All cookies are third-party cookies because the root of this document // lives under `https://appassets.androidplatform.net` CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true) // Erase all local cookies before loading the URL – we don't want to persist // anything between uses – otherwise we might send the wrong cookies + Log.d(TAG, "loadEditor: clearing cookies and setting ${configuration.cookies.size}" + + " cookie(s) from configuration") CookieManager.getInstance().removeAllCookies { CookieManager.getInstance().flush() for (cookie in configuration.cookies) { CookieManager.getInstance().setCookie(cookie.key, cookie.value) } - webView.loadUrl(editorUrl) - Log.i("GutenbergView", "Startup Complete") + val elapsed = System.currentTimeMillis() - loadStartTime + Log.d(TAG, "loadEditor: loading URL (setup took ${elapsed}ms)") + webView.loadUrl(editorUrl) } } private fun setGlobalJavaScriptVariables() { + Log.d(TAG, "setGlobalJavaScriptVariables: injecting GBKit configuration into WebView") val gbKit = GBKitGlobal.fromConfiguration(configuration, dependencies) val gbKitJson = gbKit.toJsonString() val gbKitConfig = """ @@ -558,7 +613,7 @@ class GutenbergView : FrameLayout { fun setContent(newContent: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } val encodedContent = newContent.encodeForEditor() @@ -567,7 +622,7 @@ class GutenbergView : FrameLayout { fun setTitle(newTitle: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } val encodedTitle = newTitle.encodeForEditor() @@ -656,7 +711,7 @@ class GutenbergView : FrameLayout { fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } handler.post { @@ -670,7 +725,7 @@ class GutenbergView : FrameLayout { lastUpdatedContent = jsonObject.getString("content") changed = jsonObject.getBoolean("changed") } catch (e: JSONException) { - Log.e("GutenbergView", "Received invalid JSON from editor.getTitleAndContent") + Log.e(TAG, "Received invalid JSON from editor.getTitleAndContent") } val title = lastUpdatedTitle ?: "" @@ -704,7 +759,7 @@ class GutenbergView : FrameLayout { fun appendTextAtCursor(text: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't append text until the editor has loaded") + Log.e(TAG, "You can't append text until the editor has loaded") return } val encodedText = text.encodeForEditor() @@ -715,10 +770,11 @@ class GutenbergView : FrameLayout { @JavascriptInterface fun onEditorLoaded() { - Log.i("GutenbergView", "EditorLoaded received in native code") + Log.d(TAG, "onEditorLoaded: received from JavaScript (didFireEditorLoaded=$didFireEditorLoaded)") isEditorLoaded = true handler.post { if(!didFireEditorLoaded) { + Log.d(TAG, "onEditorLoaded: notifying EditorAvailableListener and transitioning to ready") editorDidBecomeAvailableListener?.onEditorAvailable(this) this.didFireEditorLoaded = true showReadyPhase() @@ -756,9 +812,9 @@ class GutenbergView : FrameLayout { @JavascriptInterface fun onBlocksChanged(isEmpty: Boolean) { if(isEmpty) { - Log.i("GutenbergView", "BlocksChanged (empty)") + Log.i(TAG, "BlocksChanged (empty)") } else { - Log.i("GutenbergView", "BlocksChanged (not empty)") + Log.i(TAG, "BlocksChanged (not empty)") } } @@ -807,13 +863,13 @@ class GutenbergView : FrameLayout { fun setMediaUploadAttachment(media: String) { if (!isEditorLoaded) { - Log.e("GutenbergView", "You can't change the editor content until it has loaded") + Log.e(TAG, "You can't change the editor content until it has loaded") return } val contextId = currentMediaContextId if (contextId == null) { - Log.e("GutenbergView", "setMediaUploadAttachment called without contextId") + Log.e(TAG, "setMediaUploadAttachment called without contextId") return } @@ -831,7 +887,7 @@ class GutenbergView : FrameLayout { @JavascriptInterface fun showBlockPicker() { - Log.i("GutenbergView", "BlockPickerShouldShow") + Log.i(TAG, "BlockPickerShouldShow") } @JavascriptInterface @@ -863,7 +919,7 @@ class GutenbergView : FrameLayout { val request = RecordedNetworkRequest.fromJson(json) networkRequestListener?.onNetworkRequest(request) } catch (e: Exception) { - Log.e("GutenbergView", "Error parsing network request: ${e.message}") + Log.e(TAG, "Error parsing network request: ${e.message}") } } } @@ -885,7 +941,7 @@ class GutenbergView : FrameLayout { put("content", content.content) }.toString() } catch (e: JSONException) { - Log.e("GutenbergView", "Failed to serialize latest content", e) + Log.e(TAG, "Failed to serialize latest content", e) null } } @@ -938,15 +994,15 @@ class GutenbergView : FrameLayout { if (uri.scheme == "content") { if (FileCache.isKnownSafeLocalProvider(uri)) { - Log.i("GutenbergView", "Using local provider URI directly: $uri") + Log.i(TAG, "Using local provider URI directly: $uri") uri } else { val cachedUri = FileCache.copyToCache(context, uri) if (cachedUri != null) { - Log.i("GutenbergView", "Copied content URI to cache: $uri -> $cachedUri") + Log.i(TAG, "Copied content URI to cache: $uri -> $cachedUri") cachedUri } else { - Log.w("GutenbergView", "Failed to copy content URI to cache, using original: $uri") + Log.w(TAG, "Failed to copy content URI to cache, using original: $uri") uri } } @@ -978,6 +1034,7 @@ class GutenbergView : FrameLayout { } companion object { + private const val TAG = "GutenbergView" private const val ASSET_LOADING_TIMEOUT_MS = 5000L // Warmup state management From cad54a128d04580bcce6cf44d296576d335f19d9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:08:37 -0700 Subject: [PATCH 05/23] Add logging and request proxying --- .../org/wordpress/gutenberg/GutenbergView.kt | 128 +++++++++++++++++- 1 file changed, 122 insertions(+), 6 deletions(-) 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 276a88182..aab3c6761 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -38,6 +38,9 @@ import org.wordpress.gutenberg.model.GBKitGlobal import org.wordpress.gutenberg.services.EditorService import org.wordpress.gutenberg.views.EditorErrorView import org.wordpress.gutenberg.views.EditorProgressView +import java.io.ByteArrayInputStream +import java.net.HttpURLConnection +import java.net.URL import java.util.Locale const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" @@ -379,16 +382,31 @@ class GutenbergView : FrameLayout { if (request.url == null) { Log.d(TAG, "shouldInterceptRequest: null URL – passing to super") return super.shouldInterceptRequest(view, request) - } else if (request.url.host?.contains("appassets.androidplatform.net") == true) { + } + + // Serve bundled assets from the app package + if (request.url.host?.contains("appassets.androidplatform.net") == true) { Log.d(TAG, "shouldInterceptRequest: asset URL – delegating to assetLoader: ${request.url}") return assetLoader.shouldInterceptRequest(request.url) - } else if (requestInterceptor.canIntercept(request)) { - Log.d(TAG, "shouldInterceptRequest: interceptor handling: ${request.url}") - return requestInterceptor.handleRequest(request) } - Log.d(TAG, "shouldInterceptRequest: passing through to WebView: ${request.url}") - return super.shouldInterceptRequest(view, request) + // Try serving from the asset cache + if (requestInterceptor.canIntercept(request)) { + val cached = requestInterceptor.handleRequest(request) + if (cached != null) { + Log.d(TAG, "shouldInterceptRequest: served from cache: ${request.url}") + return cached + } + // Cache miss – fall through to proxy below + } + + // Proxy all other requests natively so they bypass CORS + // (the editor page is served from appassets.androidplatform.net, + // making every API call cross-origin). This doesn't use `EditorHTTPClient` because + // it's not actually a native call – the editor is building the request so we don't + // want to modify it in any way. + Log.d(TAG, "shouldInterceptRequest: proxying request: ${request.url}") + return proxyRequest(request) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest): Boolean { @@ -497,6 +515,104 @@ class GutenbergView : FrameLayout { } } + /** + * Proxies an HTTP request natively to bypass CORS restrictions. + * + * The editor page is served from `https://appassets.androidplatform.net`, so every + * `fetch()` call the JavaScript makes to the WordPress REST API is cross-origin. + * Rather than requiring the server to whitelist the synthetic WebView origin, this + * method intercepts the request in [shouldInterceptRequest] and performs it with + * [HttpURLConnection], which is not subject to CORS. + */ + private fun proxyRequest(request: WebResourceRequest): WebResourceResponse? { + // Handle CORS preflight – return permissive headers immediately + // without forwarding the OPTIONS request to the server. + if (request.method.equals("OPTIONS", ignoreCase = true)) { + Log.d(TAG, "proxyRequest: CORS preflight for ${request.url}") + val requestedHeaders = request.requestHeaders?.get("Access-Control-Request-Headers") + return createCorsPreflightResponse(requestedHeaders) + } + + try { + val connection = URL(request.url.toString()).openConnection() as HttpURLConnection + connection.requestMethod = request.method ?: "GET" + connection.connectTimeout = 30_000 + connection.readTimeout = 30_000 + + // Forward headers set by the JavaScript fetch() call (e.g. Authorization) + request.requestHeaders?.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Include cookies from CookieManager (set during loadEditor) + val cookies = CookieManager.getInstance().getCookie(request.url.toString()) + if (cookies != null) { + connection.setRequestProperty("Cookie", cookies) + } + + val statusCode = connection.responseCode + val reasonPhrase = connection.responseMessage ?: "OK" + + // Parse Content-Type for the MIME type and charset + val contentTypeHeader = connection.contentType ?: "application/octet-stream" + val mimeType = contentTypeHeader.split(";").first().trim() + val charset = contentTypeHeader + .split(";") + .map { it.trim() } + .find { it.startsWith("charset=", ignoreCase = true) } + ?.substringAfter("=") + ?.trim() + ?: "UTF-8" + + val responseHeaders = mutableMapOf() + connection.headerFields?.forEach { (key, values) -> + if (key != null && values.isNotEmpty()) { + responseHeaders[key] = values.last() + } + } + + // Add CORS headers so the WebView allows the JavaScript to read the response + addCorsHeaders(responseHeaders) + + val inputStream = if (statusCode >= 400) { + connection.errorStream ?: ByteArrayInputStream(ByteArray(0)) + } else { + connection.inputStream + } + + Log.d(TAG, "proxyRequest: $statusCode ${request.method ?: "GET"} ${request.url}") + return WebResourceResponse(mimeType, charset, statusCode, reasonPhrase, responseHeaders, inputStream) + } catch (e: Exception) { + Log.e(TAG, "proxyRequest: failed to proxy ${request.url}", e) + return null + } + } + + /** + * Returns a synthetic 204 response for CORS preflight (OPTIONS) requests, + * echoing back whatever headers the JavaScript intends to send. + */ + private fun createCorsPreflightResponse(requestedHeaders: String?): WebResourceResponse { + val headers = mutableMapOf() + addCorsHeaders(headers) + headers["Access-Control-Max-Age"] = "86400" + if (!requestedHeaders.isNullOrEmpty()) { + headers["Access-Control-Allow-Headers"] = requestedHeaders + } + + return WebResourceResponse( + "text/plain", "UTF-8", 204, "No Content", + headers, ByteArrayInputStream(ByteArray(0)) + ) + } + + private fun addCorsHeaders(headers: MutableMap) { + headers["Access-Control-Allow-Origin"] = "https://appassets.androidplatform.net" + headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD" + headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-WP-Nonce" + headers["Access-Control-Allow-Credentials"] = "true" + } + /** * Fetches all required dependencies and then loads the editor. * From 12af28274c3e62f8328158cd10d91a6ea02de488 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 10 Feb 2026 14:55:25 -0500 Subject: [PATCH 06/23] fix: Resolve dev server CORS errors and falsy post id blocking editor render (#317) * fix: Use dev server origin for CORS headers when configured The CORS proxy in `addCorsHeaders` was hardcoded to `https://appassets.androidplatform.net`, which is correct for bundled assets but causes CORS failures when using a local dev server via `GUTENBERG_EDITOR_URL`. The browser rejects the proxied responses because `Access-Control-Allow-Origin` doesn't match the dev server origin. Derive the origin from `GUTENBERG_EDITOR_URL` when it's set, falling back to the bundled asset origin otherwise. * fix: Ensure post id is truthy so editor becomes ready `__unstableIsEditorReady()` returns `!!state.postId`, so a post id of `0` keeps the editor permanently in a "not ready" state. Use `||` instead of `??` to fall back to `-1` for all falsy ids (not just null/undefined), matching the existing fallback path. --- .../main/java/org/wordpress/gutenberg/GutenbergView.kt | 8 +++++++- src/utils/bridge.js | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) 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 aab3c6761..e1684d463 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -607,7 +607,13 @@ class GutenbergView : FrameLayout { } private fun addCorsHeaders(headers: MutableMap) { - headers["Access-Control-Allow-Origin"] = "https://appassets.androidplatform.net" + val origin = if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { + val uri = Uri.parse(BuildConfig.GUTENBERG_EDITOR_URL) + "${uri.scheme}://${uri.host}${if (uri.port != -1) ":${uri.port}" else ""}" + } else { + "https://appassets.androidplatform.net" + } + headers["Access-Control-Allow-Origin"] = origin headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD" headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-WP-Nonce" headers["Access-Control-Allow-Credentials"] = "true" diff --git a/src/utils/bridge.js b/src/utils/bridge.js index fd2acf3b3..b9e0caae1 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -299,7 +299,7 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); return { - id: post?.id ?? -1, + id: post?.id || -1, type: post?.type || 'post', status: post?.status || 'draft', title: { raw: hostContent.title }, @@ -310,7 +310,7 @@ export async function getPost() { if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); return { - id: post.id, + id: post.id || -1, type: post.type || 'post', status: post.status || 'draft', title: { raw: decodeURIComponent( post.title ) }, From 921380753bba1c3403ee85de6104d2d0f1effc87 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:54:08 -0700 Subject: [PATCH 07/23] =?UTF-8?q?Don=E2=80=99t=20allow=20`0`=20as=20a=20po?= =?UTF-8?q?st=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/wordpress/gutenberg/model/EditorConfiguration.kt | 4 ++-- .../wordpress/gutenberg/model/EditorConfigurationTest.kt | 9 +++++++++ .../GutenbergKit/Sources/Model/EditorConfiguration.swift | 2 +- .../Model/EditorConfigurationTests.swift | 9 +++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 2e44cbaef..0e711be60 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -76,7 +76,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } - fun setPostId(postId: Int?) = apply { this.postId = postId } + fun setPostId(postId: Int?) = apply { this.postId = postId.takeIf { it != 0 } } fun setPostType(postType: String) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } @@ -99,7 +99,7 @@ data class EditorConfiguration( fun build(): EditorConfiguration = EditorConfiguration( title = title, content = content, - postId = postId, + postId = postId.takeIf { it != 0 }, postType = postType, postStatus = postStatus, themeStyles = themeStyles, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index e3f9cd0a6..57e414465 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -78,6 +78,15 @@ class EditorConfigurationBuilderTest { assertEquals(123, config.postId) } + @Test + fun `setPostId with zero results in null`() { + val config = builder() + .setPostId(0) + .build() + + assertNull(config.postId) + } + @Test fun `setPostId with null clears postId`() { val config = builder() diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index d2fd85779..98958dae2 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -77,7 +77,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { ) { self.title = title self.content = content - self.postID = postID + self.postID = postID == 0 ? nil : postID self.postType = postType self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index 2e0571501..1477057c3 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -65,6 +65,15 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.postID == 123) } + @Test("setPostID with zero results in nil") + func setPostIDWithZeroResultsInNil() { + let config = makeConfigurationBuilder() + .setPostID(0) + .build() + + #expect(config.postID == nil) + } + @Test("setPostID with nil clears postID") func setPostIDWithNilClearsPostID() { let config = makeConfigurationBuilder() From a5e6d089ebb0f5ecf1267735b87c33a8d47b419d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:03:31 -0700 Subject: [PATCH 08/23] Use types to build the editor URI --- .../org/wordpress/gutenberg/GutenbergView.kt | 18 +++++--- .../wordpress/gutenberg/GutenbergViewTest.kt | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) 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 e1684d463..043a0fae6 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -607,12 +607,7 @@ class GutenbergView : FrameLayout { } private fun addCorsHeaders(headers: MutableMap) { - val origin = if (BuildConfig.GUTENBERG_EDITOR_URL.isNotEmpty()) { - val uri = Uri.parse(BuildConfig.GUTENBERG_EDITOR_URL) - "${uri.scheme}://${uri.host}${if (uri.port != -1) ":${uri.port}" else ""}" - } else { - "https://appassets.androidplatform.net" - } + val origin = originForUrl(BuildConfig.GUTENBERG_EDITOR_URL) headers["Access-Control-Allow-Origin"] = origin headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD" headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-WP-Nonce" @@ -1158,6 +1153,17 @@ class GutenbergView : FrameLayout { companion object { private const val TAG = "GutenbergView" private const val ASSET_LOADING_TIMEOUT_MS = 5000L + private const val DEFAULT_ORIGIN = "https://appassets.androidplatform.net" + + internal fun originForUrl(editorUrl: String): String { + if (editorUrl.isEmpty()) return DEFAULT_ORIGIN + return Uri.parse(editorUrl).buildUpon() + .path(null) + .clearQuery() + .fragment(null) + .build() + .toString() + } // Warmup state management private var warmupHandler: Handler? = null diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index d9ddc3bc8..b8759203f 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -159,6 +159,48 @@ class GutenbergViewTest { null, gutenbergView.filePathCallback) } + // MARK: - originForUrl Tests + + @Test + fun `originForUrl returns default origin for empty string`() { + assertEquals( + "https://appassets.androidplatform.net", + GutenbergView.originForUrl("") + ) + } + + @Test + fun `originForUrl extracts origin from URL with path`() { + assertEquals( + "https://example.com", + GutenbergView.originForUrl("https://example.com/some/path") + ) + } + + @Test + fun `originForUrl retains port`() { + assertEquals( + "http://localhost:8080", + GutenbergView.originForUrl("http://localhost:8080/some/path") + ) + } + + @Test + fun `originForUrl strips query and fragment`() { + assertEquals( + "https://example.com", + GutenbergView.originForUrl("https://example.com/path?query=1#fragment") + ) + } + + @Test + fun `originForUrl handles URL with no path`() { + assertEquals( + "https://example.com", + GutenbergView.originForUrl("https://example.com") + ) + } + @Test fun `GutenbergView sets custom user agent with GutenbergKit identifier`() { // The user agent is set during construction, so we can verify it on the gutenbergView From 150f89da077acb4cdc9d41ff65defdd754364e49 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:08:49 -0700 Subject: [PATCH 09/23] Add verbose logging to EditorHTTPClient on both platforms Logs request method, URL, headers, response status, size, and headers for every HTTP call. Network errors, HTTP errors, response bodies, and parsed WP error details are logged at the error level. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/EditorHTTPClient.kt | 45 ++++++++++++++++--- .../Sources/EditorHTTPClient.swift | 38 ++++++++++++++-- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index d015ec94b..0ee90fc83 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -141,18 +141,32 @@ class EditorHTTPClient( override suspend fun download(url: String, destination: File): EditorHTTPClientDownloadResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "DOWNLOAD $url") + Log.d(TAG, " Destination: ${destination.absolutePath}") + val request = Request.Builder() .url(url) .addHeader("Authorization", authHeader) .get() .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${request.headers}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "DOWNLOAD $url – network error: ${e.message}", e) + throw e + } + val statusCode = response.code val headers = extractHeaders(response) + Log.d(TAG, "DOWNLOAD $url – $statusCode") + Log.d(TAG, " Response headers: ${response.headers}") if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error downloading $url: $statusCode") + Log.e(TAG, "DOWNLOAD $url – HTTP error: $statusCode") throw EditorHTTPClientError.DownloadFailed(statusCode) } @@ -163,8 +177,12 @@ class EditorHTTPClient( input.copyTo(output) } } - Log.d(TAG, "Downloaded file: file=${destination.absolutePath}, size=${destination.length()} bytes, url=$url") - } ?: throw EditorHTTPClientError.DownloadFailed(statusCode) + Log.d(TAG, "DOWNLOAD $url – complete (${destination.length()} bytes)") + Log.d(TAG, " Saved to: ${destination.absolutePath}") + } ?: run { + Log.e(TAG, "DOWNLOAD $url – empty response body") + throw EditorHTTPClientError.DownloadFailed(statusCode) + } EditorHTTPClientDownloadResponse( file = destination, @@ -175,6 +193,8 @@ class EditorHTTPClient( override suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse = withContext(Dispatchers.IO) { + Log.d(TAG, "$method $url") + // OkHttp requires a body for POST, PUT, PATCH methods // GET, HEAD, OPTIONS, DELETE don't require a body val requiresBody = method in listOf( @@ -190,7 +210,15 @@ class EditorHTTPClient( .method(method.toString(), requestBody) .build() - val response = client.newCall(request).execute() + Log.d(TAG, " Request headers: ${request.headers}") + + val response: Response + try { + response = client.newCall(request).execute() + } catch (e: IOException) { + Log.e(TAG, "$method $url – network error: ${e.message}", e) + throw e + } // Note: This loads the entire response into memory. This is acceptable because // this method is only used for WordPress REST API responses (editor settings, post @@ -200,14 +228,19 @@ class EditorHTTPClient( val statusCode = response.code val headers = extractHeaders(response) + Log.d(TAG, "$method $url – $statusCode (${data.size} bytes)") + Log.d(TAG, " Response headers: ${response.headers}") + delegate?.didPerformRequest(url, method, response, data) if (statusCode !in 200..299) { - Log.e(TAG, "HTTP error fetching $url: $statusCode") + Log.e(TAG, "$method $url – HTTP error: $statusCode") + Log.e(TAG, " Response body: ${data.toString(Charsets.UTF_8)}") // Try to parse as WordPress error val wpError = tryParseWPError(data) if (wpError != null) { + Log.e(TAG, " WP error – code: ${wpError.code}, message: ${wpError.message}") throw EditorHTTPClientError.WPErrorResponse(wpError) } diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 431a03828..056a6d514 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -73,15 +73,31 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (data, response) = try await self.urlSession.data(for: configuredRequest) + let url = configuredRequest.url!.absoluteString + let method = configuredRequest.httpMethod ?? "GET" + Logger.http.debug("📡 \(method) \(url)") + Logger.http.debug("📡 Request headers: \(configuredRequest.allHTTPHeaderFields ?? [:])") + + let (data, response) : (Data, URLResponse) + do { + (data, response) = try await self.urlSession.data(for: configuredRequest) + } catch { + Logger.http.error("📡 \(method) \(url) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 \(method) \(url) – \(httpResponse.statusCode) (\(data.count) bytes)") + Logger.http.debug("📡 Response headers: \(httpResponse.allHeaderFields)") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 \(method) \(url) – HTTP error: \(httpResponse.statusCode)") + Logger.http.error("📡 Response body: \(String(data: data, encoding: .utf8) ?? "")") if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { + Logger.http.error("📡 WP error – code: \(wpError.code), message: \(wpError.message)") throw ClientError.wpError(wpError) } @@ -94,13 +110,27 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { let configuredRequest = self.configureRequest(urlRequest) - let (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + let requestURL = configuredRequest.url!.absoluteString + Logger.http.debug("📡 DOWNLOAD \(requestURL)") + Logger.http.debug("📡 Request headers: \(configuredRequest.allHTTPHeaderFields ?? [:])") + + let (url, response): (URL, URLResponse) + do { + (url, response) = try await self.urlSession.download(for: configuredRequest, delegate: nil) + } catch { + Logger.http.error("📡 DOWNLOAD \(requestURL) – network error: \(error.localizedDescription)") + throw error + } + self.delegate?.didPerformRequest(configuredRequest, response: response, data: .file(url)) let httpResponse = response as! HTTPURLResponse + Logger.http.debug("📡 DOWNLOAD \(requestURL) – \(httpResponse.statusCode)") + Logger.http.debug("📡 Downloaded to: \(url.path)") + Logger.http.debug("📡 Response headers: \(httpResponse.allHeaderFields)") guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(configuredRequest.url!.absoluteString): \(httpResponse.statusCode)") + Logger.http.error("📡 DOWNLOAD \(requestURL) – HTTP error: \(httpResponse.statusCode)") throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) } From 34bc052ce3435b4189a4cbcec18d5d7b63ee34b3 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:10:05 -0700 Subject: [PATCH 10/23] Add WP.com namespace support to Android RESTAPIRepository The Android RESTAPIRepository was building API URLs by simple string concatenation, ignoring siteApiNamespace. WP.com sites need a namespace like `sites/123/` inserted after the version segment in API paths (e.g., `/wp/v2/sites/123/posts`). This matches the existing iOS buildNamespacedURL logic. Also adds matching namespace URL tests to both iOS and Android. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/RESTAPIRepository.kt | 43 +++++- .../gutenberg/RESTAPIRepositoryTest.kt | 91 +++++++++++++ .../Services/RESTAPIRepositoryTests.swift | 123 ++++++++++++++++++ 3 files changed, 251 insertions(+), 6 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 7197695e5..baa41fcc9 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -24,10 +24,12 @@ class RESTAPIRepository( private val json = Json { ignoreUnknownKeys = true } private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val editorSettingsUrl = "$apiRoot$EDITOR_SETTINGS_PATH" - private val activeThemeUrl = "$apiRoot$ACTIVE_THEME_PATH" - private val siteSettingsUrl = "$apiRoot$SITE_SETTINGS_PATH" - private val postTypesUrl = "$apiRoot$POST_TYPES_PATH" + private val namespace = configuration.siteApiNamespace.firstOrNull() + + private val editorSettingsUrl = buildNamespacedURL(EDITOR_SETTINGS_PATH) + private val activeThemeUrl = buildNamespacedURL(ACTIVE_THEME_PATH) + private val siteSettingsUrl = buildNamespacedURL(SITE_SETTINGS_PATH) + private val postTypesUrl = buildNamespacedURL(POST_TYPES_PATH) /** * Cleanup any expired cache entries. @@ -72,7 +74,7 @@ class RESTAPIRepository( } private fun buildPostUrl(id: Int): String { - return "$apiRoot/wp/v2/posts/$id?context=edit" + return buildNamespacedURL("/wp/v2/posts/$id?context=edit") } // MARK: Editor Settings @@ -139,7 +141,7 @@ class RESTAPIRepository( } private fun buildPostTypeUrl(type: String): String { - return "$apiRoot/wp/v2/types/$type?context=edit" + return buildNamespacedURL("/wp/v2/types/$type?context=edit") } // MARK: GET Active Theme @@ -212,6 +214,35 @@ class RESTAPIRepository( return urlResponse } + /** + * Builds a URL by inserting the namespace after the version segment of the path. + * For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` + */ + private fun buildNamespacedURL(path: String): String { + if (namespace == null) { + return "$apiRoot$path" + } + + // Path format is typically: /prefix/version/endpoint + // e.g., /wp/v2/posts or /wp-block-editor/v1/settings + val components = path.trimStart('/').split("/", limit = 3) + if (components.size < 2) { + return "$apiRoot$path" + } + + val prefix = components[0] + val version = components[1] + val remainder = if (components.size > 2) components[2] else "" + + // Insert namespace after the version segment + // e.g., /wp/v2/settings -> /wp/v2/sites/123/settings + return if (remainder.isEmpty()) { + "$apiRoot/$prefix/$version/$namespace" + } else { + "$apiRoot/$prefix/$version/$namespace$remainder" + } + } + companion object { private const val EDITOR_SETTINGS_PATH = "/wp-block-editor/v1/settings" private const val ACTIVE_THEME_PATH = "/wp/v2/themes?context=edit&status=active" diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index e8afa5c3a..d3a0673e4 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -342,6 +342,97 @@ class RESTAPIRepositoryTest { } } + @Test + fun `URLs include namespace when siteApiNamespace is set`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/123/")) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + + @Test + fun `editor settings URL includes namespace`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/456/")) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .setEditorSettings(null) + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchEditorSettings() + + assertEquals(1, capturedURLs.size) + assertEquals( + "https://public-api.wordpress.com/wp-json/wp-block-editor/v1/sites/456/settings", + capturedURLs.first() + ) + } + + @Test + fun `settings options URL includes namespace`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/789/")) + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchSettingsOptions() + + assertEquals(1, capturedURLs.size) + assertEquals( + "https://public-api.wordpress.com/wp-json/wp/v2/sites/789/settings", + capturedURLs.first() + ) + } + @Test fun `post URL includes context=edit query parameter`() = runBlocking { var capturedURL: String? = null diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index e0002d6c5..43128d9a2 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -249,6 +249,105 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { #expect(capturedURL?.absoluteString.contains("context=edit") == true) #expect(capturedURL?.absoluteString.contains("/posts/42") == true) } + + @Test("URLs include namespace when siteApiNamespace is set") + func urlsIncludeNamespace() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/123/"] + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchPost(id: 1) + _ = try await repository.fetchPostType(for: "post") + _ = try await repository.fetchActiveTheme() + _ = try await repository.fetchPostTypes() + + let expectedURLs: Set = [ + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view", + ] + + #expect(Set(capturingClient.capturedURLs) == expectedURLs) + } + + @Test("Editor settings URL includes namespace") + func editorSettingsUrlIncludesNamespace() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/456/"] + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchEditorSettings() + + #expect(capturingClient.capturedURLs.count == 1) + #expect( + capturingClient.capturedURLs.first + == "https://public-api.wordpress.com/wp-json/wp-block-editor/v1/sites/456/settings" + ) + } + + @Test("Settings options URL includes namespace") + func settingsOptionsUrlIncludesNamespace() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/789/"] + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchSettingsOptions() + + #expect(capturingClient.capturedURLs.count == 1) + #expect( + capturingClient.capturedURLs.first + == "https://public-api.wordpress.com/wp-json/wp/v2/sites/789/settings" + ) + } } // MARK: - URL Capturing Mock Client @@ -274,3 +373,27 @@ final class URLCapturingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sen ) } } + +final class URLCollectingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable { + private(set) var capturedURLs: [String] = [] + + func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + let url = try #require(urlRequest.url) + capturedURLs.append(url.absoluteString) + return ( + Data(#"{}"#.utf8), + HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + ) + } + + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { + let url = try #require(urlRequest.url) + capturedURLs.append(url.absoluteString) + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: tempURL) + return ( + tempURL, + HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + ) + } +} From 1ad303d08c1fdaa02daf744b716b0ad9fc8e82a1 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:50:42 -0700 Subject: [PATCH 11/23] Redact sensitive headers in EditorHTTPClient logs on both platforms Authorization, Cookie, and Set-Cookie header values are now replaced with in debug logs to prevent credential leakage. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/EditorHTTPClient.kt | 30 ++++++++++++++++--- .../Sources/EditorHTTPClient.swift | 26 +++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index 0ee90fc83..a1b5c55ca 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -150,7 +150,7 @@ class EditorHTTPClient( .get() .build() - Log.d(TAG, " Request headers: ${request.headers}") + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") val response: Response try { @@ -163,7 +163,7 @@ class EditorHTTPClient( val statusCode = response.code val headers = extractHeaders(response) Log.d(TAG, "DOWNLOAD $url – $statusCode") - Log.d(TAG, " Response headers: ${response.headers}") + Log.d(TAG, " Response headers: ${redactResponseHeaders(response)}") if (statusCode !in 200..299) { Log.e(TAG, "DOWNLOAD $url – HTTP error: $statusCode") @@ -210,7 +210,7 @@ class EditorHTTPClient( .method(method.toString(), requestBody) .build() - Log.d(TAG, " Request headers: ${request.headers}") + Log.d(TAG, " Request headers: ${redactHeaders(request.headers)}") val response: Response try { @@ -229,7 +229,7 @@ class EditorHTTPClient( val headers = extractHeaders(response) Log.d(TAG, "$method $url – $statusCode (${data.size} bytes)") - Log.d(TAG, " Response headers: ${response.headers}") + Log.d(TAG, " Response headers: ${redactResponseHeaders(response)}") delegate?.didPerformRequest(url, method, response, data) @@ -292,5 +292,27 @@ class EditorHTTPClient( companion object { private const val TAG = "EditorHTTPClient" private val gson = Gson() + + private val SENSITIVE_HEADERS = setOf("authorization", "cookie", "set-cookie") + + /** + * Returns a string representation of the given OkHttp headers with + * sensitive values (Authorization, Cookie) redacted. + */ + internal fun redactHeaders(headers: okhttp3.Headers): String { + return headers.joinToString(", ") { (name, value) -> + if (name.lowercase() in SENSITIVE_HEADERS) "$name: " else "$name: $value" + } + } + + /** + * Returns a string representation of the given response headers with + * sensitive values (Set-Cookie) redacted. + */ + internal fun redactResponseHeaders(response: Response): String { + return response.headers.joinToString(", ") { (name, value) -> + if (name.lowercase() in SENSITIVE_HEADERS) "$name: " else "$name: $value" + } + } } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 056a6d514..c6d814346 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -76,7 +76,7 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { let url = configuredRequest.url!.absoluteString let method = configuredRequest.httpMethod ?? "GET" Logger.http.debug("📡 \(method) \(url)") - Logger.http.debug("📡 Request headers: \(configuredRequest.allHTTPHeaderFields ?? [:])") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") let (data, response) : (Data, URLResponse) do { @@ -90,7 +90,7 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { let httpResponse = response as! HTTPURLResponse Logger.http.debug("📡 \(method) \(url) – \(httpResponse.statusCode) (\(data.count) bytes)") - Logger.http.debug("📡 Response headers: \(httpResponse.allHeaderFields)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { Logger.http.error("📡 \(method) \(url) – HTTP error: \(httpResponse.statusCode)") @@ -112,7 +112,7 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { let configuredRequest = self.configureRequest(urlRequest) let requestURL = configuredRequest.url!.absoluteString Logger.http.debug("📡 DOWNLOAD \(requestURL)") - Logger.http.debug("📡 Request headers: \(configuredRequest.allHTTPHeaderFields ?? [:])") + Logger.http.debug("📡 Request headers: \(self.redactHeaders(configuredRequest.allHTTPHeaderFields))") let (url, response): (URL, URLResponse) do { @@ -127,7 +127,7 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { let httpResponse = response as! HTTPURLResponse Logger.http.debug("📡 DOWNLOAD \(requestURL) – \(httpResponse.statusCode)") Logger.http.debug("📡 Downloaded to: \(url.path)") - Logger.http.debug("📡 Response headers: \(httpResponse.allHeaderFields)") + Logger.http.debug("📡 Response headers: \(self.redactHeaders(httpResponse.allHeaderFields))") guard 200...299 ~= httpResponse.statusCode else { Logger.http.error("📡 DOWNLOAD \(requestURL) – HTTP error: \(httpResponse.statusCode)") @@ -138,6 +138,24 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { return (url, response as! HTTPURLResponse) } + private static let sensitiveHeaders: Set = ["authorization", "cookie", "set-cookie"] + + private func redactHeaders(_ headers: [String: String]?) -> String { + guard let headers else { return "[:]" } + let redacted = headers.map { key, value in + Self.sensitiveHeaders.contains(key.lowercased()) ? "\(key): " : "\(key): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + + private func redactHeaders(_ headers: [AnyHashable: Any]) -> String { + let redacted = headers.map { key, value in + let name = "\(key)" + return Self.sensitiveHeaders.contains(name.lowercased()) ? "\(name): " : "\(name): \(value)" + } + return "[\(redacted.joined(separator: ", "))]" + } + private func configureRequest(_ request: URLRequest) -> URLRequest { var mutableRequest = request mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") From d215b2b5538d0c1aa306d1bab15282a72feef9d2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:55:46 -0700 Subject: [PATCH 12/23] Restrict CORS proxy credentials to trusted hosts The native CORS proxy in GutenbergView now only forwards the Authorization header and cookies to known trusted hosts (site API, WP.com API, Photon CDN, and configured cached asset hosts). Requests to other domains are still proxied but without credentials, preventing token leakage if injected content triggers fetches to untrusted origins. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/gutenberg/GutenbergView.kt | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) 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 043a0fae6..b551b2cf9 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -95,6 +95,25 @@ class GutenbergView : FrameLayout { private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies + /** + * Hosts that are allowed to receive credentials (Authorization header, cookies) + * through the CORS proxy. Derived from the editor configuration. + */ + private val trustedHosts: Set by lazy { + buildSet { + Uri.parse(configuration.siteApiRoot).host?.let { add(it) } + Uri.parse(configuration.siteURL).host?.let { add(it) } + add("public-api.wordpress.com") + // WordPress.com CDN hosts (Photon image proxy) + add("i0.wp.com") + add("i1.wp.com") + add("i2.wp.com") + add("i3.wp.com") + // Include any CDN hosts configured for asset caching + addAll(configuration.cachedAssetHosts) + } + } + private val handler = Handler(Looper.getMainLooper()) var filePathCallback: ValueCallback?>? = null val pickImageRequestCode = 1 @@ -539,15 +558,24 @@ class GutenbergView : FrameLayout { connection.connectTimeout = 30_000 connection.readTimeout = 30_000 - // Forward headers set by the JavaScript fetch() call (e.g. Authorization) + // Only forward credentials (Authorization header, cookies) to hosts + // we trust. Requests to other hosts are still proxied (e.g. CDN or + // embed fetches) but without credentials to prevent token leakage. + val isTrustedHost = trustedHosts.contains(request.url.host) + request.requestHeaders?.forEach { (key, value) -> + if (!isTrustedHost && key.equals("Authorization", ignoreCase = true)) { + Log.d(TAG, "proxyRequest: stripping Authorization header for untrusted host ${request.url.host}") + return@forEach + } connection.setRequestProperty(key, value) } - // Include cookies from CookieManager (set during loadEditor) - val cookies = CookieManager.getInstance().getCookie(request.url.toString()) - if (cookies != null) { - connection.setRequestProperty("Cookie", cookies) + if (isTrustedHost) { + val cookies = CookieManager.getInstance().getCookie(request.url.toString()) + if (cookies != null) { + connection.setRequestProperty("Cookie", cookies) + } } val statusCode = connection.responseCode From 2695bf1c96b1267932dbee9cd2f932b7077318b9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:01:17 -0700 Subject: [PATCH 13/23] Replace HttpURLConnection with OkHttp in CORS proxy Use a shared OkHttpClient for proxying WebView requests, replacing the raw HttpURLConnection. OkHttp's connection pool automatically reclaims connections when the response stream is closed, eliminating the potential connection leak. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/gutenberg/GutenbergView.kt | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) 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 b551b2cf9..41ecf582b 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -38,10 +38,12 @@ import org.wordpress.gutenberg.model.GBKitGlobal import org.wordpress.gutenberg.services.EditorService import org.wordpress.gutenberg.views.EditorErrorView import org.wordpress.gutenberg.views.EditorProgressView +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.io.ByteArrayInputStream -import java.net.HttpURLConnection -import java.net.URL import java.util.Locale +import java.util.concurrent.TimeUnit const val ASSET_URL = "https://appassets.androidplatform.net/assets/index.html" @@ -114,6 +116,13 @@ class GutenbergView : FrameLayout { } } + /** Shared OkHttp client for proxying WebView requests past CORS. */ + private val proxyClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .build() + private val handler = Handler(Looper.getMainLooper()) var filePathCallback: ValueCallback?>? = null val pickImageRequestCode = 1 @@ -541,7 +550,8 @@ class GutenbergView : FrameLayout { * `fetch()` call the JavaScript makes to the WordPress REST API is cross-origin. * Rather than requiring the server to whitelist the synthetic WebView origin, this * method intercepts the request in [shouldInterceptRequest] and performs it with - * [HttpURLConnection], which is not subject to CORS. + * OkHttp, which is not subject to CORS. OkHttp's connection pool handles cleanup + * automatically when the response stream is closed by the WebView. */ private fun proxyRequest(request: WebResourceRequest): WebResourceResponse? { // Handle CORS preflight – return permissive headers immediately @@ -553,36 +563,41 @@ class GutenbergView : FrameLayout { } try { - val connection = URL(request.url.toString()).openConnection() as HttpURLConnection - connection.requestMethod = request.method ?: "GET" - connection.connectTimeout = 30_000 - connection.readTimeout = 30_000 + val method = request.method ?: "GET" + val isTrustedHost = trustedHosts.contains(request.url.host) + + // OkHttp requires a body for POST/PUT/PATCH even if empty + val requiresBody = method in listOf("POST", "PUT", "PATCH") + val body = if (requiresBody) "".toRequestBody(null) else null + + val okRequestBuilder = Request.Builder() + .url(request.url.toString()) + .method(method, body) // Only forward credentials (Authorization header, cookies) to hosts // we trust. Requests to other hosts are still proxied (e.g. CDN or // embed fetches) but without credentials to prevent token leakage. - val isTrustedHost = trustedHosts.contains(request.url.host) - request.requestHeaders?.forEach { (key, value) -> if (!isTrustedHost && key.equals("Authorization", ignoreCase = true)) { Log.d(TAG, "proxyRequest: stripping Authorization header for untrusted host ${request.url.host}") return@forEach } - connection.setRequestProperty(key, value) + okRequestBuilder.addHeader(key, value) } if (isTrustedHost) { val cookies = CookieManager.getInstance().getCookie(request.url.toString()) if (cookies != null) { - connection.setRequestProperty("Cookie", cookies) + okRequestBuilder.addHeader("Cookie", cookies) } } - val statusCode = connection.responseCode - val reasonPhrase = connection.responseMessage ?: "OK" + val response = proxyClient.newCall(okRequestBuilder.build()).execute() + val statusCode = response.code + val reasonPhrase = response.message.ifEmpty { "OK" } // Parse Content-Type for the MIME type and charset - val contentTypeHeader = connection.contentType ?: "application/octet-stream" + val contentTypeHeader = response.header("Content-Type") ?: "application/octet-stream" val mimeType = contentTypeHeader.split(";").first().trim() val charset = contentTypeHeader .split(";") @@ -593,22 +608,18 @@ class GutenbergView : FrameLayout { ?: "UTF-8" val responseHeaders = mutableMapOf() - connection.headerFields?.forEach { (key, values) -> - if (key != null && values.isNotEmpty()) { - responseHeaders[key] = values.last() - } + response.headers.forEach { (key, value) -> + responseHeaders[key] = value } // Add CORS headers so the WebView allows the JavaScript to read the response addCorsHeaders(responseHeaders) - val inputStream = if (statusCode >= 400) { - connection.errorStream ?: ByteArrayInputStream(ByteArray(0)) - } else { - connection.inputStream - } + // OkHttp manages connections via its pool — when the WebView closes this + // stream, OkHttp automatically returns the connection to the pool. + val inputStream = response.body?.byteStream() ?: ByteArrayInputStream(ByteArray(0)) - Log.d(TAG, "proxyRequest: $statusCode ${request.method ?: "GET"} ${request.url}") + Log.d(TAG, "proxyRequest: $statusCode $method ${request.url}") return WebResourceResponse(mimeType, charset, statusCode, reasonPhrase, responseHeaders, inputStream) } catch (e: Exception) { Log.e(TAG, "proxyRequest: failed to proxy ${request.url}", e) From 2cf6cc9da3b2ce4c1d0aabddb1d485c0c3c66999 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:03:23 -0700 Subject: [PATCH 14/23] Add comment explaining error response body logging The WordPress REST API should never include sensitive information in responses, so logging the raw body on HTTP errors is acceptable and useful for debugging unexpected error formats. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt | 3 +++ ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index a1b5c55ca..dbc268d6a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -235,6 +235,9 @@ class EditorHTTPClient( if (statusCode !in 200..299) { Log.e(TAG, "$method $url – HTTP error: $statusCode") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. Log.e(TAG, " Response body: ${data.toString(Charsets.UTF_8)}") // Try to parse as WordPress error diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index c6d814346..474c5d15c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -94,6 +94,9 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { guard 200...299 ~= httpResponse.statusCode else { Logger.http.error("📡 \(method) \(url) – HTTP error: \(httpResponse.statusCode)") + // Log the raw body to aid debugging unexpected error formats. + // This is acceptable because the WordPress REST API should never + // include sensitive information (tokens, credentials) in responses. Logger.http.error("📡 Response body: \(String(data: data, encoding: .utf8) ?? "")") if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { From a9356231fe8541d91b4347f74d28b9257e9a30bc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:07:01 -0700 Subject: [PATCH 15/23] Clarify that enableNetworkLogging controls editor-to-host logging This flag enables the JavaScript editor to surface network details to the native host app via the bridge. It does not control the native EditorHTTPClient's own debug-level logging. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/gutenberg/model/EditorConfiguration.kt | 6 ++++++ .../GutenbergKit/Sources/Model/EditorConfiguration.swift | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 0e711be60..40bfa7d7a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -27,6 +27,12 @@ data class EditorConfiguration( val enableAssetCaching: Boolean = false, val cachedAssetHosts: Set = emptySet(), val editorAssetsEndpoint: String? = null, + /** + * Enables the JavaScript editor to surface network request/response details + * to the native host app (via the bridge). This does **not** control the + * native [EditorHTTPClient][org.wordpress.gutenberg.EditorHTTPClient]'s own + * debug logging, which always runs at the platform debug level. + */ val enableNetworkLogging: Boolean = false, var enableOfflineMode: Boolean = false, ): Parcelable { diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 98958dae2..d9e37f2ef 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -44,7 +44,10 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let editorAssetsEndpoint: URL? /// Logs emitted at or above this level will be printed to the debug console public let logLevel: EditorLogLevel - /// Enables logging of all network requests/responses to the native host + /// Enables the JavaScript editor to surface network request/response details + /// to the native host app (via the bridge). This does **not** control the + /// native `EditorHTTPClient`'s own debug logging, which always runs at the + /// platform debug level and is stripped from release builds. public let enableNetworkLogging: Bool /// Don't make HTTP requests public let isOfflineModeEnabled: Bool From a33d421b1522adb874d46686496054ea8c677c8c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:21:15 -0700 Subject: [PATCH 16/23] Remove overly-aggressive themeStyles guard in Android EditorService The early return in prepareEditorSettings() skipped fetching editor settings whenever themeStyles was false, even when plugins was true. This diverged from iOS, which always delegates to the repository. The repository already has the correct guard: skip only when both plugins and themeStyles are disabled. Host apps that don't support the editor settings endpoint should set both flags to false. Co-Authored-By: Claude Opus 4.6 --- .../java/org/wordpress/gutenberg/services/EditorService.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 243328b5a..6daca23d6 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -258,10 +258,6 @@ class EditorService( } private suspend fun prepareEditorSettings(): EditorSettings { - // Don't try to load theme styles if the configuration has them disabled. - if(!configuration.themeStyles) { - return EditorSettings.undefined - } val cachedSettings = restRepository.readEditorSettings() if (cachedSettings != null) { incrementProgress(DependencyWeights.EDITOR_SETTINGS) From 2d87bc36c36b43003a15f965e13c7a1a27a3f9f6 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:29:50 -0700 Subject: [PATCH 17/23] Normalize namespace trailing slash in RESTAPIRepository on both platforms Ensures namespaces like "sites/123" (without trailing slash) produce the same URLs as "sites/123/" so callers don't need to worry about the convention. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/RESTAPIRepository.kt | 4 +- .../gutenberg/RESTAPIRepositoryTest.kt | 34 +++++++++++++++++ .../Sources/RESTAPIRepository.swift | 4 +- .../Services/RESTAPIRepositoryTests.swift | 37 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index baa41fcc9..3f5f76dd9 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -24,7 +24,9 @@ class RESTAPIRepository( private val json = Json { ignoreUnknownKeys = true } private val apiRoot = configuration.siteApiRoot.trimEnd('/') - private val namespace = configuration.siteApiNamespace.firstOrNull() + private val namespace = configuration.siteApiNamespace.firstOrNull()?.let { + it.trimEnd('/') + "/" + } private val editorSettingsUrl = buildNamespacedURL(EDITOR_SETTINGS_PATH) private val activeThemeUrl = buildNamespacedURL(ACTIVE_THEME_PATH) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index d3a0673e4..f49d9acb5 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -376,6 +376,40 @@ class RESTAPIRepositoryTest { assertEquals(expectedURLs, capturedURLs.toSet()) } + @Test + fun `URLs include namespace without trailing slash`() = runBlocking { + val capturedURLs = mutableListOf() + val capturingClient = createCapturingClient { capturedURLs.add(it) } + + val configuration = EditorConfiguration.builder( + TEST_SITE_URL, + "https://public-api.wordpress.com/wp-json", + "post" + ) + .setSiteApiNamespace(arrayOf("sites/123")) // No trailing slash + .setPlugins(true) + .setThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) + val repository = RESTAPIRepository(configuration, capturingClient, cache) + + repository.fetchPost(id = 1) + repository.fetchPostType("post") + repository.fetchActiveTheme() + repository.fetchPostTypes() + + val expectedURLs = setOf( + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view" + ) + + assertEquals(expectedURLs, capturedURLs.toSet()) + } + @Test fun `editor settings URL includes namespace`() = runBlocking { val capturedURLs = mutableListOf() diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index 36aa673d3..089ed4681 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -61,10 +61,12 @@ public struct RESTAPIRepository: Sendable { /// Builds a URL by inserting the namespace after the version segment of the path. /// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts` private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL { - guard let namespace = namespace else { + guard let rawNamespace = namespace else { return apiRoot.appending(rawPath: path) } + let namespace = rawNamespace.hasSuffix("/") ? rawNamespace : rawNamespace + "/" + // Parse the path to find where to insert the namespace // Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings) let components = path.split(separator: "/", omittingEmptySubsequences: true) diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index 43128d9a2..b81123c45 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -287,6 +287,43 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { #expect(Set(capturingClient.capturedURLs) == expectedURLs) } + @Test("URLs include namespace without trailing slash") + func urlsIncludeNamespaceWithoutTrailingSlash() async throws { + let capturingClient = URLCollectingMockHTTPClient() + + let configuration = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://public-api.wordpress.com")!, + siteApiRoot: URL(string: "https://public-api.wordpress.com/wp-json")!, + siteApiNamespace: ["sites/123"] // No trailing slash + ) + .setShouldUsePlugins(true) + .setShouldUseThemeStyles(true) + .setAuthHeader("Bearer test") + .build() + + let cache = EditorURLCache(cacheRoot: .randomTemporaryDirectory) + let repository = RESTAPIRepository( + configuration: configuration, + httpClient: capturingClient, + cache: cache + ) + + _ = try await repository.fetchPost(id: 1) + _ = try await repository.fetchPostType(for: "post") + _ = try await repository.fetchActiveTheme() + _ = try await repository.fetchPostTypes() + + let expectedURLs: Set = [ + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/posts/1?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types/post?context=edit", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/themes?context=edit&status=active", + "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view", + ] + + #expect(Set(capturingClient.capturedURLs) == expectedURLs) + } + @Test("Editor settings URL includes namespace") func editorSettingsUrlIncludesNamespace() async throws { let capturingClient = URLCollectingMockHTTPClient() From 9129c513d119938c733e8835ec6a2ebaf3953185 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:32:55 -0700 Subject: [PATCH 18/23] Cancel in-flight view animations in onDetachedFromWindow Prevents withEndAction callbacks from firing on detached views if the editor is closed mid-animation. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 41ecf582b..fdb98eeaf 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -1172,6 +1172,12 @@ class GutenbergView : FrameLayout { override fun onDetachedFromWindow() { super.onDetachedFromWindow() clearConfig() + // Cancel in-flight animations to prevent withEndAction callbacks from + // firing on detached views. + progressView.animate().cancel() + spinnerView.animate().cancel() + errorView.animate().cancel() + webView.animate().cancel() webView.stopLoading() FileCache.clearCache(context) contentChangeListener = null From a365fcaa23145b9c3ae4788aed6b0c4b76ae55cf Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:35:02 -0700 Subject: [PATCH 19/23] Remove unused LoadingState enum from GutenbergView Co-Authored-By: Claude Opus 4.6 --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 14 -------------- 1 file changed, 14 deletions(-) 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 fdb98eeaf..ef6ec199c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -154,20 +154,6 @@ class GutenbergView : FrameLayout { private val spinnerView: ProgressBar private val errorView: EditorErrorView - /** - * Internal loading states for the editor. - */ - private enum class LoadingState { - /** Dependencies are being loaded from the network */ - PROGRESS, - /** Dependencies loaded, waiting for WebView to initialize */ - SPINNER, - /** Editor is fully ready */ - READY, - /** Loading failed with an error */ - ERROR - } - /** * Provides access to the internal WebView for tests and advanced use cases. */ From d0b425fc2be70a4d968310a55f823c7da41ea1c9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:35:29 -0700 Subject: [PATCH 20/23] Remove unused ASSET_LOADING_TIMEOUT_MS constant from GutenbergView Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 1 - 1 file changed, 1 deletion(-) 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 ef6ec199c..a65167d3a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -1183,7 +1183,6 @@ class GutenbergView : FrameLayout { companion object { private const val TAG = "GutenbergView" - private const val ASSET_LOADING_TIMEOUT_MS = 5000L private const val DEFAULT_ORIGIN = "https://appassets.androidplatform.net" internal fun originForUrl(editorUrl: String): String { From 0a5cd5de7f951896722a5aa1295ed7b3a6631316 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:37:02 -0700 Subject: [PATCH 21/23] Add delegate notification for download requests on Android iOS calls the delegate for both perform and download, but Android only called it for perform. This adds EditorResponseData (matching iOS's enum) and calls the delegate after downloads complete. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/gutenberg/EditorHTTPClient.kt | 21 +++++++++++++++++-- .../gutenberg/EditorHTTPClientTest.kt | 7 ++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt index dbc268d6a..7b12163a1 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/EditorHTTPClient.kt @@ -24,13 +24,28 @@ interface EditorHTTPClientProtocol { suspend fun perform(method: EditorHttpMethod, url: String): EditorHTTPClientResponse } +/** + * The response data from an HTTP request, either in-memory bytes or a downloaded file. + */ +sealed class EditorResponseData { + data class Bytes(val data: ByteArray) : EditorResponseData() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Bytes) return false + return data.contentEquals(other.data) + } + override fun hashCode(): Int = data.contentHashCode() + } + data class File(val file: java.io.File) : EditorResponseData() +} + /** * A delegate for observing HTTP requests made by the editor. * * Implement this interface to inspect or log all network requests. */ interface EditorHTTPClientDelegate { - fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) + fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) } /** @@ -184,6 +199,8 @@ class EditorHTTPClient( throw EditorHTTPClientError.DownloadFailed(statusCode) } + delegate?.didPerformRequest(url, EditorHttpMethod.GET, response, EditorResponseData.File(destination)) + EditorHTTPClientDownloadResponse( file = destination, statusCode = statusCode, @@ -231,7 +248,7 @@ class EditorHTTPClient( Log.d(TAG, "$method $url – $statusCode (${data.size} bytes)") Log.d(TAG, " Response headers: ${redactResponseHeaders(response)}") - delegate?.didPerformRequest(url, method, response, data) + delegate?.didPerformRequest(url, method, response, EditorResponseData.Bytes(data)) if (statusCode !in 200..299) { Log.e(TAG, "$method $url – HTTP error: $statusCode") diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt index 667e00ea8..ebc480b32 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorHTTPClientTest.kt @@ -263,10 +263,10 @@ class EditorHTTPClientTest { var delegateCalled = false var capturedUrl: String? = null var capturedMethod: EditorHttpMethod? = null - var capturedData: ByteArray? = null + var capturedData: EditorResponseData? = null val delegate = object : EditorHTTPClientDelegate { - override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: ByteArray) { + override fun didPerformRequest(url: String, method: EditorHttpMethod, response: Response, data: EditorResponseData) { delegateCalled = true capturedUrl = url capturedMethod = method @@ -280,7 +280,8 @@ class EditorHTTPClientTest { assertTrue(delegateCalled) assertTrue(capturedUrl?.contains("test") == true) assertEquals(EditorHttpMethod.GET, capturedMethod) - assertEquals("response data", capturedData?.toString(Charsets.UTF_8)) + val bytes = (capturedData as? EditorResponseData.Bytes)?.data + assertEquals("response data", bytes?.toString(Charsets.UTF_8)) } @Test From 2a195501e2adcb3e4175a72350e3bb2b5982ab5e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:39:52 -0700 Subject: [PATCH 22/23] Make URLCollectingMockHTTPClient an actor for thread safety Replaces @unchecked Sendable with proper actor isolation so capturedURLs is synchronized. Co-Authored-By: Claude Opus 4.6 --- .../Services/RESTAPIRepositoryTests.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift index b81123c45..d11562f1e 100644 --- a/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift +++ b/ios/Tests/GutenbergKitTests/Services/RESTAPIRepositoryTests.swift @@ -284,7 +284,7 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view", ] - #expect(Set(capturingClient.capturedURLs) == expectedURLs) + #expect(Set(await capturingClient.capturedURLs) == expectedURLs) } @Test("URLs include namespace without trailing slash") @@ -321,7 +321,7 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { "https://public-api.wordpress.com/wp-json/wp/v2/sites/123/types?context=view", ] - #expect(Set(capturingClient.capturedURLs) == expectedURLs) + #expect(Set(await capturingClient.capturedURLs) == expectedURLs) } @Test("Editor settings URL includes namespace") @@ -348,9 +348,9 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { _ = try await repository.fetchEditorSettings() - #expect(capturingClient.capturedURLs.count == 1) + #expect(await capturingClient.capturedURLs.count == 1) #expect( - capturingClient.capturedURLs.first + await capturingClient.capturedURLs.first == "https://public-api.wordpress.com/wp-json/wp-block-editor/v1/sites/456/settings" ) } @@ -379,9 +379,9 @@ struct RESTAPIRepositoryTests: MakesTestFixtures { _ = try await repository.fetchSettingsOptions() - #expect(capturingClient.capturedURLs.count == 1) + #expect(await capturingClient.capturedURLs.count == 1) #expect( - capturingClient.capturedURLs.first + await capturingClient.capturedURLs.first == "https://public-api.wordpress.com/wp-json/wp/v2/sites/789/settings" ) } @@ -411,7 +411,7 @@ final class URLCapturingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sen } } -final class URLCollectingMockHTTPClient: EditorHTTPClientProtocol, @unchecked Sendable { +actor URLCollectingMockHTTPClient: EditorHTTPClientProtocol { private(set) var capturedURLs: [String] = [] func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { From 5e8b852ba12e50c0076348563f439d80d2ff9a8d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:40:39 -0700 Subject: [PATCH 23/23] Clear openMediaLibraryListener and logJsExceptionListener in onDetachedFromWindow These two listeners were not being nulled out during teardown, inconsistent with all other listener cleanup in the same method. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/org/wordpress/gutenberg/GutenbergView.kt | 2 ++ 1 file changed, 2 insertions(+) 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 a65167d3a..32c10632f 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -1169,6 +1169,8 @@ class GutenbergView : FrameLayout { contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null + openMediaLibraryListener = null + logJsExceptionListener = null editorDidBecomeAvailableListener = null filePathCallback = null onFileChooserRequested = null