Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
95bfde5
feat: add savePost() bridge to trigger editor store save lifecycle
dcalhoun Apr 6, 2026
c79f249
feat(demo-ios): add save button that persists posts via REST API
dcalhoun Apr 6, 2026
91304c7
feat(android): add savePost() to GutenbergView
dcalhoun Apr 6, 2026
9ce00b3
chore(demo-ios): point wordpress-rs to PR build branch
dcalhoun Apr 7, 2026
db7e1d1
feat(android): add posts list activity for selecting existing posts
dcalhoun Apr 7, 2026
087c2b7
feat(android): add save button that persists posts via REST API
dcalhoun Apr 7, 2026
4b52b55
fix(android): include restBase and restNamespace in GBKit post payload
dcalhoun Apr 7, 2026
f6ba8ae
refactor(android): move browse button beneath post type selector
dcalhoun Apr 7, 2026
f27bdbe
refactor(android): remove disabled publish button from editor toolbar
dcalhoun Apr 7, 2026
c495c71
refactor(android): uppercase save button label and clean up strings
dcalhoun Apr 7, 2026
18f9041
fix(android): include drafts and other statuses in posts list
dcalhoun Apr 7, 2026
648ed5a
refactor(demo-ios): remove permanently disabled items from more menu
dcalhoun Apr 7, 2026
968237e
refactor(android): remove permanently disabled items from more menu
dcalhoun Apr 7, 2026
75a0f89
refactor: harden savePost bridge and clarify host contract
dcalhoun Apr 7, 2026
fdfcb42
refactor(android): introduce PostTypeDetails to mirror iOS
dcalhoun Apr 7, 2026
6584ac2
fix(android): build post fetch URL from PostTypeDetails
dcalhoun Apr 7, 2026
a3bd3a8
feat(demo-android): fetch real PostTypeDetails from REST
dcalhoun Apr 7, 2026
0dbcff6
fix(demo): always persist content even when savePost lifecycle fails
dcalhoun Apr 7, 2026
71b5782
refactor(demo-ios): extract persistPost helper, document RunnableEditor
dcalhoun Apr 7, 2026
b26f6b1
refactor(demo-android): simplify PostsListActivity, extract strings, …
dcalhoun Apr 7, 2026
4d70c26
fix(demo-android): default post type picker to Post
dcalhoun Apr 7, 2026
2926f08
fix: suppress editor save snackbar in host bridge
dcalhoun Apr 7, 2026
1de0566
test: cover savePost snackbar removal in host bridge
dcalhoun Apr 7, 2026
6679cf1
test: localize useDispatch mock for savePost coverage
dcalhoun Apr 7, 2026
74a29ee
test: revert global useDispatch mock to its trunk shape
dcalhoun Apr 7, 2026
a31ef37
docs: Reduce comment length
dcalhoun Apr 7, 2026
285a742
revert(demo): keep editor overflow menu unchanged
dcalhoun Apr 7, 2026
990b0ac
Revert "revert(demo): keep editor overflow menu unchanged"
dcalhoun Apr 7, 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
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,65 @@ class GutenbergView : FrameLayout {
}
}

private val pendingSaveCallbacks = Collections.synchronizedMap(mutableMapOf<String, SavePostCallback>())

/**
* Triggers the editor store's save lifecycle and invokes [callback] when it completes.
*
* This drives the WordPress `core/editor` store through its full save flow, causing
* `isSavingPost()` to transition `true` → `false`. Plugins that subscribe to this
* lifecycle (e.g., VideoPress syncing metadata via `/wpcom/v2/videopress/meta`) fire
* their side-effect API calls during this transition.
*
* The actual post content is **not** persisted by this method — the host app is
* responsible for reading content via [getTitleAndContent] and saving it through
* its own REST API calls. The callback fires only after the editor store's save
* lifecycle completes, so it is safe to read and persist content at that point.
*
* **Important:** if the callback reports `success = false` (for example because a
* third-party plugin subscribed to the lifecycle errored out), hosts should still
* proceed to read and persist content. A misbehaving plugin must not block the
* user from saving their work — log the lifecycle failure and continue.
*
* Note: `window.editor.savePost()` is an async JS function that returns a Promise.
* Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's
* `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion
* back via the `editorDelegate` JavaScript interface.
*/
fun savePost(callback: SavePostCallback) {
if (!isEditorLoaded) {
Log.e("GutenbergView", "You can't save until the editor has loaded")
callback.onComplete(false, "Editor not loaded")
return
}
val requestId = java.util.UUID.randomUUID().toString()
pendingSaveCallbacks[requestId] = callback
// Quote the requestId for safe JS string interpolation. UUIDs are safe
// today, but routing all values through `JSONObject.quote()` ensures we
// never accidentally inject untrusted strings into the JS context.
val quotedRequestId = JSONObject.quote(requestId)
handler.post {
webView.evaluateJavascript(
"editor.savePost()" +
".then(() => editorDelegate.onSavePostComplete($quotedRequestId, true, null))" +
".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, String(e)));",
null
)
}
}

