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..471f26ce8 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,14 @@ 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 val configuration: EditorConfiguration private lateinit var dependencies: EditorDependencies @@ -107,7 +111,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 +121,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,8 +184,13 @@ class GutenbergView : WebView { editorDidBecomeAvailableListener = listener } - fun setEditorLoadingListener(listener: EditorLoadingListener?) { - loadingListener = 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.") } /** @@ -190,37 +208,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 +415,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 +462,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 +475,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 +486,7 @@ class GutenbergView : WebView { loadEditor(fetchedDependencies) } catch (e: Exception) { Log.e("GutenbergView", "Failed to load dependencies", e) - loadingListener?.onDependencyLoadingFailed(e) + showErrorPhase(e) } } } @@ -392,8 +505,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 +515,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 +527,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 +541,7 @@ class GutenbergView : WebView { localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); """.trimIndent() - this.evaluateJavascript(gbKitConfig, null) + webView.evaluateJavascript(gbKitConfig, null) } @@ -438,7 +551,7 @@ class GutenbergView : WebView { localStorage.removeItem('GBKit'); """.trimIndent() - this.evaluateJavascript(jsCode, null) + webView.evaluateJavascript(jsCode, null) } fun setContent(newContent: String) { @@ -447,7 +560,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 +569,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 +658,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 +684,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 +707,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 +717,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 +816,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 +958,20 @@ 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 + openMediaLibraryListener = null + logJsExceptionListener = null editorDidBecomeAvailableListener = null - loadingListener = null filePathCallback = null onFileChooserRequested = null autocompleterTriggeredListener = null @@ -866,11 +980,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 +994,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/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() } 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/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/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) 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 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"