Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
2a665b9
feat(ios): surface editor crashes natively and offer a reload
dcalhoun Sep 9, 2026
56c2182
feat(android): surface editor crashes natively and offer a reload
dcalhoun Sep 9, 2026
dd633bd
fix(ios): reset readiness when reloading the editor
dcalhoun Sep 10, 2026
dfa6369
fix(android): reset readiness when reloading the editor
dcalhoun Sep 10, 2026
12e93c2
fix(ios): move VoiceOver focus to the crash notice
dcalhoun Sep 10, 2026
61a0b65
fix(android): hide the crashed editor from touch and TalkBack
dcalhoun Sep 10, 2026
a2eccdb
docs: explain how hosts learn the editor has recovered
dcalhoun Sep 10, 2026
ee95909
feat(demo): add a menu action that crashes the editor
dcalhoun Sep 10, 2026
781cb32
fix(android): mark the error view title as an accessibility heading
dcalhoun Sep 10, 2026
98185fe
fix(ios): mark the crash notice title as an accessibility heading
dcalhoun Sep 10, 2026
fbd457e
feat(demo): disable editor-dependent controls while the editor is cra…
dcalhoun Sep 10, 2026
82c7829
fix(demo): crash the editor in code editor mode too
dcalhoun Sep 10, 2026
786707c
refactor(android): tidy the editor error view
dcalhoun Sep 10, 2026
ae0412b
fix(android): restore the code editor after the editor reloads
dcalhoun Sep 10, 2026
d6c7042
fix(ios): restore the code editor after the editor reloads
dcalhoun Sep 10, 2026
032d7d9
fix(android): hide the web view behind the load failure message
dcalhoun Sep 10, 2026
6ebb4ef
fix(ios): mark the load failure title as an accessibility heading
dcalhoun Sep 10, 2026
5f6beac
fix(ios): use the load failure icon for the crash notice
dcalhoun Sep 14, 2026
f22423c
fix(android): replace the platform alert icon in the error view
dcalhoun Sep 14, 2026
848d391
feat(ios): tint the crash notice button with the host's tint color
dcalhoun Sep 14, 2026
e8278ac
feat(android): color the error view's action button with the host theme
dcalhoun Sep 14, 2026
d8c6382
fix(demo-android): apply dynamic colors to the XML theme
dcalhoun Sep 14, 2026
c0b5e54
docs: Remove unnecessary comments
dcalhoun Sep 14, 2026
fae2590
docs: describe the error message under the crash notice
dcalhoun Sep 15, 2026
725c7f7
refactor: stop exposing reloadEditor to hosts
dcalhoun Sep 15, 2026
69c07ef
fix(android): reset readiness when a new editor page starts
dcalhoun Sep 15, 2026
b2c7e3e
fix(android): fail pending editor reads when the editor reloads
dcalhoun Sep 15, 2026
eb31f38
fix(ios): report open dialogs closed when the editor reloads
dcalhoun Sep 15, 2026
8243080
refactor(android): use Material's button for the error view action
dcalhoun Sep 15, 2026
546ef1a
refactor(android): share the error view transition
dcalhoun Sep 15, 2026
0ae010b
fix(android): announce editor errors to TalkBack
dcalhoun Sep 15, 2026
cd4306e
docs: explain which content the editor restores on reload
dcalhoun Sep 15, 2026
dd6b9e2
fix(ios): read the crash notice tint undimmed
dcalhoun Sep 15, 2026
52d7bca
fix(ios): move VoiceOver focus to the crash notice's title
dcalhoun Sep 15, 2026
a522dc5
fix(ios): move VoiceOver focus to the load failure title
dcalhoun Sep 15, 2026
11fc5c4
fix(android): keep the crash notice up when the editor just loaded
dcalhoun Sep 15, 2026
c411710
fix(ios): dismiss the block inserter when the editor reloads
dcalhoun Sep 15, 2026
a5a948b
fix(android): autofocus the editor only on its first load
dcalhoun Sep 15, 2026
ba3c218
fix(ios): report the editor unavailable when its web process ends
dcalhoun Sep 15, 2026
74ab3ac
fix(android): announce the editor error title once
dcalhoun Sep 15, 2026
c7cf8b3
refactor(android): order the crash helpers by call order
dcalhoun Sep 15, 2026
2664065
fix(ios): autofocus the editor only on its first load
dcalhoun Sep 15, 2026
b9984a2
fix(android): localize the editor load failure strings
dcalhoun Sep 15, 2026
ce89716
refactor(ios): order the crash helpers by call order
dcalhoun Sep 15, 2026
90377e2
fix(ios): stop the loading indicator when the editor crashes
dcalhoun Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions android/Gutenberg/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ android {
testOptions {
unitTests {
isReturnDefaultValues = true
// Views the editor shows natively, such as the block inserter, read
// their resources, so Robolectric needs the merged resources.
// Views the editor shows natively, such as the block inserter and its
// error states, read their resources, so Robolectric needs the merged
// resources.
isIncludeAndroidResources = true
all {
// Make the shared test fixtures available to fixture-driven tests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ class GutenbergView : FrameLayout {
private val webView: WebView
@Volatile private var isEditorLoaded = false
private var didFireEditorLoaded = false

/**
* Whether opening the editor has already placed the caret in its content.
*
* Unlike [didFireEditorLoaded], this survives a reload. Autofocus decides
* from the content the editor was opened with, which a reload can replace
* with newer content from the host, so repeating it would pop the keyboard
* over a restored post.
*/
private var hasAutofocused = false
private lateinit var assetLoader: WebViewAssetLoader
private lateinit var assetAuthority: String
private val configuration: EditorConfiguration
Expand Down Expand Up @@ -214,6 +224,7 @@ class GutenbergView : FrameLayout {
var textEditorEnabled: Boolean = false
set(value) {
field = value
if (!isEditorLoaded) return
val mode = if (value) "text" else "visual"
handler.post {
webView.evaluateJavascript("editor.switchEditorMode('$mode');", null)
Expand Down Expand Up @@ -363,6 +374,7 @@ class GutenbergView : FrameLayout {
spinnerView.animate().alpha(1f).setDuration(200).start()
errorView.visibility = GONE
webView.alpha = 0f
webView.visibility = VISIBLE
}
}

Expand All @@ -378,6 +390,7 @@ class GutenbergView : FrameLayout {
progressView.visibility = GONE
}.start()
errorView.visibility = GONE
webView.visibility = VISIBLE
webView.animate().alpha(1f).setDuration(200).start()
}
}
Expand All @@ -386,18 +399,29 @@ class GutenbergView : FrameLayout {
* Transitions to the error phase (loading failed).
*/
private fun showErrorPhase(error: Throwable) {
showErrorView { setError(error) }
}

/**
* Replaces the editor and any loading indicator with [errorView], after
* [configure] sets its content.
*/
private fun showErrorView(configure: EditorErrorView.() -> Unit) {
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.configure()
errorView.alpha = 0f
errorView.visibility = VISIBLE
errorView.animate().alpha(1f).setDuration(200).start()
webView.alpha = 0f
// Transparency alone leaves the web view reachable by touch and TalkBack.
webView.visibility = INVISIBLE
errorView.focusTitleForAccessibility()
}
}

Expand Down Expand Up @@ -667,6 +691,10 @@ class GutenbergView : FrameLayout {
* background-thread delegate assignment.
*/
private fun onEditorPageStarted() {
// Readiness belongs to the page: a new page, including one a reload starts,
// is not ready until it reports `onEditorLoaded`.
isEditorLoaded = false
didFireEditorLoaded = false
if (!hasStartedLoading) {
hasStartedLoading = true
startUploadServer()
Expand Down Expand Up @@ -829,9 +857,11 @@ class GutenbergView : FrameLayout {
* `editor` API, so calls to them are refused from this point until the editor
* reloads.
*
* The editor cannot recover on its own. Hosts should disable the controls that
* depend on it — history, editor mode — while leaving those that read from
* their own persisted copy, such as saving and closing, available.
* GutenbergKit covers the editor with a notice offering to reload it. Until it
* reloads, hosts should disable the controls that depend on the editor —
* history, editor mode — while leaving those that read from their own
* persisted copy, such as saving and closing, available. Re-enable them the
* next time [EditorAvailableListener.onEditorAvailable] is called.
*/
fun interface EditorUnavailableListener {
fun onEditorUnavailable(view: GutenbergView?)
Expand All @@ -855,16 +885,17 @@ class GutenbergView : FrameLayout {
}

/**
* Provides the latest persisted content for recovery after WebView refresh.
* Provides the content the editor starts from when its page loads.
*
* When the WebView reinitializes (e.g., due to OS memory pressure or page refresh),
* the editor requests the latest content from this provider. The host app should
* return the most recently persisted title and content from autosave.
* Asked each time the editor page loads, including when it reloads after a
* crash. Return the newest title and content the host holds, including
* anything saved during this session.
*/
interface LatestContentProvider {
/**
* Returns the most recently persisted title and content from autosave.
* @return LatestContent if available, null if no persisted content exists.
* Returns the newest title and content the host holds.
* @return LatestContent, or null to start from the content the editor was
* opened with, discarding any edits made since.
*/
fun getLatestContent(): LatestContent?
}
Expand Down Expand Up @@ -938,15 +969,27 @@ class GutenbergView : FrameLayout {
Log.i("GutenbergView", "EditorLoaded received in native code")
isEditorLoaded = true
handler.post {
// The editor can become unavailable before this runs, which resets
// readiness and shows the crash notice. Carrying on would report the
// editor available again and replace that notice with the ready phase.
if (!isEditorLoaded) return@post

lastKnownConnectivity?.let { isConnected ->
if (!isConnected) dispatchConnectivityEvent(false)
}
if(!didFireEditorLoaded) {
// The web editor always starts in visual mode, so restore code
// editor mode when the host enabled it, including after a reload.
if (textEditorEnabled) {
webView.evaluateJavascript("editor.switchEditorMode('text');", null)
}
editorDidBecomeAvailableListener?.onEditorAvailable(this)
this.didFireEditorLoaded = true
showReadyPhase()

if (configuration.content.isEmpty()) {
if (!hasAutofocused && configuration.content.isEmpty()) {
hasAutofocused = true

// Focus the editor content
webView.evaluateJavascript("editor.focus();", null)

Expand All @@ -973,13 +1016,50 @@ class GutenbergView : FrameLayout {
fun onEditorUnavailable() {
Log.e("GutenbergView", "EditorUnavailable received in native code")
isEditorLoaded = false
showEditorCrashPhase()
handler.post {
// Picks made in an open inserter can no longer reach the editor.
blockInserterDialog?.dismiss()
editorDidBecomeUnavailableListener?.onEditorUnavailable(this)
}
}

/**
* Covers the editor with a notice offering to reload.
*
* The web view still shows the editor's error message underneath, so it is
* covered rather than left showing two competing error states.
*/
private fun showEditorCrashPhase() {
showErrorView {
setActionableState(
titleResId = R.string.gbk_editor_crashed_title,
descriptionResId = R.string.gbk_editor_crashed_description,
actionResId = R.string.gbk_editor_crashed_reload,
onAction = { reloadEditor() }
)
}
}

/**
* Reloads the editor after it has crashed.
*
* The reloaded editor starts from the content [LatestContentProvider]
* returns, or from the content it was opened with when there is none.
* Readiness is reset immediately and restored only once the editor emits
* `onEditorLoaded` again.
*/
internal fun reloadEditor() {
isEditorLoaded = false
// The reload replaces the page these reads were sent to, so their results
// may never arrive.
failPendingTitleAndContentReads()
handler.post {
showSpinnerPhase()
webView.reload()
}
}

@JavascriptInterface
fun onEditorContentChanged() {
contentChangeListener?.onContentChanged()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ package org.wordpress.gutenberg.views

import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.Button
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.view.ViewCompat
import androidx.core.widget.TextViewCompat
import com.google.android.material.button.MaterialButton
import org.wordpress.gutenberg.R

/**
* A view displaying an error state with an icon, title, and description.
Expand All @@ -30,6 +37,7 @@ class EditorErrorView @JvmOverloads constructor(
private val icon: ImageView
private val titleText: TextView
private val descriptionText: TextView
private val actionButton: Button

init {
orientation = VERTICAL
Expand All @@ -38,7 +46,7 @@ class EditorErrorView @JvmOverloads constructor(
// Create error icon
icon = ImageView(context).apply {
layoutParams = LayoutParams(dpToPx(48), dpToPx(48))
setImageResource(android.R.drawable.ic_dialog_alert)
setImageResource(R.drawable.gbk_ic_editor_error)
}

// Create title
Expand All @@ -50,7 +58,8 @@ class EditorErrorView @JvmOverloads constructor(
}
gravity = Gravity.CENTER
TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Subhead)
text = "Failed to load editor"
text = context.getText(R.string.gbk_editor_load_failed_title)
ViewCompat.setAccessibilityHeading(this, true)
}

// Create description
Expand All @@ -64,9 +73,18 @@ class EditorErrorView @JvmOverloads constructor(
TextViewCompat.setTextAppearance(this, android.R.style.TextAppearance_Material_Body1)
}

// Hidden unless the state gives the user something to do about it.
actionButton = createActionButton().apply {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
topMargin = dpToPx(16)
}
visibility = GONE
}

addView(icon)
addView(titleText)
addView(descriptionText)
addView(actionButton)
}

/**
Expand All @@ -75,7 +93,67 @@ class EditorErrorView @JvmOverloads constructor(
* @param error The exception that caused the failure.
*/
fun setError(error: Throwable) {
descriptionText.text = error.message ?: "Unknown error"
setTitle(context.getText(R.string.gbk_editor_load_failed_title))
descriptionText.text =
error.message ?: context.getString(R.string.gbk_editor_load_failed_unknown_error)
clearAction()
}

/**
* Shows a state the user can act on, rather than a load failure.
*
* @param titleResId Title describing the state.
* @param descriptionResId What the user can do about it.
* @param actionResId Label for the action button.
* @param onAction Invoked when the action button is tapped.
*/
fun setActionableState(
@StringRes titleResId: Int,
@StringRes descriptionResId: Int,
@StringRes actionResId: Int,
onAction: () -> Unit
) {
setTitle(context.getText(titleResId))
descriptionText.setText(descriptionResId)
actionButton.setText(actionResId)
actionButton.setOnClickListener { onAction() }
actionButton.visibility = VISIBLE
}

/**
* Moves TalkBack focus to the title, since this view replaces content that
* could have held it.
*
* This is what announces the view, which is why it sets no accessibility
* pane title: that announces on appearance, so TalkBack would read the title
* once for the pane and again for the focus landing on it.
*/
fun focusTitleForAccessibility() {
titleText.performAccessibilityAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS, null)
}

private fun setTitle(title: CharSequence) {
titleText.text = title
}

private fun clearAction() {
actionButton.setOnClickListener(null)
actionButton.visibility = GONE
}

/**
* A [MaterialButton], styled by the host's Material theme, or a platform
* [Button] when the host theme is not a Material theme, which
* [MaterialButton] requires.
*/
private fun createActionButton(): Button {
// The attribute Material's theme check looks for.
val isMaterialTheme = context.theme.resolveAttribute(
com.google.android.material.R.attr.colorPrimaryVariant,
TypedValue(),
true
)
return if (isMaterialTheme) MaterialButton(context) else Button(context)
}

private fun dpToPx(dp: Int): Int {
Expand Down
12 changes: 12 additions & 0 deletions android/Gutenberg/src/main/res/drawable/gbk_ic_editor_error.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:fillType="evenOdd"
android:pathData="M12,2A10,10 0,0 1,12 22A10,10 0,0 1,12 2ZM12,4A8,8 0,0 1,12 20A8,8 0,0 1,12 4ZM11,7h2v6h-2ZM11,15h2v2h-2Z" />
</vector>
13 changes: 13 additions & 0 deletions android/Gutenberg/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,17 @@
<string name="gbk_block_inserter_photos">Photos</string>
<string name="gbk_block_inserter_camera">Camera</string>
<string name="gbk_block_inserter_camera_unavailable">Camera not available on this device</string>

<!-- Editor states -->
<string name="gbk_editor_load_failed_title">Failed to load editor</string>
<string name="gbk_editor_load_failed_unknown_error">Unknown error</string>
<string name="gbk_editor_crashed_title">The editor stopped working</string>
<!--
Deliberately makes no claim about whether the user's work was saved.
Whether anything was persisted depends entirely on the host: some mirror
the editor's content continuously, others read it only when the user
saves, and the editor cannot tell which.
-->
<string name="gbk_editor_crashed_description">Reload the editor to continue editing.</string>
<string name="gbk_editor_crashed_reload">Reload Editor</string>
</resources>
Loading
Loading