@JavascriptInterface
fun onSavePostComplete(requestId: String, success: Boolean, error: String?) {
val callback = pendingSaveCallbacks.remove(requestId) ?: return
handler.post {
callback.onComplete(success, error)
}
}

fun interface SavePostCallback {
fun onComplete(success: Boolean, error: String?)
}

fun appendTextAtCursor(text: String) {
if (!isEditorLoaded) {
Log.e("GutenbergView", "You can't append text until the editor has loaded")
Expand Down Expand Up @@ -1025,10 +1084,23 @@ class GutenbergView : FrameLayout {
networkRequestListener = null
requestInterceptor = DefaultGutenbergRequestInterceptor()
latestContentProvider = null
// Fail any save callbacks still waiting on a JS Promise — without this,
// coroutines awaiting `savePost()` would hang forever (and leak whatever
// they captured) when the view is torn down mid-save.
drainPendingSaveCallbacks("View detached")
handler.removeCallbacksAndMessages(null)
webView.destroy()
}

private fun drainPendingSaveCallbacks(reason: String) {
val pending = synchronized(pendingSaveCallbacks) {
val snapshot = pendingSaveCallbacks.toMap()
pendingSaveCallbacks.clear()
snapshot
}
pending.values.forEach { it.onComplete(false, reason) }
}

// Network Monitoring

private fun startNetworkMonitoring() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ class RESTAPIRepository(
}

private fun buildPostUrl(id: Int): String {
return buildNamespacedUrl("/wp/v2/posts/$id?context=edit")
val restNamespace = configuration.postType.restNamespace
val restBase = configuration.postType.restBase
return buildNamespacedUrl("/$restNamespace/$restBase/$id?context=edit")
}

// MARK: Editor Settings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ data class EditorConfiguration(
val title: String,
val content: String,
val postId: UInt?,
val postType: String,
val postType: PostTypeDetails,
val postStatus: String,
val themeStyles: Boolean,
val plugins: Boolean,
Expand Down Expand Up @@ -46,15 +46,15 @@ data class EditorConfiguration(
}
companion object {
@JvmStatic
fun builder(siteURL: String, siteApiRoot: String, postType: String = "post"): Builder = Builder(siteURL, siteApiRoot, postType = postType)
fun builder(siteURL: String, siteApiRoot: String, postType: PostTypeDetails = PostTypeDetails.post): Builder = Builder(siteURL, siteApiRoot, postType = postType)

@JvmStatic
fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", "post")
fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", PostTypeDetails.post)
.setEnableOfflineMode(true)
.build()
}

class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) {
class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: PostTypeDetails) {
private var title: String = ""
private var content: String = ""
private var postId: UInt? = null
Expand All @@ -77,7 +77,7 @@ data class EditorConfiguration(
fun setTitle(title: String) = apply { this.title = title }
fun setContent(content: String) = apply { this.content = content }
fun setPostId(postId: UInt?) = apply { this.postId = postId?.takeIf { it != 0u } }
fun setPostType(postType: String) = apply { this.postType = postType }
fun setPostType(postType: PostTypeDetails) = apply { this.postType = postType }
fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus }
fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles }
fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ data class EditorPreloadList private constructor(
val postID: Int?,
/** The pre-fetched post data for the post being edited. */
val postData: EditorURLResponse?,
/** The post type identifier (e.g., "post", "page"). */
val postType: String,
/** Details about the post type, including REST API configuration. */
val postType: PostTypeDetails,
/** Pre-fetched data for the current post type's schema. */
val postTypeData: EditorURLResponse,
/** Pre-fetched data for all available post types. */
Expand All @@ -47,7 +47,7 @@ data class EditorPreloadList private constructor(
constructor(
postID: Int? = null,
postData: EditorURLResponse? = null,
postType: String,
postType: PostTypeDetails,
postTypeData: EditorURLResponse,
postTypesData: EditorURLResponse,
activeThemeData: EditorURLResponse?,
Expand All @@ -72,7 +72,7 @@ data class EditorPreloadList private constructor(
fun build(): JsonElement {
val entries = mutableMapOf<String, JsonElement>()

entries[buildPostTypePath(postType)] = postTypeData.toJsonElement()
entries[buildPostTypePath(postType.postType)] = postTypeData.toJsonElement()
entries[POST_TYPES_PATH] = postTypesData.toJsonElement()

if (postID != null && postData != null) {
Expand Down Expand Up @@ -115,7 +115,8 @@ data class EditorPreloadList private constructor(
}

/** Builds the API path for fetching a specific post. */
private fun buildPostPath(id: Int): String = "/wp/v2/posts/$id?context=edit"
private fun buildPostPath(id: Int): String =
"/${postType.restNamespace}/${postType.restBase}/$id?context=edit"

/** Builds the API path for fetching a post type's schema. */
private fun buildPostTypePath(type: String): String = "/wp/v2/types/$type?context=edit"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ data class GBKitGlobal(
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 REST API base path for this post type (e.g., `posts`, `pages`). */
val restBase: String,
/** The REST API namespace (e.g., `wp/v2`). */
val restNamespace: String,
/** The post status (e.g., `draft`, `publish`, `pending`). */
val status: String,
/** The post title (URL-encoded). */
Expand Down Expand Up @@ -109,7 +113,9 @@ data class GBKitGlobal(
locale = configuration.locale ?: "en",
post = Post(
id = postId ?: -1,
type = configuration.postType,
type = configuration.postType.postType,
restBase = configuration.postType.restBase,
restNamespace = configuration.postType.restNamespace,
status = configuration.postStatus,
title = configuration.title.encodeForEditor(),
content = configuration.content.encodeForEditor()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.wordpress.gutenberg.model

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

/**
* Details about a WordPress post type needed for REST API interactions.
*
* This class encapsulates the information required to construct correct REST API
* endpoints for different post types. WordPress custom post types (like WooCommerce
* products) have their own REST endpoints that differ from the standard `/wp/v2/posts/`.
*
* For standard post types, use the provided constants:
* ```kotlin
* val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.post)
* val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.page)
* ```
*
* For custom post types, create an instance with the appropriate REST base:
* ```kotlin
* val productType = PostTypeDetails(postType = "product", restBase = "products")
* val config = EditorConfiguration.builder(siteURL, siteApiRoot, productType)
* ```
*
* @property postType The post type slug (e.g., "post", "page", "product").
* @property restBase The REST API base path for this post type (e.g., "posts", "pages", "products").
* @property restNamespace The REST API namespace for this post type (e.g., "wp/v2"). Defaults to "wp/v2".
*/
@Parcelize
@Serializable
data class PostTypeDetails(
val postType: String,
val restBase: String,
val restNamespace: String = "wp/v2"
) : Parcelable {
companion object {
/** Standard WordPress post type. */
val post = PostTypeDetails(postType = "post", restBase = "posts")

/** Standard WordPress page type. */
val page = PostTypeDetails(postType = "page", restBase = "pages")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ class EditorService(
return coroutineScope {
val activeThemeDeferred = async { prepareActiveTheme() }
val settingsOptionsDeferred = async { prepareSettingsOptions() }
val postTypeDataDeferred = async { preparePostType(configuration.postType) }
val postTypeDataDeferred = async { preparePostType(configuration.postType.postType) }
val postTypesDataDeferred = async { preparePostTypes() }

val postId = configuration.postId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.wordpress.gutenberg.model.EditorCachePolicy
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorProgress
import org.wordpress.gutenberg.model.LocalEditorAssetManifest
import org.wordpress.gutenberg.model.PostTypeDetails
import org.wordpress.gutenberg.model.TestResources
import org.wordpress.gutenberg.model.http.EditorHTTPHeaders
import org.wordpress.gutenberg.model.http.EditorHttpMethod
Expand All @@ -37,7 +38,7 @@ class EditorAssetsLibraryTest {
val testConfiguration: EditorConfiguration = EditorConfiguration.builder(
TEST_SITE_URL,
TEST_API_ROOT,
"post"
PostTypeDetails.post
)
.setPlugins(true)
.setThemeStyles(true)
Expand All @@ -46,7 +47,7 @@ class EditorAssetsLibraryTest {
val minimalConfiguration: EditorConfiguration = EditorConfiguration.builder(
TEST_SITE_URL,
TEST_API_ROOT,
"post"
PostTypeDetails.post
)
.setPlugins(false)
.setThemeStyles(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.junit.rules.TemporaryFolder
import org.wordpress.gutenberg.model.EditorCachePolicy
import org.wordpress.gutenberg.model.EditorConfiguration
import org.wordpress.gutenberg.model.EditorSettings
import org.wordpress.gutenberg.model.PostTypeDetails
import org.wordpress.gutenberg.model.http.EditorHTTPHeaders
import org.wordpress.gutenberg.model.http.EditorHttpMethod
import org.wordpress.gutenberg.stores.EditorURLCache
Expand Down Expand Up @@ -42,7 +43,7 @@ class RESTAPIRepositoryTest {
siteApiRoot: String = TEST_API_ROOT,
siteApiNamespace: Array<String> = arrayOf()
): EditorConfiguration {
return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, "post")
return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, PostTypeDetails.post)
.setPlugins(shouldUsePlugins)
.setThemeStyles(shouldUseThemeStyles)
.setAuthHeader("Bearer test-token")
Expand Down Expand Up @@ -290,7 +291,7 @@ class RESTAPIRepositoryTest {
val configuration = EditorConfiguration.builder(
TEST_SITE_URL,
"https://example.com/wp-json", // No trailing slash
"post"
PostTypeDetails.post
).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build()

val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always)
Expand Down Expand Up @@ -319,7 +320,7 @@ class RESTAPIRepositoryTest {
val configuration = EditorConfiguration.builder(
TEST_SITE_URL,
"https://example.com/wp-json/", // With trailing slash
"post"
PostTypeDetails.post
).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build()

val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always)
Expand Down
Loading
Loading