From 46454a8b5c66aaed1a6e4b4b3ec4b7baa778148a 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 1/6] 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 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 Remove unused LoadingState enum from GutenbergView Co-Authored-By: Claude Opus 4.6 Remove unused ASSET_LOADING_TIMEOUT_MS constant from GutenbergView Co-Authored-By: Claude Opus 4.6 --- .../gutenberg/EditorLoadingListener.kt | 62 ----- .../org/wordpress/gutenberg/GutenbergView.kt | 231 +++++++++++++----- .../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, 255 insertions(+), 268 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..32bfaa2f2 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,22 @@ class GutenbergView : WebView { private val coroutineScope: CoroutineScope + // Internal loading overlay views + private val progressView: EditorProgressView + private val spinnerView: ProgressBar + private val errorView: EditorErrorView + + /** + * 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 +181,6 @@ class GutenbergView : WebView { editorDidBecomeAvailableListener = listener } - fun setEditorLoadingListener(listener: EditorLoadingListener?) { - loadingListener = listener - } - /** * Creates a new GutenbergView with the specified configuration. * @@ -190,37 +196,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 +403,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 +450,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 +463,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 +474,7 @@ class GutenbergView : WebView { loadEditor(fetchedDependencies) } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) - loadingListener?.onDependencyLoadingFailed(e) + showErrorPhase(e) } } } @@ -392,8 +493,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 +503,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 +515,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 +529,7 @@ class GutenbergView : WebView { localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() - this.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript(gbKitConfig, null) } @@ -438,7 +539,7 @@ class GutenbergView : WebView { localStorage.removeItem('GBKit'); """.trimIndent() - this.evaluateJavascript(jsCode, null) + webView.evaluateJavascript(jsCode, null) } fun setContent(newContent: String) { @@ -447,7 +548,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 +557,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 +646,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 +672,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 +695,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 +705,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 +804,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 +946,18 @@ class GutenbergView : WebView { override fun onDetachedFromWindow() { super.onDetachedFromWindow() clearConfig() - this.stopLoading() + // 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 historyChangeListener = null featuredImageChangeListener = null editorDidBecomeAvailableListener = null - loadingListener = null filePathCallback = null onFileChooserRequested = null autocompleterTriggeredListener = null @@ -866,11 +966,10 @@ class GutenbergView : WebView { requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null handler.removeCallbacksAndMessages(null) - this.destroy() + webView.destroy() } companion object { - private const val ASSET_LOADING_TIMEOUT_MS = 5000L // Warmup state management private var warmupHandler: Handler? = null @@ -881,10 +980,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 49b9f46496dbc10401f7c2ad579c21c30ad5d515 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 2/6] 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 32bfaa2f2..6a52bc072 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -957,6 +957,8 @@ class GutenbergView : FrameLayout { contentChangeListener = null historyChangeListener = null featuredImageChangeListener = null + openMediaLibraryListener = null + logJsExceptionListener = null editorDidBecomeAvailableListener = null filePathCallback = null onFileChooserRequested = null From dc455b9513c6dcab7555a2f418e4a7661e2c3f66 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:38:01 -0700 Subject: [PATCH 3/6] Update AGP --- 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 ff05da88cae77da2d78a77370f338b2452bd3d05 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:00:56 -0700 Subject: [PATCH 4/6] Add required constructor --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 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 6a52bc072..471f26ce8 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -88,7 +88,10 @@ class GutenbergView : FrameLayout { private val webView: WebView private var isEditorLoaded = false private var didFireEditorLoaded = false - private lateinit var assetLoader: WebViewAssetLoader + private var assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/assets/", AssetsPathHandler(this.context)) + .build() + private val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -181,6 +184,15 @@ class GutenbergView : FrameLayout { editorDidBecomeAvailableListener = listener } + constructor(context: Context) : this( + configuration = EditorConfiguration.bundled(), + dependencies = null, + coroutineScope = CoroutineScope(Dispatchers.IO), + context = context + ) { + Log.e("GutenbergView", "Using the default constructor for `GutenbergView` – this is probably not what you want.") + } + /** * Creates a new GutenbergView with the specified configuration. * From 850ae9e12042c67686b5f52caba054f6fe0687df Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:20:26 -0700 Subject: [PATCH 5/6] Enforce valid postId using the type system --- .../gutenberg/model/EditorConfiguration.kt | 8 ++--- .../wordpress/gutenberg/model/GBKitGlobal.kt | 8 +++-- .../gutenberg/services/EditorService.kt | 6 ++-- .../gutenberg/model/EditorAssetBundleTest.kt | 6 ++++ .../model/EditorConfigurationTest.kt | 34 +++++++++---------- .../gutenberg/model/GBKitGlobalTest.kt | 15 +++++--- .../gutenberg/services/EditorServiceTest.kt | 31 ++++------------- 7 files changed, 52 insertions(+), 56 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..703b4c1b8 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 @@ -10,7 +10,7 @@ import java.util.UUID data class EditorConfiguration( val title: String, val content: String, - val postId: Int?, + val postId: UInt?, val postType: String, val postStatus: String, val themeStyles: Boolean, @@ -57,7 +57,7 @@ data class EditorConfiguration( class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) { private var title: String = "" private var content: String = "" - private var postId: Int? = null + private var postId: UInt? = null private var postStatus: String = "draft" private var themeStyles: Boolean = false private var plugins: Boolean = false @@ -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: UInt?) = apply { this.postId = postId } fun setPostType(postType: String) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } @@ -181,7 +181,7 @@ data class EditorConfiguration( override fun hashCode(): Int { var result = title.hashCode() result = 31 * result + content.hashCode() - result = 31 * result + (postId ?: 0) + result = 31 * result + (postId?.toInt() ?: 0) result = 31 * result + postType.hashCode() result = 31 * result + postStatus.hashCode() result = 31 * result + themeStyles.hashCode() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index 898d9ed82..f14815f90 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -68,7 +68,7 @@ data class GBKitGlobal( @Serializable data class Post( /** The post ID, or -1 for new posts. */ - val id: Int, + val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts /** The post type (e.g., `post`, `page`). */ val type: String, /** The post status (e.g., `draft`, `publish`, `pending`). */ @@ -92,6 +92,8 @@ data class GBKitGlobal( configuration: EditorConfiguration, dependencies: EditorDependencies? ): GBKitGlobal { + val postId = (configuration.postId?.toInt() ?: -1).takeIf({ it != 0 }) + return GBKitGlobal( siteURL = configuration.siteURL.ifEmpty { null }, siteApiRoot = configuration.siteApiRoot.ifEmpty { null }, @@ -103,9 +105,9 @@ data class GBKitGlobal( hideTitle = configuration.hideTitle, locale = configuration.locale ?: "en", post = Post( - id = configuration.postId ?: -1, + id = postId ?: -1, type = configuration.postType, - status = configuration.postStatus ?: "draft", + status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() ), 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..507c36351 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 @@ -289,11 +289,11 @@ class EditorService( val postTypesDataDeferred = async { preparePostTypes() } val postId = configuration.postId - if (postId != null && postId > 0) { - val postDataDeferred = async { preparePost(postId) } + if (postId != null) { + val postDataDeferred = async { preparePost(postId.toInt()) } EditorPreloadList( - postID = postId, + postID = postId.toInt(), postData = postDataDeferred.await(), postType = configuration.postType, postTypeData = postTypeDataDeferred.await(), diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt index f5e85d596..4d9dd9a22 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorAssetBundleTest.kt @@ -230,6 +230,12 @@ class EditorAssetBundleTest { // MARK: - hasAssetData Tests + @Test + fun `hasAssetData returns false for empty bundle`() { + val url = "https://example.com/wp-content/plugins/script.js" + assertFalse(EditorAssetBundle.empty.hasAssetData(url)) + } + @Test fun `hasAssetData returns false for non-existent file`() { val bundle = makeBundle() 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..c5e225e4b 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 @@ -72,16 +72,16 @@ class EditorConfigurationBuilderTest { @Test fun `setPostId updates postId`() { val config = builder() - .setPostId(123) + .setPostId(123u) .build() - assertEquals(123, config.postId) + assertEquals(123u, config.postId) } @Test fun `setPostId with null clears postId`() { val config = builder() - .setPostId(123) + .setPostId(123u) .setPostId(null) .build() @@ -265,7 +265,7 @@ class EditorConfigurationBuilderTest { val config = builder() .setTitle("Chained Title") .setContent("

Chained content

") - .setPostId(456) + .setPostId(456u) .setPlugins(true) .setThemeStyles(true) .setLocale("de_DE") @@ -274,7 +274,7 @@ class EditorConfigurationBuilderTest { assertEquals("Chained Title", config.title) assertEquals("

Chained content

", config.content) - assertEquals(456, config.postId) + assertEquals(456u, config.postId) assertTrue(config.plugins) assertTrue(config.themeStyles) assertEquals("de_DE", config.locale) @@ -300,7 +300,7 @@ class EditorConfigurationBuilderTest { val original = builder() .setTitle("Round Trip Title") .setContent("

Round trip content

") - .setPostId(999) + .setPostId(999u) .setPostType("page") .setPostStatus("draft") .setThemeStyles(true) @@ -330,7 +330,7 @@ class EditorConfigurationBuilderTest { fun `toBuilder allows modification of existing config`() { val original = builder() .setTitle("Original Title") - .setPostId(100) + .setPostId(100u) .build() val modified = original.toBuilder() @@ -339,7 +339,7 @@ class EditorConfigurationBuilderTest { assertEquals("Original Title", original.title) assertEquals("Modified Title", modified.title) - assertEquals(100, modified.postId) + assertEquals(100u, modified.postId) } @Test @@ -377,7 +377,7 @@ class EditorConfigurationBuilderTest { @Test fun `toBuilder preserves nullable values when set`() { val original = builder() - .setPostId(123) + .setPostId(123u) .setPostType("post") .setPostStatus("publish") .setEditorSettings("""{"test":true}""") @@ -386,7 +386,7 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() - assertEquals(123, rebuilt.postId) + assertEquals(123u, rebuilt.postId) assertEquals("post", rebuilt.postType) assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) @@ -532,11 +532,11 @@ class EditorConfigurationTest { @Test fun `Configurations with different postId are not equal`() { val config1 = builder() - .setPostId(1) + .setPostId(1u) .build() val config2 = builder() - .setPostId(2) + .setPostId(2u) .build() assertNotEquals(config1, config2) @@ -803,15 +803,15 @@ class EditorConfigurationTest { @Test fun `Configurations can be used in Set`() { val config1 = builder() - .setPostId(1) + .setPostId(1u) .build() val config2 = builder() - .setPostId(2) + .setPostId(2u) .build() val config3 = builder() - .setPostId(1) + .setPostId(1u) .build() val set = setOf(config1, config2, config3) @@ -844,7 +844,7 @@ class EditorConfigurationTest { val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", "post") .setTitle("Test Title") .setContent("Test Content") - .setPostId(123) + .setPostId(123u) .setPostType("post") .setPostStatus("publish") .setThemeStyles(true) @@ -867,7 +867,7 @@ class EditorConfigurationTest { assertEquals("Test Title", config.title) assertEquals("Test Content", config.content) - assertEquals(123, config.postId) + assertEquals(123u, config.postId) assertEquals("post", config.postType) assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index 91c919dbf..a755ca100 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -36,7 +36,7 @@ class GBKitGlobalTest { } private fun makeConfiguration( - postId: Int? = null, + postId: UInt? = null, title: String? = null, content: String? = null, siteURL: String = TEST_SITE_URL, @@ -107,7 +107,7 @@ class GBKitGlobalTest { @Test fun `maps postID to post id`() { - val withPostID = makeConfiguration(postId = 42) + val withPostID = makeConfiguration(postId = 42u) val withoutPostID = makeConfiguration(postId = null) val globalWith = GBKitGlobal.fromConfiguration(withPostID, makeDependencies()) @@ -117,6 +117,13 @@ class GBKitGlobalTest { assertEquals(-1, globalWithout.post.id) } + @Test + fun `maps zero postID to negative one`() { + val configuration = makeConfiguration(postId = 0u) + val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) + assertEquals(-1, global.post.id) + } + @Test fun `maps title with percent encoding`() { val configuration = makeConfiguration(title = "Hello World") @@ -149,7 +156,7 @@ class GBKitGlobalTest { @Test fun `toJsonString includes all required fields`() { - val configuration = makeConfiguration(postId = 123, title = "Test", content = "Content") + val configuration = makeConfiguration(postId = 123u, title = "Test", content = "Content") val global = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) val jsonString = global.toJsonString() @@ -165,7 +172,7 @@ class GBKitGlobalTest { @Test fun `toJsonString round-trips through serialization`() { - val configuration = makeConfiguration(postId = 99, title = "Round Trip", content = "Test content") + val configuration = makeConfiguration(postId = 99u, title = "Round Trip", content = "Test content") val original = GBKitGlobal.fromConfiguration(configuration, makeDependencies()) val jsonString = original.toJsonString() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt index a52aec4e2..81afaf959 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt @@ -130,38 +130,19 @@ class EditorServiceTest { // MARK: - preparePreloadList Tests (negative postID handling) @Test - fun `prepare does not fetch post when postID is negative`() = runBlocking { + fun `prepare does not fetch post when postID is null`() = runBlocking { val mockClient = EditorServiceMockHTTPClient() val configuration = testConfiguration.toBuilder() - .setPostId(-1) + .setPostId(null) .build() val service = makeService(configuration = configuration, httpClient = mockClient) service.prepare() - // Verify no request was made to /posts/-1 - val postRequests = mockClient.requestedURLs.filter { it.contains("/posts/-1") } + // Verify no request was made to any specific post endpoint + val postRequests = mockClient.requestedURLs.filter { it.matches(Regex(".*/posts/\\d+.*")) } assertEquals( - "Should not request /posts/-1 for negative post IDs", - emptyList(), - postRequests - ) - } - - @Test - fun `prepare does not fetch post when postID is zero`() = runBlocking { - val mockClient = EditorServiceMockHTTPClient() - val configuration = testConfiguration.toBuilder() - .setPostId(0) - .build() - - val service = makeService(configuration = configuration, httpClient = mockClient) - service.prepare() - - // Verify no request was made to /posts/0 - val postRequests = mockClient.requestedURLs.filter { it.contains("/posts/0") } - assertEquals( - "Should not request /posts/0 for zero post IDs", + "Should not request any post for null post IDs", emptyList(), postRequests ) @@ -171,7 +152,7 @@ class EditorServiceTest { fun `prepare fetches post when postID is positive`() = runBlocking { val mockClient = EditorServiceMockHTTPClient() val configuration = testConfiguration.toBuilder() - .setPostId(123) + .setPostId(123u) .build() val service = makeService(configuration = configuration, httpClient = mockClient) From cbc6d4446eab23c5b62da0f0b6e2770e583a1d2b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:20:43 -0700 Subject: [PATCH 6/6] FIx `hasAssetData` assertion for empty bundle --- .../java/org/wordpress/gutenberg/model/EditorAssetBundle.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt index 159ec86f2..7b457d8c8 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorAssetBundle.kt @@ -117,6 +117,10 @@ data class EditorAssetBundle( * @return `true` if the asset has been cached locally. */ fun hasAssetData(url: String): Boolean { + if(this == empty) { + return false + } + return assetDataPath(url).exists() }