From 701b05f1e1a367c52a219a9009151a9e09369a49 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:31:38 -0700 Subject: [PATCH 01/42] Bump GutenbergKit to v0.13.2 Co-Authored-By: Claude Opus 4.6 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf7b28a3cb3b..b76bfab1ee1d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ google-play-review = '2.0.2' google-services = '4.4.4' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = 'v0.11.1' +gutenberg-kit = 'v0.13.2' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' From 3ff57a641729c8830ddcbba34e5bc1cc5212e125 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:31:45 -0700 Subject: [PATCH 02/42] Remove old EditorConfigurationBuilder and GutenbergKitWarmupHelper These are replaced by the simplified GutenbergKitSettingsBuilder and the new GutenbergEditorPreloader in subsequent commits. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/EditorConfigurationBuilder.kt | 89 ---------- .../ui/posts/GutenbergKitWarmupHelper.kt | 154 ------------------ 2 files changed, 243 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt delete mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt deleted file mode 100644 index a1bea3d546b0..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.wordpress.android.ui.posts - -import org.wordpress.android.util.UrlUtils -import org.wordpress.gutenberg.EditorConfiguration - -/** - * Utility object for building EditorConfiguration from settings maps. - * Eliminates duplication between GutenbergKitEditorFragment and GutenbergKitWarmupHelper. - */ -object EditorConfigurationBuilder { - /** - * Builds an EditorConfiguration from the provided settings map. - * - * @param settings The settings map containing all configuration values - * @param editorSettings Optional editor settings string (null for warmup scenarios) - * @return Configured EditorConfiguration instance - */ - fun build( - settings: Map, - editorSettings: String? = null - ): EditorConfiguration { - return EditorConfiguration.Builder().apply { - val postId = settings.getSetting("postId")?.let { if (it == 0) -1 else it } - val siteURL = settings.getSetting("siteURL") ?: "" - val siteApiNamespace = settings.getStringArray("siteApiNamespace") - - // Post settings - setTitle(settings.getSetting("postTitle") ?: "") - setContent(settings.getSetting("postContent") ?: "") - setPostId(postId) - setPostType(settings.getSetting("postType")) - - // Site settings - setSiteURL(siteURL) - setSiteApiRoot(settings.getSetting("siteApiRoot") ?: "") - setSiteApiNamespace(siteApiNamespace) - setNamespaceExcludedPaths(settings.getStringArray("namespaceExcludedPaths")) - setAuthHeader(settings.getSetting("authHeader") ?: "") - - // Features - setThemeStyles(settings.getSettingOrDefault("themeStyles", false)) - setPlugins(settings.getSettingOrDefault("plugins", false)) - setLocale(settings.getSetting("locale") ?: "en") - - // Editor asset caching configuration - configureEditorAssetCaching(settings, siteURL, siteApiNamespace) - - // Cookies - setCookies(settings.getSetting>("cookies") ?: emptyMap()) - - // Network logging for debugging - setEnableNetworkLogging(settings.getSettingOrDefault("enableNetworkLogging", false)) - - // Editor settings (null for warmup scenarios) - setEditorSettings(editorSettings) - }.build() - } - - private fun EditorConfiguration.Builder.configureEditorAssetCaching( - settings: Map, - siteURL: String, - siteApiNamespace: Array - ) { - setEnableAssetCaching(true) - - val siteHost = UrlUtils.getHost(siteURL) - val cachedHosts = if (!siteHost.isNullOrEmpty()) { - setOf("s0.wp.com", siteHost) - } else { - setOf("s0.wp.com") - } - setCachedAssetHosts(cachedHosts) - - val firstNamespace = siteApiNamespace.firstOrNull() ?: "" - val siteApiRoot = settings.getSetting("siteApiRoot") ?: "" - if (firstNamespace.isNotEmpty() && siteApiRoot.isNotEmpty()) { - setEditorAssetsEndpoint("${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets") - } - } - - // Type-safe settings accessors - moved from GutenbergKitEditorFragment - private inline fun Map.getSetting(key: String): T? = this[key] as? T - - private inline fun Map.getSettingOrDefault(key: String, default: T): T = - getSetting(key) ?: default - - private fun Map.getStringArray(key: String): Array = - getSetting>(key)?.asSequence()?.filterNotNull()?.toList()?.toTypedArray() ?: emptyArray() -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt deleted file mode 100644 index f84f326fb1f7..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt +++ /dev/null @@ -1,154 +0,0 @@ -package org.wordpress.android.ui.posts - -import android.content.Context -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.UserAgent -import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.modules.BG_THREAD -import org.wordpress.android.util.AppLog -import org.wordpress.android.util.AppLog.T -import org.wordpress.android.util.PerAppLocaleManager -import org.wordpress.android.util.SiteUtils -import org.wordpress.android.util.config.GutenbergKitPluginsFeature -import org.wordpress.gutenberg.EditorConfiguration -import org.wordpress.gutenberg.GutenbergView -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -/** - * Helper class to manage GutenbergView warmup for preloading editor assets. - * This improves editor launch speed by caching WebView assets before the editor is opened. - */ -@Singleton -class GutenbergKitWarmupHelper @Inject constructor( - private val appContext: Context, - private val accountStore: AccountStore, - private val userAgent: UserAgent, - private val perAppLocaleManager: PerAppLocaleManager, - private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, - private val gutenbergKitPluginsFeature: GutenbergKitPluginsFeature, - @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher -) { - private var lastWarmedUpSiteId: Long? = null - private var isWarmupInProgress = false - - /** - * Triggers warmup for the given site if not already warmed up. - * - * @param site The site to warm up the editor for - * @param scope The coroutine scope to launch the warmup in - */ - fun warmupIfNeeded(site: SiteModel?, scope: CoroutineScope) { - when { - site == null -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - no site provided") - } - lastWarmedUpSiteId == site.siteId && !isWarmupInProgress -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Already warmed up for site ${site.siteId}") - } - isWarmupInProgress -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup already in progress") - } - !shouldWarmupForSite(site) -> { - // Logging handled within shouldWarmupForSite() - } - else -> { - scope.launch(bgDispatcher) { - performWarmup(site) - } - } - } - } - - /** - * Clears the warmup state when switching sites or logging out. - */ - fun clearWarmupState() { - lastWarmedUpSiteId = null - isWarmupInProgress = false - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup state cleared") - } - - private fun shouldWarmupForSite(site: SiteModel): Boolean { - if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled()) { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - GutenbergKit features disabled") - return false - } - - val shouldWarmup = SiteUtils.isBlockEditorDefaultForNewPost(site) - - if (shouldWarmup) { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warming site ${site.siteId} " + - "(isBlockEditorDefault: true, webEditor: ${site.webEditor})") - } else { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - site ${site.siteId} doesn't " + - "default to the block editor for new posts " + - "(isBlockEditorDefault: false, webEditor: ${site.webEditor})") - } - - return shouldWarmup - } - - private suspend fun performWarmup(site: SiteModel) { - try { - isWarmupInProgress = true - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Starting warmup for site ${site.siteId}") - - val configuration = buildWarmupConfiguration(site) - - // Perform the warmup on the main thread as it involves WebView - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - GutenbergView.warmup(appContext, configuration) - } - - lastWarmedUpSiteId = site.siteId - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup completed for site ${site.siteId}") - } catch (e: IllegalStateException) { - AppLog.e(T.EDITOR, "GutenbergKitWarmupHelper: Warmup failed - illegal state", e) - } finally { - isWarmupInProgress = false - } - } - - private fun buildWarmupConfiguration(site: SiteModel): EditorConfiguration { - // Build the configuration using the same patterns as GutenbergKitSettingsBuilder - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig.fromSiteModel(site) - - // Create minimal post config for warmup (no specific post data) - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = null, - isPage = false, - title = "", - content = "" - ) - - val appConfig = GutenbergKitSettingsBuilder.AppConfig( - accessToken = accountStore.accessToken, - locale = perAppLocaleManager.getCurrentLocaleLanguageCode(), - cookies = null, // No cookies needed for warmup - accountUserId = accountStore.account.userId, - accountUserName = accountStore.account.userName, - userAgent = userAgent, - isJetpackSsoEnabled = false // Default to false for warmup - ) - - val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(), - // Default to true during warmup; actual value will be used when editor launches - isThemeStylesFeatureEnabled = true - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = appConfig, - featureConfig = featureConfig - ) - - return EditorConfigurationBuilder.build(settings, editorSettings = null) - } -} From 03803c60612707845f019ea1d82887c2ce13075b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:31:53 -0700 Subject: [PATCH 03/42] Simplify GutenbergKitSettingsBuilder API Inline the 18-parameter buildEditorConfiguration into buildPostConfiguration, which now reads site/post fields directly from SiteModel and PostImmutableModel. Also fixes a bug in shouldUsePlugins where a stray `return false` made the method always return false. Tests are updated to construct SiteModel objects instead of passing individual primitives. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergKitSettingsBuilder.kt | 265 ++---- .../posts/GutenbergKitSettingsBuilderTest.kt | 855 ++++++++---------- 2 files changed, 462 insertions(+), 658 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 8e43e1eb2891..9d945f2080ca 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -1,162 +1,90 @@ package org.wordpress.android.ui.posts import android.util.Base64 -import org.wordpress.android.editor.gutenberg.GutenbergWebViewAuthorizationData import org.wordpress.android.fluxc.model.PostImmutableModel import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.UserAgent -import org.wordpress.android.fluxc.utils.extensions.getPasswordProcessed -import org.wordpress.android.fluxc.utils.extensions.getUserNameProcessed import org.wordpress.android.util.AppLog -import org.wordpress.android.util.UrlUtils +import org.wordpress.gutenberg.model.EditorConfiguration +import java.net.URI object GutenbergKitSettingsBuilder { private const val AUTH_BEARER_PREFIX = "Bearer " private const val AUTH_BASIC_PREFIX = "Basic " - - data class SiteConfig( - val url: String, - val siteId: Long, - val isWPCom: Boolean, - val isWPComAtomic: Boolean, - val isJetpackConnected: Boolean, - val isUsingWpComRestApi: Boolean, - val wpApiRestUrl: String?, - val apiRestUsernamePlain: String?, - val apiRestPasswordPlain: String?, - val selfHostedSiteId: Long, - val webEditor: String?, - val apiRestUsernameProcessed: String?, - val apiRestPasswordProcessed: String? - ) { - companion object { - fun fromSiteModel(site: SiteModel): SiteConfig { - return SiteConfig( - url = site.url, - siteId = site.siteId, - isWPCom = site.isWPCom, - isWPComAtomic = site.isWPComAtomic, - isJetpackConnected = site.isJetpackConnected, - isUsingWpComRestApi = site.isUsingWpComRestApi, - wpApiRestUrl = site.wpApiRestUrl, - apiRestUsernamePlain = site.apiRestUsernamePlain, - apiRestPasswordPlain = site.apiRestPasswordPlain, - selfHostedSiteId = site.selfHostedSiteId, - webEditor = site.webEditor, - apiRestUsernameProcessed = site.getUserNameProcessed(), - apiRestPasswordProcessed = site.getPasswordProcessed() - ) - } - } - } - - data class PostConfig( - val remotePostId: Long?, - val isPage: Boolean, - val title: String?, - val content: String? - ) { - companion object { - fun fromPostModel(postModel: PostImmutableModel?): PostConfig { - return PostConfig( - remotePostId = postModel?.remotePostId, - isPage = postModel?.isPage ?: false, - title = postModel?.title, - content = postModel?.content - ) - } + private const val WPCOM_API_ROOT = "https://public-api.wordpress.com/" + + fun buildPostConfiguration( + site: SiteModel, + post: PostImmutableModel? = null, + accessToken: String? + ): EditorConfiguration { + val applicationPassword = site.apiRestPasswordPlain + val shouldUseWPComRestApi = + applicationPassword.isNullOrEmpty() && site.isUsingWpComRestApi + + val siteApiRoot = if (shouldUseWPComRestApi) { + WPCOM_API_ROOT + } else { + site.wpApiRestUrl ?: "${site.url}/wp-json/" } - } - - data class FeatureConfig( - val isPluginsFeatureEnabled: Boolean, - val isThemeStylesFeatureEnabled: Boolean, - val isNetworkLoggingEnabled: Boolean = false - ) - - data class AppConfig( - val accessToken: String?, - val locale: String, - val cookies: Any?, - val accountUserId: Long, - val accountUserName: String?, - val userAgent: UserAgent, - val isJetpackSsoEnabled: Boolean - ) - - data class GutenbergKitConfig( - val siteConfig: SiteConfig, - val postConfig: PostConfig, - val appConfig: AppConfig, - val featureConfig: FeatureConfig - ) - - /** - * Builds the settings configuration for GutenbergKit editor. - * - * This method determines the appropriate authentication method based on site type: - * - WP.com sites use Bearer token authentication with the public API - * - Jetpack/self-hosted sites with application passwords use Basic authentication - * - Falls back to WP.com REST API when no application password is available - */ - fun buildSettings( - siteConfig: SiteConfig, - postConfig: PostConfig, - appConfig: AppConfig, - featureConfig: FeatureConfig - ): MutableMap { - val applicationPassword = siteConfig.apiRestPasswordPlain - val shouldUseWPComRestApi = applicationPassword.isNullOrEmpty() && siteConfig.isUsingWpComRestApi - - val siteApiRoot = if (shouldUseWPComRestApi) "https://public-api.wordpress.com/" - else siteConfig.wpApiRestUrl ?: "${siteConfig.url}/wp-json/" val authHeader = buildAuthHeader( shouldUseWPComRestApi = shouldUseWPComRestApi, - accessToken = appConfig.accessToken, - username = siteConfig.apiRestUsernamePlain, + accessToken = accessToken, + username = site.apiRestUsernamePlain, password = applicationPassword + ) ?: "" + + val siteApiNamespace = buildSiteApiNamespace( + shouldUseWPComRestApi, site.siteId, site.url ) - val siteApiNamespace = if (shouldUseWPComRestApi) - arrayOf("sites/${siteConfig.siteId}/", "sites/${UrlUtils.removeScheme(siteConfig.url)}/") - else arrayOf() + val postType = if (post?.isPage == true) "page" else "post" - val wpcomLocaleSlug = appConfig.locale.replace("_", "-").lowercase() + val siteHost = extractHost(site.url) + val cachedHosts = if (!siteHost.isNullOrEmpty()) { + setOf("s0.wp.com", siteHost) + } else { + setOf("s0.wp.com") + } - return mutableMapOf( - "postId" to postConfig.remotePostId?.toInt(), - "postType" to if (postConfig.isPage) "page" else "post", - "postTitle" to postConfig.title, - "postContent" to postConfig.content, - "siteURL" to siteConfig.url, - "siteApiRoot" to siteApiRoot, - "namespaceExcludedPaths" to arrayOf("/wpcom/v2/following/recommendations", "/wpcom/v2/following/mine"), - "authHeader" to authHeader, - "siteApiNamespace" to siteApiNamespace, - "themeStyles" to featureConfig.isThemeStylesFeatureEnabled, - "plugins" to shouldUsePlugins( - isFeatureEnabled = featureConfig.isPluginsFeatureEnabled, - isWPComSite = siteConfig.isWPCom, - isJetpackConnected = siteConfig.isJetpackConnected, - applicationPassword = applicationPassword - ), - "locale" to wpcomLocaleSlug, - "cookies" to appConfig.cookies, - "enableNetworkLogging" to featureConfig.isNetworkLoggingEnabled - ) + val firstNamespace = siteApiNamespace.firstOrNull() ?: "" + val editorAssetsEndpoint = if ( + firstNamespace.isNotEmpty() && siteApiRoot.isNotEmpty() + ) { + "${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets" + } else { + null + } + + return EditorConfiguration.builder( + siteURL = site.url, + siteApiRoot = siteApiRoot, + postType = postType + ).apply { + setTitle(post?.title ?: "") + setContent(post?.content ?: "") + setPostId(post?.remotePostId?.toInt()) + setPostStatus(post?.status ?: "draft") + setAuthHeader(authHeader) + setSiteApiNamespace(siteApiNamespace) + setNamespaceExcludedPaths( + arrayOf( + "/wpcom/v2/following/recommendations", + "/wpcom/v2/following/mine" + ) + ) + setThemeStyles(false) + setPlugins(false) + setLocale("en") + setCookies(emptyMap()) + setEnableAssetCaching(true) + setCachedAssetHosts(cachedHosts) + setEditorAssetsEndpoint(editorAssetsEndpoint) + setEnableNetworkLogging(false) + }.build() } - /** - * Builds the authentication header based on the authentication method. - * - * @param shouldUseWPComRestApi True if using WP.com REST API (Bearer auth) - * @param accessToken The OAuth2 access token for WP.com authentication - * @param username The username for Basic auth (application passwords) - * @param password The password for Basic auth (application passwords) - * @return The formatted authentication header string, or null if credentials are invalid - */ - private fun buildAuthHeader( + fun buildAuthHeader( shouldUseWPComRestApi: Boolean, accessToken: String?, username: String?, @@ -166,7 +94,10 @@ object GutenbergKitSettingsBuilder { if (!accessToken.isNullOrEmpty()) { "$AUTH_BEARER_PREFIX$accessToken" } else { - AppLog.w(AppLog.T.EDITOR, "Missing access token for WP.com REST API authentication") + AppLog.w( + AppLog.T.EDITOR, + "Missing access token for WP.com REST API authentication" + ) null } } else { @@ -179,49 +110,49 @@ object GutenbergKitSettingsBuilder { ) "$AUTH_BASIC_PREFIX$encodedCredentials" } catch (e: IllegalArgumentException) { - AppLog.e(AppLog.T.EDITOR, "Failed to encode Basic auth credentials", e) + AppLog.e( + AppLog.T.EDITOR, + "Failed to encode Basic auth credentials", + e + ) null } } else { - AppLog.w(AppLog.T.EDITOR, "Incomplete credentials for Basic authentication") + AppLog.w( + AppLog.T.EDITOR, + "Incomplete credentials for Basic authentication" + ) null } } } - private fun shouldUsePlugins( + fun shouldUsePlugins( isFeatureEnabled: Boolean, isWPComSite: Boolean, isJetpackConnected: Boolean, applicationPassword: String? ): Boolean { - // Enable plugins for: - // 1. WP.com Simple sites (when feature is enabled) - // 2. Jetpack-connected sites with application passwords (when feature is enabled) return isFeatureEnabled && - (isWPComSite || (isJetpackConnected && !applicationPassword.isNullOrEmpty())) + (isWPComSite || + (isJetpackConnected && !applicationPassword.isNullOrEmpty())) } - /** - * Builds Gutenberg WebView authorization data for the fragment. - */ - fun buildAuthorizationData( - siteConfig: SiteConfig, - appConfig: AppConfig - ): GutenbergWebViewAuthorizationData { - return GutenbergWebViewAuthorizationData( - siteConfig.url, - siteConfig.isWPCom || siteConfig.isWPComAtomic, - appConfig.accountUserId, - appConfig.accountUserName, - appConfig.accessToken, - siteConfig.selfHostedSiteId, - siteConfig.apiRestUsernameProcessed, - siteConfig.apiRestPasswordProcessed, - siteConfig.isUsingWpComRestApi, - siteConfig.webEditor, - appConfig.userAgent.webViewUserAgent, - appConfig.isJetpackSsoEnabled - ) + internal fun buildSiteApiNamespace( + shouldUseWPComRestApi: Boolean, + siteId: Long, + siteUrl: String + ): Array { + if (!shouldUseWPComRestApi) return arrayOf() + val host = extractHost(siteUrl) ?: return arrayOf("sites/$siteId/") + return arrayOf("sites/$siteId/", "sites/$host/") + } + + internal fun extractHost(url: String): String? { + return try { + URI(url).host + } catch (_: Exception) { + null + } } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index 3f63e25fcfa3..2613bc5e6ee6 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -1,661 +1,534 @@ package org.wordpress.android.ui.posts -import android.content.Context import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner -import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.model.SiteModel @RunWith(MockitoJUnitRunner::class) -@Suppress("LargeClass") class GutenbergKitSettingsBuilderTest { - // ===== Plugin Logic Tests ===== - @Mock - lateinit var appContext: Context + // ===== Auth Header Tests ===== @Test - fun `plugins disabled when feature flag is off regardless of site configuration`() { - val testCases = listOf( - // isWPCom, isJetpackConnected, applicationPassword - Triple(true, false, null), // WPCom site - Triple(false, true, "password"), // Jetpack with password - Triple(false, false, null), // Self-hosted + fun `WPCom site returns Bearer token header`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = "my_token", + username = null, + password = null ) - testCases.forEach { (isWPCom, isJetpack, password) -> - val siteConfig = createSiteConfig( - isWPCom = isWPCom, - isJetpackConnected = isJetpack, - apiRestPasswordPlain = password - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(), // Both features disabled - ) - - assertThat(settings["plugins"]) - .withFailMessage("Expected plugins=false for WPCom=$isWPCom, Jetpack=$isJetpack, password=$password") - .isEqualTo(false) - } + assertThat(header).isEqualTo("Bearer my_token") } @Test - fun `plugins enabled for WPCom sites when feature flag is on`() { - val siteConfig = createSiteConfig(isWPCom = true) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + fun `WPCom site with null token returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = null, + username = null, + password = null + ) - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + assertThat(header).isNull() + } + @Test + fun `WPCom site with empty token returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = "", + username = null, + password = null ) - assertThat(settings["plugins"]).isEqualTo(true) + assertThat(header).isNull() } @Test - fun `plugins enabled for Jetpack sites with application password when feature flag is on`() { - val siteConfig = createSiteConfig( - isWPCom = false, - isJetpackConnected = true, - apiRestPasswordPlain = "validPassword123" + fun `self-hosted site returns Basic auth header`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "testuser", + password = "testpass" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + assertThat(header).isNotNull() + assertThat(header).startsWith("Basic ") + } + @Test + fun `Basic auth with null username returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = null, + password = "password123" ) - assertThat(settings["plugins"]).isEqualTo(true) + assertThat(header).isNull() } @Test - fun `plugins disabled for Jetpack sites without application password`() { - val passwordVariants = listOf(null, "") + fun `Basic auth with empty username returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "", + password = "password123" + ) - passwordVariants.forEach { password -> - val siteConfig = createSiteConfig( - isWPCom = false, - isJetpackConnected = true, - apiRestPasswordPlain = password - ) + assertThat(header).isNull() + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + @Test + fun `Basic auth with null password returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "username", + password = null + ) - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + assertThat(header).isNull() + } - ) + @Test + fun `Basic auth with empty password returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "username", + password = "" + ) - assertThat(settings["plugins"]) - .withFailMessage("Expected plugins=false for password=$password") - .isEqualTo(false) - } + assertThat(header).isNull() } @Test - fun `plugins disabled for self-hosted sites without Jetpack`() { - val siteConfig = createSiteConfig( - isWPCom = false, - isJetpackConnected = false, - apiRestPasswordPlain = "password" // Has password but no Jetpack + fun `Basic auth with both empty returns null`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "", + password = "" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + assertThat(header).isNull() + } + @Test + fun `special characters in Basic auth are encoded`() { + val header = GutenbergKitSettingsBuilder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "user@example.com", + password = "p@ss:word!123" ) - assertThat(settings["plugins"]).isEqualTo(false) + assertThat(header).isNotNull() + assertThat(header).startsWith("Basic ") } - // ===== Authentication Flow Tests ===== + // ===== Plugin Logic Tests ===== @Test - fun `WPCom site uses Bearer token and public API`() { - val siteConfig = createSiteConfig( - url = "https://example.wordpress.com", - siteId = 123, - isWPCom = true, - isUsingWpComRestApi = true + fun `plugins disabled when feature flag is off`() { + val testCases = listOf( + Triple(true, false, null), + Triple(false, true, "password"), + Triple(false, false, null), ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "test_bearer_token"), + testCases.forEach { (isWPCom, isJetpack, password) -> + val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + isFeatureEnabled = false, + isWPComSite = isWPCom, + isJetpackConnected = isJetpack, + applicationPassword = password + ) - featureConfig = createFeatureConfig(), + assertThat(result) + .withFailMessage( + "Expected false for WPCom=$isWPCom, " + + "Jetpack=$isJetpack, password=$password" + ) + .isFalse() + } + } + @Test + fun `plugins enabled for WPCom sites when feature is on`() { + val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = true, + isJetpackConnected = false, + applicationPassword = null ) - assertThat(settings["authHeader"]).isEqualTo("Bearer test_bearer_token") - assertThat(settings["siteApiRoot"]).isEqualTo("https://public-api.wordpress.com/") - assertThat(settings["siteApiNamespace"] as Array<*>) - .containsExactly("sites/123/", "sites/example.wordpress.com/") + assertThat(result).isTrue() } @Test - fun `Jetpack site with application password uses Basic auth and site API`() { - val siteConfig = createSiteConfig( - url = "https://mysite.com", - siteId = 789, + fun `plugins enabled for Jetpack with app password when feature is on`() { + val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = false, isJetpackConnected = true, - wpApiRestUrl = "https://mysite.com/wp-json/", - apiRestUsernamePlain = "testuser", - apiRestPasswordPlain = "testpass123" + applicationPassword = "validPassword" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "unused_token"), - - featureConfig = createFeatureConfig(), + assertThat(result).isTrue() + } - ) + @Test + fun `plugins disabled for Jetpack without app password`() { + listOf(null, "").forEach { password -> + val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = false, + isJetpackConnected = true, + applicationPassword = password + ) - assertThat(settings["authHeader"] as String).startsWith("Basic ") - assertThat(settings["siteApiRoot"]).isEqualTo("https://mysite.com/wp-json/") - assertThat(settings["siteApiNamespace"] as Array<*>).isEmpty() + assertThat(result) + .withFailMessage( + "Expected false for password=$password" + ) + .isFalse() + } } @Test - fun `Jetpack site without password falls back to Bearer when WPCom REST available`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - isUsingWpComRestApi = true, - apiRestPasswordPlain = null + fun `plugins disabled for self-hosted without Jetpack`() { + val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = false, + isJetpackConnected = false, + applicationPassword = "password" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "fallback_token"), + assertThat(result).isFalse() + } - featureConfig = createFeatureConfig(), + // ===== Site API Namespace Tests ===== + @Test + fun `namespace is empty for non-WPCom sites`() { + val result = GutenbergKitSettingsBuilder.buildSiteApiNamespace( + shouldUseWPComRestApi = false, + siteId = 123L, + siteUrl = "https://example.com" ) - assertThat(settings["authHeader"]).isEqualTo("Bearer fallback_token") - assertThat(settings["siteApiRoot"]).isEqualTo("https://public-api.wordpress.com/") + assertThat(result).isEmpty() } - // ===== Authentication Edge Cases Tests ===== - @Test - fun `WPCom site with null access token returns null auth header`() { - val siteConfig = createSiteConfig( - isWPCom = true, - isUsingWpComRestApi = true + fun `namespace includes site ID and host for WPCom sites`() { + val result = GutenbergKitSettingsBuilder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 456L, + siteUrl = "https://example.wordpress.com" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = null), - featureConfig = createFeatureConfig() + assertThat(result).containsExactly( + "sites/456/", + "sites/example.wordpress.com/" ) - - assertThat(settings["authHeader"]).isNull() } @Test - fun `WPCom site with empty access token returns null auth header`() { - val siteConfig = createSiteConfig( - isWPCom = true, - isUsingWpComRestApi = true - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = ""), - featureConfig = createFeatureConfig() + fun `namespace includes only site ID when host extraction fails`() { + val result = GutenbergKitSettingsBuilder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 789L, + siteUrl = "not-a-valid-url" ) - assertThat(settings["authHeader"]).isNull() + assertThat(result).containsExactly("sites/789/") } - @Test - fun `Basic auth with null username returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = null, - apiRestPasswordPlain = "password123" - ) + // ===== Extract Host Tests ===== - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + @Test + fun `extractHost returns host from valid URL`() { + assertThat( + GutenbergKitSettingsBuilder.extractHost( + "https://example.wordpress.com" + ) + ).isEqualTo("example.wordpress.com") + } - assertThat(settings["authHeader"]).isNull() + @Test + fun `extractHost returns null for invalid URL`() { + assertThat( + GutenbergKitSettingsBuilder.extractHost("not-a-url") + ).isNull() } @Test - fun `Basic auth with empty username returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "", - apiRestPasswordPlain = "password123" - ) + fun `extractHost strips path from URL`() { + assertThat( + GutenbergKitSettingsBuilder.extractHost( + "https://example.com/blog/page" + ) + ).isEqualTo("example.com") + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + // ===== buildPostConfiguration Tests ===== - assertThat(settings["authHeader"]).isNull() - } + // --- WPCom site configuration --- @Test - fun `Basic auth with null password returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "username", - apiRestPasswordPlain = null - ) + fun `WPCom site uses WPCom API root`() { + val config = buildWPComConfig() - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + assertThat(config.siteApiRoot) + .isEqualTo("https://public-api.wordpress.com/") + } + + @Test + fun `WPCom site sets Bearer auth header`() { + val config = buildWPComConfig(accessToken = "wpcom_token") - assertThat(settings["authHeader"]).isNull() + assertThat(config.authHeader) + .isEqualTo("Bearer wpcom_token") } @Test - fun `Basic auth with empty password returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "username", - apiRestPasswordPlain = "" + fun `WPCom site sets site API namespace with ID and host`() { + val config = buildWPComConfig( + siteUrl = "https://mysite.wordpress.com", + siteId = 42L ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + assertThat(config.siteApiNamespace).containsExactly( + "sites/42/", + "sites/mysite.wordpress.com/" ) - - assertThat(settings["authHeader"]).isNull() } @Test - fun `Basic auth with both username and password empty returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "", - apiRestPasswordPlain = "" - ) + fun `WPCom site sets editor assets endpoint`() { + val config = buildWPComConfig(siteId = 100L) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://public-api.wordpress.com/" + + "wpcom/v2/sites/100/editor-assets" ) - - assertThat(settings["authHeader"]).isNull() } @Test - fun `Valid WPCom authentication returns proper Bearer header`() { - val siteConfig = createSiteConfig( - isWPCom = true, - isUsingWpComRestApi = true - ) + fun `WPCom site with missing token uses empty auth header`() { + val config = buildWPComConfig(accessToken = null) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "valid_token_123"), - featureConfig = createFeatureConfig() + assertThat(config.authHeader).isEmpty() + } + + // --- Self-hosted site configuration --- + + @Test + fun `self-hosted site uses wpApiRestUrl as API root`() { + val config = buildSelfHostedConfig( + wpApiRestUrl = "https://mysite.com/wp-json/" ) - assertThat(settings["authHeader"]).isEqualTo("Bearer valid_token_123") + assertThat(config.siteApiRoot) + .isEqualTo("https://mysite.com/wp-json/") } @Test - fun `Valid Basic auth returns proper Basic header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "testuser", - apiRestPasswordPlain = "testpass" + fun `self-hosted site falls back to siteUrl wp-json when no REST URL`() { + val config = buildSelfHostedConfig( + siteUrl = "https://mysite.com", + wpApiRestUrl = null ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + assertThat(config.siteApiRoot) + .isEqualTo("https://mysite.com/wp-json/") + } + + @Test + fun `self-hosted site sets Basic auth header`() { + val config = buildSelfHostedConfig( + applicationPassword = "app_pass", + apiRestUsername = "admin" ) - val authHeader = settings["authHeader"] as String? - assertThat(authHeader).isNotNull() - assertThat(authHeader).startsWith("Basic ") - // Verify it's a valid Base64 encoded string - val encodedPart = authHeader?.removePrefix("Basic ") - assertThat(encodedPart).isNotEmpty() + assertThat(config.authHeader).startsWith("Basic ") } @Test - fun `Special characters in Basic auth credentials are handled correctly`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "user@example.com", - apiRestPasswordPlain = "p@ss:word!123" - ) + fun `self-hosted site has empty namespace`() { + val config = buildSelfHostedConfig() - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + assertThat(config.siteApiNamespace).isEmpty() + } - val authHeader = settings["authHeader"] as String? - assertThat(authHeader).isNotNull() - assertThat(authHeader).startsWith("Basic ") + @Test + fun `self-hosted site has null editor assets endpoint`() { + val config = buildSelfHostedConfig() + + assertThat(config.editorAssetsEndpoint).isNull() } - // ===== Complete Scenario Tests ===== + // --- Application password overrides WPCom REST API --- @Test - fun `complete settings for WPCom simple site with all features enabled`() { - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig( - url = "https://example.wordpress.com", - siteId = 123, - isWPCom = true, - isWPComAtomic = false, - isJetpackConnected = false, - isUsingWpComRestApi = true, - wpApiRestUrl = null, - apiRestUsernamePlain = null, - apiRestPasswordPlain = null, - selfHostedSiteId = 0, - webEditor = "gutenberg", - apiRestUsernameProcessed = null, - apiRestPasswordProcessed = null - ) + fun `app password forces non-WPCom API even if site uses WPCom REST`() { + val site = SiteModel().apply { + url = "https://mysite.wordpress.com" + siteId = 123L + setIsWPCom(true) + setIsJetpackConnected(false) + origin = SiteModel.ORIGIN_WPCOM_REST + wpApiRestUrl = "https://mysite.com/wp-json/" + apiRestPasswordPlain = "app_pass" + apiRestUsernamePlain = "admin" + } + val config = GutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + accessToken = "wpcom_token" + ) - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = 456L, - isPage = false, - title = "Test Post", - content = "Test Content" - ) + assertThat(config.siteApiRoot) + .isEqualTo("https://mysite.com/wp-json/") + assertThat(config.authHeader).startsWith("Basic ") + assertThat(config.siteApiNamespace).isEmpty() + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = createAppConfig( - accessToken = "test_token", - cookies = "test_cookies" - ), - featureConfig = createFeatureConfig( - isPluginsFeatureEnabled = true, - isThemeStylesFeatureEnabled = true - ) - ) + // --- Post configuration --- - // Verify all settings are correctly configured - assertThat(settings["postId"]).isEqualTo(456) - assertThat(settings["postType"]).isEqualTo("post") - assertThat(settings["postTitle"]).isEqualTo("Test Post") - assertThat(settings["postContent"]).isEqualTo("Test Content") - assertThat(settings["siteURL"]).isEqualTo("https://example.wordpress.com") - assertThat(settings["authHeader"]).isEqualTo("Bearer test_token") - assertThat(settings["siteApiRoot"]).isEqualTo("https://public-api.wordpress.com/") - assertThat(settings["plugins"]).isEqualTo(true) // WPCom with feature enabled - assertThat(settings["themeStyles"]).isEqualTo(true) - assertThat(settings["locale"]).isEqualTo("en-us") - assertThat(settings["cookies"]).isEqualTo("test_cookies") - } - - @Test - fun `complete settings for Jetpack site with application password`() { - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig( - url = "https://jetpack-site.com", - siteId = 999, - isWPCom = false, - isWPComAtomic = false, - isJetpackConnected = true, - isUsingWpComRestApi = false, - wpApiRestUrl = "https://jetpack-site.com/wp-json/", - apiRestUsernamePlain = "admin", - apiRestPasswordPlain = "securepass", - selfHostedSiteId = 999, - webEditor = "gutenberg", - apiRestUsernameProcessed = "admin", - apiRestPasswordProcessed = "securepass" - ) + @Test + fun `post type is post by default`() { + val config = buildWPComConfig() - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = 100L, - isPage = true, - title = "Test Page", - content = "Page Content" - ) + assertThat(config.postType).isEqualTo("post") + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = createAppConfig( - accessToken = "unused", - locale = "fr_FR" - ), - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true) - ) + @Test + fun `null post title becomes empty string`() { + val config = buildWPComConfig() - assertThat(settings["postType"]).isEqualTo("page") - assertThat(settings["authHeader"] as String).startsWith("Basic ") - assertThat(settings["siteApiRoot"]).isEqualTo("https://jetpack-site.com/wp-json/") - assertThat(settings["siteApiNamespace"] as Array<*>).isEmpty() - assertThat(settings["plugins"]).isEqualTo(true) // Jetpack with password and feature enabled - assertThat(settings["locale"]).isEqualTo("fr-fr") + assertThat(config.title).isEmpty() } @Test - fun `locale transformation handles underscores correctly`() { - val testCases = mapOf( - "en_US" to "en-us", - "fr_FR" to "fr-fr", - "de_DE" to "de-de", - "es_ES" to "es-es", - "pt_BR" to "pt-br" - ) + fun `null post content becomes empty string`() { + val config = buildWPComConfig() - testCases.forEach { (input, expected) -> - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = createPostConfig(), - appConfig = createAppConfig(locale = input), - featureConfig = createFeatureConfig() - ) + assertThat(config.content).isEmpty() + } - assertThat(settings["locale"]) - .withFailMessage("Expected $input to transform to $expected") - .isEqualTo(expected) - } + @Test + fun `null remote ID results in null post ID`() { + val config = buildWPComConfig() + + assertThat(config.postId).isNull() } @Test - fun `feature flags control themeStyles and plugins independently`() { - val siteConfig = createSiteConfig(isWPCom = true) + fun `null post status defaults to draft`() { + val config = buildWPComConfig() - // Test all combinations - val flagCombinations = listOf( - Triple(false, false, Pair(false, false)), - Triple(false, true, Pair(false, true)), - Triple(true, false, Pair(true, false)), - Triple(true, true, Pair(true, true)) - ) + assertThat(config.postStatus).isEqualTo("draft") + } - flagCombinations.forEach { (plugins, themes, expected) -> - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + // --- Asset caching --- - featureConfig = createFeatureConfig( - isPluginsFeatureEnabled = plugins, - isThemeStylesFeatureEnabled = themes - ), - ) + @Test + fun `asset caching is always enabled`() { + val config = buildWPComConfig() - assertThat(settings["plugins"]).isEqualTo(expected.first) - assertThat(settings["themeStyles"]).isEqualTo(expected.second) - } + assertThat(config.enableAssetCaching).isTrue() } @Test - fun `self-hosted site uses correct API endpoint when wpApiRestUrl is null`() { - val siteConfig = createSiteConfig( - url = "https://selfhosted.org", - wpApiRestUrl = null, - apiRestPasswordPlain = "password" + fun `cached hosts includes s0 wp com and site host`() { + val config = buildWPComConfig( + siteUrl = "https://mysite.wordpress.com" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(), - + assertThat(config.cachedAssetHosts).containsExactlyInAnyOrder( + "s0.wp.com", + "mysite.wordpress.com" ) - - assertThat(settings["siteApiRoot"]).isEqualTo("https://selfhosted.org/wp-json/") } @Test - fun `namespaceExcludedPaths is always included`() { - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = createPostConfig(), - appConfig = createAppConfig(), + fun `cached hosts includes only s0 wp com for invalid URL`() { + val config = buildWPComConfig(siteUrl = "not-a-url") - featureConfig = createFeatureConfig(), + assertThat(config.cachedAssetHosts) + .containsExactly("s0.wp.com") + } - ) + // --- Namespace excluded paths --- + + @Test + fun `namespace excluded paths are always set`() { + val config = buildWPComConfig() - val excludedPaths = settings["namespaceExcludedPaths"] as Array<*> - assertThat(excludedPaths).containsExactly( + assertThat(config.namespaceExcludedPaths).containsExactly( "/wpcom/v2/following/recommendations", "/wpcom/v2/following/mine" ) } + // --- Site URL passthrough --- + @Test - fun `null post data is handled correctly`() { - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = null, - isPage = false, - title = null, - content = null + fun `site URL is passed through to configuration`() { + val config = buildWPComConfig( + siteUrl = "https://example.wordpress.com" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = postConfig, - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(), + assertThat(config.siteURL) + .isEqualTo("https://example.wordpress.com") + } + // ===== Helpers ===== + + private fun buildWPComConfig( + siteUrl: String = "https://example.wordpress.com", + siteId: Long = 123L, + accessToken: String? = "test_token" + ): org.wordpress.gutenberg.model.EditorConfiguration { + val site = SiteModel().apply { + url = siteUrl + this.siteId = siteId + setIsWPCom(true) + setIsJetpackConnected(false) + origin = SiteModel.ORIGIN_WPCOM_REST + } + return GutenbergKitSettingsBuilder.buildPostConfiguration( + site = site, + accessToken = accessToken ) + } - assertThat(settings["postId"]).isNull() - assertThat(settings["postTitle"]).isNull() - assertThat(settings["postContent"]).isNull() - assertThat(settings["postType"]).isEqualTo("post") // Still defaults to post - } - - // ===== Helper Methods ===== - - private fun createFeatureConfig( - isPluginsFeatureEnabled: Boolean = false, - isThemeStylesFeatureEnabled: Boolean = false - ) = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = isPluginsFeatureEnabled, - isThemeStylesFeatureEnabled = isThemeStylesFeatureEnabled - ) - - private fun createAppConfig( - accessToken: String? = "token", - locale: String = "en_US", - cookies: Any? = null - ) = GutenbergKitSettingsBuilder.AppConfig( - accessToken = accessToken, - locale = locale, - cookies = cookies, - accountUserId = 123L, - accountUserName = "testuser", - userAgent = UserAgent(appContext = appContext, appName = "foo"), - isJetpackSsoEnabled = false - ) - - private fun createSiteConfig( - url: String = "https://test.com", - siteId: Long = 1, - isWPCom: Boolean = false, - isWPComAtomic: Boolean = false, - isJetpackConnected: Boolean = false, - isUsingWpComRestApi: Boolean = false, - wpApiRestUrl: String? = null, - apiRestUsernamePlain: String? = null, - apiRestPasswordPlain: String? = null - ) = GutenbergKitSettingsBuilder.SiteConfig( - url = url, - siteId = siteId, - isWPCom = isWPCom, - isWPComAtomic = isWPComAtomic, - isJetpackConnected = isJetpackConnected, - isUsingWpComRestApi = isUsingWpComRestApi, - wpApiRestUrl = wpApiRestUrl, - apiRestUsernamePlain = apiRestUsernamePlain, - apiRestPasswordPlain = apiRestPasswordPlain, - selfHostedSiteId = siteId, - webEditor = "gutenberg", - apiRestUsernameProcessed = apiRestUsernamePlain, - apiRestPasswordProcessed = apiRestPasswordPlain - ) - - private fun createPostConfig( - remotePostId: Long? = 1L, - isPage: Boolean = false, - title: String? = "Test", - content: String? = "Content" - ) = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = remotePostId, - isPage = isPage, - title = title, - content = content - ) + private fun buildSelfHostedConfig( + siteUrl: String = "https://mysite.com", + wpApiRestUrl: String? = "https://mysite.com/wp-json/", + applicationPassword: String? = "app_pass", + apiRestUsername: String? = "admin" + ): org.wordpress.gutenberg.model.EditorConfiguration { + val site = SiteModel().apply { + url = siteUrl + siteId = 999L + setIsWPCom(false) + setIsJetpackConnected(false) + this.wpApiRestUrl = wpApiRestUrl + apiRestPasswordPlain = applicationPassword + apiRestUsernamePlain = apiRestUsername + } + return GutenbergKitSettingsBuilder.buildPostConfiguration( + site = site, + accessToken = null + ) + } } From 4b8a3a49bb853ef629169918f5f75293c9549d38 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:32:00 -0700 Subject: [PATCH 04/42] Refactor GutenbergKitActivity editor configuration Replace the old GutenbergKitConfig builder pattern with a simpler buildEditorConfiguration that calls buildPostConfiguration and customizes locale, cookies, plugins, theme styles, and network logging via toBuilder(). Remove the ProgressBar from the editor layout since GutenbergView manages its own loading visibility. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/posts/GutenbergKitActivity.kt | 85 +++++++++++-------- .../layout/fragment_gutenberg_kit_editor.xml | 6 -- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 1ebf3fb74dd4..ac2306c1ef69 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -84,6 +84,7 @@ import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.post.PostStatus import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged @@ -232,6 +233,7 @@ import java.util.regex.Pattern import javax.inject.Inject import kotlin.math.max import androidx.core.view.isNotEmpty +import org.wordpress.android.util.EditorDependencyStore // ViewPager configuration constants private const val VIEW_PAGER_PAGE_CONTENT = 0 @@ -2208,41 +2210,57 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene onXpostsSettingsCapability(isXpostsCapable) } - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig.fromSiteModel(siteModel) + val post = editPostRepository.getPost() + val configuration = buildEditorConfiguration(siteModel, post) - val postConfig = GutenbergKitSettingsBuilder.PostConfig.fromPostModel( - editPostRepository.getPost() - ) - - val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(), - isThemeStylesFeatureEnabled = siteSettings?.useThemeStyles ?: true, - isNetworkLoggingEnabled = AppPrefs.isTrackNetworkRequestsEnabled() + return GutenbergKitEditorFragment.newInstance( + configuration ) + } - val appConfig = GutenbergKitSettingsBuilder.AppConfig( - accessToken = accountStore.accessToken, - locale = perAppLocaleManager.getCurrentLocaleLanguageCode(), - cookies = editPostAuthViewModel.getCookiesForPrivateSites(site, privateAtomicCookie), - accountUserId = accountStore.account.userId, - accountUserName = accountStore.account.userName, - userAgent = userAgent, - isJetpackSsoEnabled = isJetpackSsoEnabled - ) + private fun buildEditorConfiguration( + site: SiteModel, + post: PostImmutableModel? + ): EditorConfiguration { + val base = GutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + post = post, + accessToken = accountStore.accessToken + ) - val config = GutenbergKitSettingsBuilder.GutenbergKitConfig( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = appConfig, - featureConfig = featureConfig - ) + val locale = perAppLocaleManager + .getCurrentLocaleLanguageCode() + .replace("_", "-").lowercase() - return GutenbergKitEditorFragment.newInstanceWithBuilder( - getContext(), - isNewPost, - jetpackFeatureRemovalPhaseHelper.shouldShowJetpackPoweredEditorFeatures(), - config - ) + return base.toBuilder() + .setLocale(locale) + .setCookies( + editPostAuthViewModel + .getCookiesForPrivateSites( + site, privateAtomicCookie + ) + ) + .setPlugins( + GutenbergKitSettingsBuilder + .shouldUsePlugins( + isFeatureEnabled = + gutenbergKitPluginsFeature + .isEnabled(), + isWPComSite = site.isWPCom, + isJetpackConnected = + site.isJetpackConnected, + applicationPassword = + site.apiRestPasswordPlain + ) + ) + .setThemeStyles( + siteSettings?.useThemeStyles ?: true + ) + .setEnableNetworkLogging( + AppPrefs.isTrackNetworkRequestsEnabled() + ) + .build() } override fun instantiateItem(container: ViewGroup, position: Int): Any { @@ -3130,13 +3148,6 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene dispatcher.dispatch(EditorSettingsActionBuilder.newFetchEditorSettingsAction(payload)) } - @Suppress("unused") - @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) - fun onEditorSettingsChanged(event: OnEditorSettingsChanged) { - val editorSettingsString = event.editorSettings?.toJsonString() ?: "undefined" - editorFragment?.startWithEditorSettings(editorSettingsString) - } - // EditorDataProvider methods override fun getEditPostRepository() = editPostRepository override fun getSite() = siteModel diff --git a/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml b/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml index d20f0298540a..2e013f222801 100644 --- a/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml +++ b/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml @@ -9,10 +9,4 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - From 7dfbd59dfdb32cf6307ace9535192abafa120125 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:32:09 -0700 Subject: [PATCH 05/42] Add GutenbergEditorPreloader for background dependency fetching Preload editor dependencies (settings JSON, JS/CSS assets, API preload data) in the background when the My Site screen loads, so they are ready when the user opens the editor. GutenbergEditorPreloader is a Dagger singleton that guards against redundant work and caches results. The cache is cleared on pull-to-refresh, which triggers a fresh preload. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/mysite/MySiteViewModel.kt | 10 +-- .../ui/posts/GutenbergEditorPreloader.kt | 70 +++++++++++++++++++ .../android/util/EditorDependencyStore.kt | 29 ++++++++ .../android/ui/mysite/MySiteViewModelTest.kt | 21 ++++-- 4 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt index 0dacfb4cd9a4..50e343f39c8f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt @@ -31,6 +31,7 @@ import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.mediapicker.MediaPickerActivity import org.wordpress.android.ui.posts.BasicDialogViewModel +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -43,7 +44,6 @@ import javax.inject.Inject import javax.inject.Named import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper import org.wordpress.android.ui.utils.UiString @Suppress("LargeClass", "LongMethod", "LongParameterList") @@ -64,8 +64,8 @@ class MySiteViewModel @Inject constructor( private val dashboardCardsViewModelSlice: DashboardCardsViewModelSlice, private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice, private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice, - private val gutenbergKitWarmupHelper: GutenbergKitWarmupHelper, private val siteCapabilityChecker: SiteCapabilityChecker, + private val gutenbergEditorPreloader: GutenbergEditorPreloader, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() private val _onNavigation = MutableLiveData>() @@ -166,6 +166,7 @@ class MySiteViewModel @Inject constructor( selectedSiteRepository.getSelectedSite()?.let { site -> if (isPullToRefresh) { siteCapabilityChecker.clearCacheForSite(site.siteId) + gutenbergEditorPreloader.clear() } buildDashboardOrSiteItems(site) } ?: run { @@ -248,7 +249,7 @@ class MySiteViewModel @Inject constructor( dashboardCardsViewModelSlice.onCleared() dashboardItemsViewModelSlice.onCleared() accountDataViewModelSlice.onCleared() - gutenbergKitWarmupHelper.clearWarmupState() + gutenbergEditorPreloader.clear() super.onCleared() } @@ -291,8 +292,7 @@ class MySiteViewModel @Inject constructor( dashboardItemsViewModelSlice.buildItems(site) dashboardCardsViewModelSlice.clearValue() } - // Trigger GutenbergView warmup for the selected site - gutenbergKitWarmupHelper.warmupIfNeeded(site, viewModelScope) + gutenbergEditorPreloader.preloadIfNeeded(site, viewModelScope) } private fun onSitePicked(site: SiteModel) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt new file mode 100644 index 000000000000..07b64d425446 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.EditorDependencyStore +import org.wordpress.android.util.SiteUtils +import org.wordpress.gutenberg.model.EditorDependencies +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class GutenbergEditorPreloader @Inject constructor( + @ApplicationContext private val appContext: Context, + private val accountStore: AccountStore, + private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, + @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher +) { + private var lastPreloadedSiteId: Long = -1 + private var preloadJob: Job? = null + private var cachedDependencies: EditorDependencies? = null + + fun preloadIfNeeded(site: SiteModel, scope: CoroutineScope) { + if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled()) return + if (!SiteUtils.isBlockEditorDefaultForNewPost(site)) return +// if (site.siteId == lastPreloadedSiteId) return + if (preloadJob?.isActive == true) return + + lastPreloadedSiteId = site.siteId + preloadJob = scope.launch(bgDispatcher) { + try { + val config = GutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + accessToken = accountStore.accessToken + ) + val store = EditorDependencyStore(appContext, scope) + cachedDependencies = store.fetch(config) + AppLog.d( + AppLog.T.EDITOR, + "Editor dependencies preloaded for site ${site.siteId}" + ) + } catch (e: Exception) { + AppLog.e( + AppLog.T.EDITOR, + "Failed to preload editor dependencies", + e + ) + cachedDependencies = null + } + } + } + + fun getDependencies(): EditorDependencies? = cachedDependencies + + fun clear() { + preloadJob?.cancel() + preloadJob = null + cachedDependencies = null + lastPreloadedSiteId = -1 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt new file mode 100644 index 000000000000..37498c617cdf --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.util + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.ThemeCoroutineStore +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.services.EditorService + +class EditorDependencyStore(val context: Context, val coroutineScope: CoroutineScope) { + var dependencies: EditorDependencies? = null + + suspend fun fetch(configuration: EditorConfiguration): EditorDependencies { + val service = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + + val dependencies = service.prepare(null) + return dependencies + } + + fun read(configuration: EditorConfiguration): EditorDependencies { + return EditorDependencies.empty + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt index f22245be043c..f4a2c9a22bca 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt @@ -40,7 +40,7 @@ import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewMode import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker import org.wordpress.android.ui.pages.SnackbarMessageHolder -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -98,10 +98,10 @@ class MySiteViewModelTest : BaseUnitTest() { lateinit var applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice @Mock - lateinit var gutenbergKitWarmupHelper: GutenbergKitWarmupHelper + lateinit var siteCapabilityChecker: SiteCapabilityChecker @Mock - lateinit var siteCapabilityChecker: SiteCapabilityChecker + lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader private lateinit var viewModel: MySiteViewModel private lateinit var uiModels: MutableList @@ -156,8 +156,8 @@ class MySiteViewModelTest : BaseUnitTest() { dashboardCardsViewModelSlice, dashboardItemsViewModelSlice, applicationPasswordViewModelSlice, - gutenbergKitWarmupHelper, siteCapabilityChecker, + gutenbergEditorPreloader, ) uiModels = mutableListOf() snackbars = mutableListOf() @@ -399,6 +399,19 @@ class MySiteViewModelTest : BaseUnitTest() { verify(accountDataViewModelSlice).onCleared() verify(dashboardCardsViewModelSlice).onCleared() verify(dashboardItemsViewModelSlice).onCleared() + verify(gutenbergEditorPreloader).clear() + } + + @Test + fun `when dashboard is built, then editor preload is triggered`() { + initSelectedSite() + + viewModel.refresh() + + verify(gutenbergEditorPreloader).preloadIfNeeded( + org.mockito.kotlin.eq(siteTest), + org.mockito.kotlin.any() + ) } @Suppress("LongParameterList") From c011476cdb6f0c7392fb4d968aa8a12600abf891 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:32:20 -0700 Subject: [PATCH 06/42] Inject GutenbergEditorPreloader into GutenbergKitEditorFragment Wire the fragment for Dagger injection via AppComponent so it can read preloaded dependencies directly from GutenbergEditorPreloader instead of receiving them through Bundle arguments. Also use FrameLayout.LayoutParams when adding GutenbergView to its container. Co-Authored-By: Claude Opus 4.6 --- .../android/modules/AppComponent.java | 3 + .../editor/GutenbergKitEditorFragment.kt | 205 ++++++------------ 2 files changed, 72 insertions(+), 136 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java index 5c80ffbaca1a..1576ede00c19 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java @@ -82,6 +82,7 @@ import org.wordpress.android.ui.posts.AddCategoryFragment; import org.wordpress.android.ui.posts.EditPostActivity; import org.wordpress.android.ui.posts.GutenbergKitActivity; +import org.wordpress.android.ui.posts.editor.GutenbergKitEditorFragment; import org.wordpress.android.ui.posts.EditPostPublishSettingsFragment; import org.wordpress.android.ui.posts.EditPostSettingsFragment; import org.wordpress.android.ui.posts.HistoryListFragment; @@ -257,6 +258,8 @@ public interface AppComponent { void inject(GutenbergKitActivity object); + void inject(GutenbergKitEditorFragment object); + void inject(EditPostSettingsFragment object); void inject(PostSettingsListDialogFragment object); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index eb865c9803ab..3d365fac708a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -13,6 +13,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.webkit.URLUtil +import android.widget.FrameLayout import androidx.core.util.Pair import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope @@ -24,15 +25,12 @@ import org.wordpress.android.editor.EditorEditMediaListener import org.wordpress.android.editor.EditorFragmentAbstract import org.wordpress.android.editor.EditorImagePreviewListener import org.wordpress.android.editor.LiveTextWatcher -import org.wordpress.android.editor.gutenberg.GutenbergWebViewAuthorizationData -import org.wordpress.android.editor.savedinstance.SavedInstanceDatabase.Companion.getDatabase -import org.wordpress.android.ui.posts.EditorConfigurationBuilder -import org.wordpress.android.ui.posts.GutenbergKitSettingsBuilder +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.util.AppLog import org.wordpress.android.util.PermissionUtils import org.wordpress.android.util.ProfilingUtils import org.wordpress.android.util.helpers.MediaFile -import org.wordpress.gutenberg.EditorConfiguration +import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView import org.wordpress.gutenberg.GutenbergView.ContentChangeListener import org.wordpress.gutenberg.GutenbergView.FeaturedImageChangeListener @@ -41,10 +39,13 @@ import org.wordpress.gutenberg.GutenbergView.LogJsExceptionListener import org.wordpress.gutenberg.GutenbergView.OpenMediaLibraryListener import org.wordpress.gutenberg.GutenbergView.TitleAndContentCallback import org.wordpress.gutenberg.Media -import java.io.Serializable import java.util.concurrent.CountDownLatch +import javax.inject.Inject class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { + @Inject + lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader + private var gutenbergView: GutenbergView? = null private var isHtmlModeEnabled = false @@ -55,22 +56,19 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { private var onLogJsExceptionListener: LogJsExceptionListener? = null private var modalDialogStateListener: GutenbergView.ModalDialogStateListener? = null private var networkRequestListener: GutenbergView.NetworkRequestListener? = null - - private var editorStarted = false - private var isEditorDidMount = false private var rootView: View? = null private var isXPostsEnabled: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + (requireActivity().application as org.wordpress.android.WordPress) + .component().inject(this) ProfilingUtils.start("Visual Editor Startup") ProfilingUtils.split("EditorFragment.onCreate") if (savedInstanceState != null) { isHtmlModeEnabled = savedInstanceState.getBoolean(KEY_HTML_MODE_ENABLED) - editorStarted = savedInstanceState.getBoolean(KEY_EDITOR_STARTED) - isEditorDidMount = savedInstanceState.getBoolean(KEY_EDITOR_DID_MOUNT) mFeaturedImageId = savedInstanceState.getLong(ARG_FEATURED_IMAGE_ID) } } @@ -145,11 +143,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - if (arguments != null) { - @Suppress("UNCHECKED_CAST", "DEPRECATION") - settings = requireArguments().getSerializable(ARG_GUTENBERG_KIT_SETTINGS) as Map? - } - // Set up fragment's own listeners before initializing the editor initializeFragmentListeners() @@ -158,60 +151,74 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { rootView = inflater.inflate(R.layout.fragment_gutenberg_kit_editor, container, false) val gutenbergViewContainer = rootView!!.findViewById(R.id.gutenberg_view_container) - gutenbergView = GutenbergView.createForEditor(requireContext()).also { gutenbergView -> - gutenbergView.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + val configuration = requireNotNull( + requireArguments().getParcelable( + ARG_GUTENBERG_KIT_SETTINGS, EditorConfiguration::class.java + ) + ).toBuilder() + .setThemeStyles(false) // Temporarily disabled during editor integration + .setPlugins(false) // Temporarily disabled during editor integration + .build() + + val gutenbergView = GutenbergView( + configuration = configuration, + dependencies = this.gutenbergEditorPreloader.getDependencies(), + coroutineScope = this.lifecycleScope, + context = requireContext() + ) + + gutenbergViewContainer.addView( + gutenbergView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT ) - gutenbergViewContainer.addView(gutenbergView) + ) - gutenbergView.setOnFileChooserRequestedListener { intent: Intent?, requestCode: Int? -> - @Suppress("DEPRECATION") startActivityForResult(intent!!, requestCode!!) - null + gutenbergView.setOnFileChooserRequestedListener { intent: Intent?, requestCode: Int? -> + @Suppress("DEPRECATION") startActivityForResult(intent!!, requestCode!!) + null + } + gutenbergView.setContentChangeListener(object : ContentChangeListener { + override fun onContentChanged() { + textWatcher.postTextChanged() } - gutenbergView.setContentChangeListener(object : ContentChangeListener { - override fun onContentChanged() { - textWatcher.postTextChanged() - } - }) - historyChangeListener?.let(gutenbergView::setHistoryChangeListener) - featuredImageChangeListener?.let(gutenbergView::setFeaturedImageChangeListener) - openMediaLibraryListener?.let(gutenbergView::setOpenMediaLibraryListener) - onLogJsExceptionListener?.let(gutenbergView::setLogJsExceptionListener) - modalDialogStateListener?.let(gutenbergView::setModalDialogStateListener) - networkRequestListener?.let(gutenbergView::setNetworkRequestListener) - - // Set up autocomplete listener for user mentions and cross-post suggestions - gutenbergView.setAutocompleterTriggeredListener(object : GutenbergView.AutocompleterTriggeredListener { - override fun onAutocompleterTriggered(type: String) { - when (type) { - "at-symbol" -> mEditorFragmentListener.showUserSuggestions { result -> - result?.let { - // Appended space completes the autocomplete session - gutenbergView.appendTextAtCursor("$it ") - } + }) + + historyChangeListener?.let(gutenbergView::setHistoryChangeListener) + featuredImageChangeListener?.let(gutenbergView::setFeaturedImageChangeListener) + openMediaLibraryListener?.let(gutenbergView::setOpenMediaLibraryListener) + onLogJsExceptionListener?.let(gutenbergView::setLogJsExceptionListener) + modalDialogStateListener?.let(gutenbergView::setModalDialogStateListener) + networkRequestListener?.let(gutenbergView::setNetworkRequestListener) + + // Set up autocomplete listener for user mentions and cross-post suggestions + gutenbergView.setAutocompleterTriggeredListener(object : GutenbergView.AutocompleterTriggeredListener { + override fun onAutocompleterTriggered(type: String) { + when (type) { + "at-symbol" -> mEditorFragmentListener.showUserSuggestions { result -> + result?.let { + // Appended space completes the autocomplete session + gutenbergView.appendTextAtCursor("$it ") } - "plus-symbol" -> { - if (isXPostsEnabled) { - mEditorFragmentListener.showXpostSuggestions { result -> - result?.let { - // Appended space completes the autocomplete session - gutenbergView.appendTextAtCursor("$it ") - } + } + "plus-symbol" -> { + if (isXPostsEnabled) { + mEditorFragmentListener.showXpostSuggestions { result -> + result?.let { + // Appended space completes the autocomplete session + gutenbergView.appendTextAtCursor("$it ") } } } } } - }) - - gutenbergView.setEditorDidBecomeAvailable { - isEditorDidMount = true - mEditorFragmentListener.onEditorFragmentContentReady(ArrayList(), false) - setEditorProgressBarVisibility(false) } - } + }) - setEditorProgressBarVisibility(true) + gutenbergView.setEditorDidBecomeAvailable { + mEditorFragmentListener.onEditorFragmentContentReady(ArrayList(), false) + } return rootView } @@ -251,17 +258,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { } } - override fun onResume() { - super.onResume() - setEditorProgressBarVisibility(!isEditorDidMount) - } - - private fun setEditorProgressBarVisibility(shown: Boolean) { - if (isAdded) { - rootView?.findViewById(R.id.editor_progress).setVisibleOrGone(shown) - } - } - @Deprecated("Deprecated in Java") @Suppress("DEPRECATION") override fun onRequestPermissionsResult( @@ -299,8 +295,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(KEY_HTML_MODE_ENABLED, isHtmlModeEnabled) - outState.putBoolean(KEY_EDITOR_STARTED, editorStarted) - outState.putBoolean(KEY_EDITOR_DID_MOUNT, isEditorDidMount) outState.putLong(ARG_FEATURED_IMAGE_ID, mFeaturedImageId) } @@ -449,21 +443,9 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { historyChangeListener = null featuredImageChangeListener = null } - editorStarted = false - isEditorDidMount = false super.onDestroy() } - fun startWithEditorSettings(editorSettings: String) { - if (gutenbergView == null || editorStarted) { - return - } - - val config = buildEditorConfiguration(editorSettings) - editorStarted = true - gutenbergView?.start(config) - } - fun setXPostsEnabled(enabled: Boolean) { isXPostsEnabled = enabled } @@ -473,11 +455,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { gutenbergView?.setNetworkRequestListener(listener) } - private fun buildEditorConfiguration(editorSettings: String): EditorConfiguration { - val settingsMap = settings!! - return EditorConfigurationBuilder.build(settingsMap, editorSettings) - } - override fun onUndoPressed() { gutenbergView?.undo() } @@ -495,65 +472,21 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { private const val KEY_HTML_MODE_ENABLED = "KEY_HTML_MODE_ENABLED" private const val KEY_EDITOR_STARTED = "KEY_EDITOR_STARTED" private const val KEY_EDITOR_DID_MOUNT = "KEY_EDITOR_DID_MOUNT" - private const val ARG_IS_NEW_POST = "param_is_new_post" - private const val ARG_GUTENBERG_WEB_VIEW_AUTH_DATA = "param_gutenberg_web_view_auth_data" const val ARG_FEATURED_IMAGE_ID: String = "featured_image_id" - const val ARG_JETPACK_FEATURES_ENABLED: String = "jetpack_features_enabled" const val ARG_GUTENBERG_KIT_SETTINGS: String = "gutenberg_kit_settings" private const val CAPTURE_PHOTO_PERMISSION_REQUEST_CODE = 101 private const val CAPTURE_VIDEO_PERMISSION_REQUEST_CODE = 102 - private var settings: Map? = null - fun newInstance( - context: Context, - isNewPost: Boolean, - webViewAuthorizationData: GutenbergWebViewAuthorizationData?, - jetpackFeaturesEnabled: Boolean, - settings: Map? + configuration: EditorConfiguration ): GutenbergKitEditorFragment { val fragment = GutenbergKitEditorFragment() val args = Bundle() - args.putBoolean(ARG_IS_NEW_POST, isNewPost) - args.putBoolean(ARG_JETPACK_FEATURES_ENABLED, jetpackFeaturesEnabled) - args.putSerializable(ARG_GUTENBERG_KIT_SETTINGS, settings as Serializable?) - fragment.setArguments(args) - val db = getDatabase(context) - GutenbergKitEditorFragment.settings = settings - db?.addParcel(ARG_GUTENBERG_WEB_VIEW_AUTH_DATA, webViewAuthorizationData) + args.putParcelable(ARG_GUTENBERG_KIT_SETTINGS, configuration) + fragment.arguments = args return fragment } - /** - * Simplified factory method that uses GutenbergKitSettingsBuilder for configuration. - * This reduces the activity's responsibility for detailed fragment setup. - */ - fun newInstanceWithBuilder( - context: Context, - isNewPost: Boolean, - jetpackFeaturesEnabled: Boolean, - config: GutenbergKitSettingsBuilder.GutenbergKitConfig - ): GutenbergKitEditorFragment { - val authorizationData = GutenbergKitSettingsBuilder.buildAuthorizationData( - siteConfig = config.siteConfig, - appConfig = config.appConfig - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = config.siteConfig, - postConfig = config.postConfig, - appConfig = config.appConfig, - featureConfig = config.featureConfig - ) - - return newInstance( - context, - isNewPost, - authorizationData, - jetpackFeaturesEnabled, - settings - ) - } } } From 20bc90225d020462c8434c875113238384df44ca Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:01:51 -0700 Subject: [PATCH 07/42] Align usage with GBKit changes --- .../android/ui/posts/editor/GutenbergKitEditorFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 3d365fac708a..60933cb908ff 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -439,7 +439,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { override fun onDestroy() { gutenbergView?.let { gutenbergView -> - gutenbergView.destroy() historyChangeListener = null featuredImageChangeListener = null } From 5ed583499e46efc5044a650905952bb500924b77 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:36:36 -0700 Subject: [PATCH 08/42] Add Theme / Editor Settings repos --- .../repositories/EditorSettingsRepository.kt | 62 +++++++++++++++++++ .../android/repositories/ThemeRepository.kt | 43 +++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt new file mode 100644 index 000000000000..de2e65bd0c32 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.fluxc.persistence.EditorSettingsSqlUtils +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import rs.wordpress.api.kotlin.WpRequestResult +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class EditorSettingsRepository @Inject constructor( + private val wpApiClientProvider: WpApiClientProvider, + private val appPrefsWrapper: AppPrefsWrapper, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher +) { + private val editorSettingsSqlUtils = EditorSettingsSqlUtils() + + /** + * Returns whether the site is known to support the + * `wp-block-editor/v1/settings` endpoint, based on + * cached editor settings or a previously persisted + * result from [fetchSupportsEditorSettingsForSite]. + */ + fun getSupportsEditorSettingsForSite(site: SiteModel): Boolean { + val hasExistingEditorSettings = editorSettingsSqlUtils.getEditorSettingsForSite(site) != null + return hasExistingEditorSettings || appPrefsWrapper.getSiteSupportsEditorSettings(site.siteId) + } + + /** + * Queries the site's REST API root index to check + * whether the `wp-block-editor/v1/settings` route + * is available. The result is persisted so that + * [getSupportsEditorSettingsForSite] can return it + * synchronously on future calls. + * + * Returns `false` if the API root request fails + * (e.g. network error, missing application password). + */ + suspend fun fetchSupportsEditorSettingsForSite(site: SiteModel): Boolean = + withContext(ioDispatcher) { + val client = wpApiClientProvider.getWpApiClient(site) + val response = client.request { it.apiRoot().get() } + + val supports = when (response) { + is WpRequestResult.Success -> + response.response.data + .hasRoute("wp-block-editor/v1/settings") + else -> false + } + + appPrefsWrapper.setSiteSupportsEditorSettings( + site.siteId, supports + ) + + supports + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt new file mode 100644 index 000000000000..c13feabeaf98 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.modules.IO_THREAD +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.ThemeListParams +import uniffi.wp_api.ThemeStatus +import uniffi.wp_api.ThemeWithEditContext +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ThemeRepository @Inject constructor( + private val wpApiClientProvider: WpApiClientProvider, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher +) { + + + + /** + * Fetches the current active theme for the given site + * via the `wp/v2/themes?status=active` endpoint. + */ + suspend fun fetchCurrentTheme(site: SiteModel): ThemeWithEditContext? = + withContext(ioDispatcher) { + val client = wpApiClientProvider.getWpApiClient(site) + val response = client.request { + it.themes().listWithEditContext(ThemeListParams( + status = ThemeStatus.Active + )) + } + + when (response) { + is WpRequestResult.Success -> + response.response.data.first() + else -> null + } + } +} From f9938de8bd3af6da5ee0d3847ef758756e8314c2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:06:43 -0700 Subject: [PATCH 09/42] Simplify preloading for debugging --- .../repositories/EditorSettingsRepository.kt | 9 ++++---- .../android/ui/posts/GutenbergKitActivity.kt | 21 ++++--------------- .../editor/GutenbergKitEditorFragment.kt | 13 ++++++++++-- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index de2e65bd0c32..761d4b2adcae 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -28,7 +28,8 @@ class EditorSettingsRepository @Inject constructor( */ fun getSupportsEditorSettingsForSite(site: SiteModel): Boolean { val hasExistingEditorSettings = editorSettingsSqlUtils.getEditorSettingsForSite(site) != null - return hasExistingEditorSettings || appPrefsWrapper.getSiteSupportsEditorSettings(site.siteId) + return false +// return hasExistingEditorSettings || appPrefsWrapper.getSiteSupportsEditorSettings(site.siteId) } /** @@ -53,9 +54,9 @@ class EditorSettingsRepository @Inject constructor( else -> false } - appPrefsWrapper.setSiteSupportsEditorSettings( - site.siteId, supports - ) +// appPrefsWrapper.setSiteSupportsEditorSettings( +// site.siteId, supports +// ) supports } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index ac2306c1ef69..8bfb3ab92765 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -89,7 +89,6 @@ import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged import org.wordpress.android.fluxc.store.EditorSettingsStore.FetchEditorSettingsPayload -import org.wordpress.android.fluxc.store.EditorSettingsStore.OnEditorSettingsChanged import org.wordpress.android.fluxc.store.EditorThemeStore import org.wordpress.android.fluxc.store.MediaStore import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType @@ -315,6 +314,8 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var editorThemeStore: EditorThemeStore + + @Inject lateinit var imageLoader: FluxCImageLoader @Inject lateinit var shortcutUtils: ShortcutUtils @@ -2241,22 +2242,8 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene site, privateAtomicCookie ) ) - .setPlugins( - GutenbergKitSettingsBuilder - .shouldUsePlugins( - isFeatureEnabled = - gutenbergKitPluginsFeature - .isEnabled(), - isWPComSite = site.isWPCom, - isJetpackConnected = - site.isJetpackConnected, - applicationPassword = - site.apiRestPasswordPlain - ) - ) - .setThemeStyles( - siteSettings?.useThemeStyles ?: true - ) + .setPlugins(false) + .setThemeStyles(false) .setEnableNetworkLogging( AppPrefs.isTrackNetworkRequestsEnabled() ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 60933cb908ff..21a7994bbadf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -156,13 +156,13 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { ARG_GUTENBERG_KIT_SETTINGS, EditorConfiguration::class.java ) ).toBuilder() - .setThemeStyles(false) // Temporarily disabled during editor integration + .setCookies(mapOf()) .setPlugins(false) // Temporarily disabled during editor integration .build() val gutenbergView = GutenbergView( configuration = configuration, - dependencies = this.gutenbergEditorPreloader.getDependencies(), + dependencies = null, coroutineScope = this.lifecycleScope, context = requireContext() ) @@ -216,6 +216,15 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { } }) + gutenbergView.setLatestContentProvider( + object : GutenbergView.LatestContentProvider { + override fun getLatestContent(): + GutenbergView.LatestContent? { + return null + } + } + ) + gutenbergView.setEditorDidBecomeAvailable { mEditorFragmentListener.onEditorFragmentContentReady(ArrayList(), false) } From c6ff4c9ff7c651a9f74213f11fd1007db03377eb Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 10 Feb 2026 14:55:29 -0500 Subject: [PATCH 10/42] fix: Pass null post ID for local drafts in GutenbergKit (#22580) Local draft posts have remotePostId defaulting to 0, which was passed directly to GutenbergKit's setPostId(). GBK expects null for unsaved posts. Now check isLocalDraft and pass null instead. --- .../ui/posts/GutenbergKitSettingsBuilder.kt | 5 +++- .../posts/GutenbergKitSettingsBuilderTest.kt | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 9d945f2080ca..fad624edbbcb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -63,7 +63,10 @@ object GutenbergKitSettingsBuilder { ).apply { setTitle(post?.title ?: "") setContent(post?.content ?: "") - setPostId(post?.remotePostId?.toInt()) + setPostId( + if (post?.isLocalDraft == true) null + else post?.remotePostId?.toInt() + ) setPostStatus(post?.status ?: "draft") setAuthHeader(authHeader) setSiteApiNamespace(siteApiNamespace) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index 2613bc5e6ee6..8841ad83f270 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -4,6 +4,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel @RunWith(MockitoJUnitRunner::class) @@ -431,6 +432,29 @@ class GutenbergKitSettingsBuilderTest { assertThat(config.postId).isNull() } + @Test + fun `local draft post results in null post ID`() { + val site = SiteModel().apply { + url = "https://example.wordpress.com" + siteId = 123L + setIsWPCom(true) + setIsJetpackConnected(false) + origin = SiteModel.ORIGIN_WPCOM_REST + } + val post = PostModel().apply { + setIsLocalDraft(true) + setRemotePostId(99L) + } + val config = GutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) + + assertThat(config.postId).isNull() + } + @Test fun `null post status defaults to draft`() { val config = buildWPComConfig() From 883eb043be517f85a277d295e3600a41c1fdb110 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:32:11 -0700 Subject: [PATCH 11/42] Remove unused code from ThemeRepository, EditorDependencyStore, and GutenbergKitEditorFragment Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/android/repositories/ThemeRepository.kt | 3 --- .../android/ui/posts/editor/GutenbergKitEditorFragment.kt | 8 -------- .../org/wordpress/android/util/EditorDependencyStore.kt | 3 --- 3 files changed, 14 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt index c13feabeaf98..21bbd0fa803e 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt @@ -18,9 +18,6 @@ class ThemeRepository @Inject constructor( private val wpApiClientProvider: WpApiClientProvider, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher ) { - - - /** * Fetches the current active theme for the given site * via the `wp/v2/themes?status=active` endpoint. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 21a7994bbadf..ecdec0936517 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -297,11 +297,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { } } - // View extension functions - private fun View?.setVisibleOrGone(visible: Boolean) { - this?.visibility = if (visible) View.VISIBLE else View.GONE - } - override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(KEY_HTML_MODE_ENABLED, isHtmlModeEnabled) outState.putLong(ARG_FEATURED_IMAGE_ID, mFeaturedImageId) @@ -478,8 +473,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { companion object { private const val GUTENBERG_EDITOR_NAME = "gutenberg" private const val KEY_HTML_MODE_ENABLED = "KEY_HTML_MODE_ENABLED" - private const val KEY_EDITOR_STARTED = "KEY_EDITOR_STARTED" - private const val KEY_EDITOR_DID_MOUNT = "KEY_EDITOR_DID_MOUNT" const val ARG_FEATURED_IMAGE_ID: String = "featured_image_id" const val ARG_GUTENBERG_KIT_SETTINGS: String = "gutenberg_kit_settings" @@ -495,6 +488,5 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { fragment.arguments = args return fragment } - } } diff --git a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt index 37498c617cdf..eded9fc1156c 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt @@ -2,11 +2,8 @@ package org.wordpress.android.util import android.content.Context import kotlinx.coroutines.CoroutineScope -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.store.ThemeCoroutineStore import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies -import org.wordpress.gutenberg.model.EditorDependenciesSerializer import org.wordpress.gutenberg.services.EditorService class EditorDependencyStore(val context: Context, val coroutineScope: CoroutineScope) { From 470d1c5859d4b947ad94cf96e1f790d31fa0d391 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:38:03 -0700 Subject: [PATCH 12/42] Add siteSupportsEditorSettings preference to AppPrefs and AppPrefsWrapper Per-site boolean preference that caches whether a site supports the wp-block-editor/v1/settings endpoint, used by EditorSettingsRepository. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/android/ui/prefs/AppPrefs.java | 14 ++++++++++++++ .../wordpress/android/ui/prefs/AppPrefsWrapper.kt | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 0b4d6fc34cbb..bed8eaa8a651 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -200,6 +200,7 @@ public enum DeletablePrefKey implements PrefKey { PENDING_LOGIN_FLOW, // Whether a share flow is pending (for self-hosted login) IS_SHARE_FLOW_PENDING, + SITE_SUPPORTS_EDITOR_SETTINGS, } /** @@ -1866,4 +1867,17 @@ public static boolean consumeShareFlowPending() { public static void setShareFlowPending(boolean pending) { setBoolean(DeletablePrefKey.IS_SHARE_FLOW_PENDING, pending); } + + public static boolean getSiteSupportsEditorSettings(long siteId) { + return prefs().getBoolean(getSiteSupportsEditorSettingsKey(siteId), false); + } + + public static void setSiteSupportsEditorSettings(long siteId, boolean supports) { + prefs().edit().putBoolean(getSiteSupportsEditorSettingsKey(siteId), supports).apply(); + } + + @NonNull + private static String getSiteSupportsEditorSettingsKey(long siteId) { + return DeletablePrefKey.SITE_SUPPORTS_EDITOR_SETTINGS.name() + siteId; + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 2124b5e7d502..947edd70076b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -515,6 +515,12 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra get() = AppPrefs.getSupportEmail() set(value) = AppPrefs.setSupportEmail(value) + fun getSiteSupportsEditorSettings(siteId: Long): Boolean = + AppPrefs.getSiteSupportsEditorSettings(siteId) + + fun setSiteSupportsEditorSettings(siteId: Long, supports: Boolean) = + AppPrefs.setSiteSupportsEditorSettings(siteId, supports) + var isTrackNetworkRequestsEnabled: Boolean get() = AppPrefs.isTrackNetworkRequestsEnabled() set(value) = AppPrefs.setTrackNetworkRequestsEnabled(value) From fc6e23e6ea01e5d747b76366bf843e8adcd2a83d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:38:16 -0700 Subject: [PATCH 13/42] Fix EditorSettingsRepository and add detailed logging Enable the actual editor settings support check (was returning false) and persist results to AppPrefs. Add logging at every key point for debugging: cache hits, API fetch results, and persistence. Co-Authored-By: Claude Opus 4.6 --- .../repositories/EditorSettingsRepository.kt | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index 761d4b2adcae..42b89b7b02c5 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -4,9 +4,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.fluxc.persistence.EditorSettingsSqlUtils import org.wordpress.android.modules.IO_THREAD -import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T import rs.wordpress.api.kotlin.WpRequestResult import javax.inject.Inject import javax.inject.Named @@ -27,9 +29,20 @@ class EditorSettingsRepository @Inject constructor( * result from [fetchSupportsEditorSettingsForSite]. */ fun getSupportsEditorSettingsForSite(site: SiteModel): Boolean { - val hasExistingEditorSettings = editorSettingsSqlUtils.getEditorSettingsForSite(site) != null - return false -// return hasExistingEditorSettings || appPrefsWrapper.getSiteSupportsEditorSettings(site.siteId) + val hasExistingEditorSettings = + editorSettingsSqlUtils.getEditorSettingsForSite(site) != null + val cachedPref = + appPrefsWrapper.getSiteSupportsEditorSettings(site.siteId) + val supports = hasExistingEditorSettings || cachedPref + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: getSupportsEditorSettings" + + " siteId=${site.siteId}" + + " hasExistingEditorSettings=$hasExistingEditorSettings" + + " cachedPref=$cachedPref" + + " result=$supports" + ) + return supports } /** @@ -42,22 +55,51 @@ class EditorSettingsRepository @Inject constructor( * Returns `false` if the API root request fails * (e.g. network error, missing application password). */ - suspend fun fetchSupportsEditorSettingsForSite(site: SiteModel): Boolean = - withContext(ioDispatcher) { - val client = wpApiClientProvider.getWpApiClient(site) - val response = client.request { it.apiRoot().get() } - - val supports = when (response) { - is WpRequestResult.Success -> - response.response.data - .hasRoute("wp-block-editor/v1/settings") - else -> false - } + suspend fun fetchSupportsEditorSettingsForSite( + site: SiteModel + ): Boolean = withContext(ioDispatcher) { + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: fetching editor settings" + + " support for site=${site.name}" + ) -// appPrefsWrapper.setSiteSupportsEditorSettings( -// site.siteId, supports -// ) + val client = wpApiClientProvider.getWpApiClient(site) + val response = client.request { it.apiRoot().get() } - supports + val supports = when (response) { + is WpRequestResult.Success -> { + val hasRoute = response.response.data + .hasRoute("/wp-block-editor/v1/settings") + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: API root fetched" + + " for siteId=${site.name}" + + " hasEditorSettingsRoute=$hasRoute" + ) + hasRoute + } + else -> { + AppLog.w( + T.EDITOR, + "EditorSettingsRepository: API root request" + + " failed for site=${site.name}" + + " response=$response" + ) + false + } } + + appPrefsWrapper.setSiteSupportsEditorSettings( + site.siteId, supports + ) + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: persisted" + + " siteSupportsEditorSettings=$supports" + + " for site=${site.name}" + ) + + supports + } } From 754cca650ccba83826fd2bcd4dc3e520b0964e88 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:38:42 -0700 Subject: [PATCH 14/42] Convert GutenbergKitSettingsBuilder from object to injectable class Inject EditorSettingsRepository so the builder can query editor settings support when configuring theme styles. Extract buildCachedHosts and buildEditorAssetsEndpoint into named methods. Update GutenbergKitActivity to use the injected instance and remove redundant builder overrides. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/posts/GutenbergKitActivity.kt | 6 +- .../ui/posts/GutenbergKitSettingsBuilder.kt | 63 ++++--- .../posts/GutenbergKitSettingsBuilderTest.kt | 155 +++++++++++++++--- 3 files changed, 175 insertions(+), 49 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 8bfb3ab92765..7b48ab09613a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -232,7 +232,6 @@ import java.util.regex.Pattern import javax.inject.Inject import kotlin.math.max import androidx.core.view.isNotEmpty -import org.wordpress.android.util.EditorDependencyStore // ViewPager configuration constants private const val VIEW_PAGER_PAGE_CONTENT = 0 @@ -394,6 +393,7 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var editorBloggingPromptsViewModel: EditorBloggingPromptsViewModel @Inject lateinit var editorJetpackSocialViewModel: EditorJetpackSocialViewModel @Inject lateinit var gutenbergKitNetworkLogger: GutenbergKitNetworkLogger + @Inject lateinit var gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder private lateinit var editPostNavigationViewModel: EditPostNavigationViewModel private lateinit var editPostSettingsViewModel: EditPostSettingsViewModel private lateinit var prepublishingViewModel: PrepublishingViewModel @@ -2223,7 +2223,7 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene site: SiteModel, post: PostImmutableModel? ): EditorConfiguration { - val base = GutenbergKitSettingsBuilder + val base = gutenbergKitSettingsBuilder .buildPostConfiguration( site = site, post = post, @@ -2242,8 +2242,6 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene site, privateAtomicCookie ) ) - .setPlugins(false) - .setThemeStyles(false) .setEnableNetworkLogging( AppPrefs.isTrackNetworkRequestsEnabled() ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index fad624edbbcb..0540a51af731 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -3,14 +3,17 @@ package org.wordpress.android.ui.posts import android.util.Base64 import org.wordpress.android.fluxc.model.PostImmutableModel import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog import org.wordpress.gutenberg.model.EditorConfiguration import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton -object GutenbergKitSettingsBuilder { - private const val AUTH_BEARER_PREFIX = "Bearer " - private const val AUTH_BASIC_PREFIX = "Basic " - private const val WPCOM_API_ROOT = "https://public-api.wordpress.com/" +@Singleton +class GutenbergKitSettingsBuilder @Inject constructor( + private val editorSettingsRepository: EditorSettingsRepository +) { fun buildPostConfiguration( site: SiteModel, @@ -40,21 +43,9 @@ object GutenbergKitSettingsBuilder { val postType = if (post?.isPage == true) "page" else "post" - val siteHost = extractHost(site.url) - val cachedHosts = if (!siteHost.isNullOrEmpty()) { - setOf("s0.wp.com", siteHost) - } else { - setOf("s0.wp.com") - } - - val firstNamespace = siteApiNamespace.firstOrNull() ?: "" - val editorAssetsEndpoint = if ( - firstNamespace.isNotEmpty() && siteApiRoot.isNotEmpty() - ) { - "${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets" - } else { - null - } + val cachedHosts = buildCachedHosts(site.url) + val editorAssetsEndpoint = + buildEditorAssetsEndpoint(siteApiRoot, siteApiNamespace) return EditorConfiguration.builder( siteURL = site.url, @@ -76,7 +67,7 @@ object GutenbergKitSettingsBuilder { "/wpcom/v2/following/mine" ) ) - setThemeStyles(false) + setThemeStyles(editorSettingsRepository.getSupportsEditorSettingsForSite(site)) setPlugins(false) setLocale("en") setCookies(emptyMap()) @@ -147,8 +138,30 @@ object GutenbergKitSettingsBuilder { siteUrl: String ): Array { if (!shouldUseWPComRestApi) return arrayOf() - val host = extractHost(siteUrl) ?: return arrayOf("sites/$siteId/") - return arrayOf("sites/$siteId/", "sites/$host/") + val host = extractHost(siteUrl) + return if (host != null) { + arrayOf("sites/$siteId/", "sites/$host/") + } else { + arrayOf("sites/$siteId/") + } + } + + private fun buildCachedHosts(siteUrl: String): Set { + val siteHost = extractHost(siteUrl) + return if (!siteHost.isNullOrEmpty()) { + setOf("s0.wp.com", siteHost) + } else { + setOf("s0.wp.com") + } + } + + private fun buildEditorAssetsEndpoint( + siteApiRoot: String, + siteApiNamespace: Array + ): String? { + val firstNamespace = siteApiNamespace.firstOrNull() ?: "" + if (firstNamespace.isEmpty() || siteApiRoot.isEmpty()) return null + return "${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets" } internal fun extractHost(url: String): String? { @@ -158,4 +171,10 @@ object GutenbergKitSettingsBuilder { null } } + + companion object { + private const val AUTH_BEARER_PREFIX = "Bearer " + private const val AUTH_BASIC_PREFIX = "Basic " + private const val WPCOM_API_ROOT = "https://public-api.wordpress.com/" + } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index 8841ad83f270..de92507e6bc2 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -3,17 +3,25 @@ package org.wordpress.android.ui.posts import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.repositories.EditorSettingsRepository @RunWith(MockitoJUnitRunner::class) class GutenbergKitSettingsBuilderTest { + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + + private val builder by lazy { + GutenbergKitSettingsBuilder(editorSettingsRepository) + } // ===== Auth Header Tests ===== @Test fun `WPCom site returns Bearer token header`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = true, accessToken = "my_token", username = null, @@ -25,7 +33,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `WPCom site with null token returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = true, accessToken = null, username = null, @@ -37,7 +45,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `WPCom site with empty token returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = true, accessToken = "", username = null, @@ -49,7 +57,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `self-hosted site returns Basic auth header`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = "testuser", @@ -62,7 +70,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `Basic auth with null username returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = null, @@ -74,7 +82,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `Basic auth with empty username returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = "", @@ -86,7 +94,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `Basic auth with null password returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = "username", @@ -98,7 +106,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `Basic auth with empty password returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = "username", @@ -110,7 +118,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `Basic auth with both empty returns null`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = "", @@ -122,7 +130,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `special characters in Basic auth are encoded`() { - val header = GutenbergKitSettingsBuilder.buildAuthHeader( + val header = builder.buildAuthHeader( shouldUseWPComRestApi = false, accessToken = null, username = "user@example.com", @@ -144,7 +152,7 @@ class GutenbergKitSettingsBuilderTest { ) testCases.forEach { (isWPCom, isJetpack, password) -> - val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + val result = builder.shouldUsePlugins( isFeatureEnabled = false, isWPComSite = isWPCom, isJetpackConnected = isJetpack, @@ -162,7 +170,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `plugins enabled for WPCom sites when feature is on`() { - val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + val result = builder.shouldUsePlugins( isFeatureEnabled = true, isWPComSite = true, isJetpackConnected = false, @@ -174,7 +182,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `plugins enabled for Jetpack with app password when feature is on`() { - val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + val result = builder.shouldUsePlugins( isFeatureEnabled = true, isWPComSite = false, isJetpackConnected = true, @@ -187,7 +195,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `plugins disabled for Jetpack without app password`() { listOf(null, "").forEach { password -> - val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + val result = builder.shouldUsePlugins( isFeatureEnabled = true, isWPComSite = false, isJetpackConnected = true, @@ -204,7 +212,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `plugins disabled for self-hosted without Jetpack`() { - val result = GutenbergKitSettingsBuilder.shouldUsePlugins( + val result = builder.shouldUsePlugins( isFeatureEnabled = true, isWPComSite = false, isJetpackConnected = false, @@ -218,7 +226,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `namespace is empty for non-WPCom sites`() { - val result = GutenbergKitSettingsBuilder.buildSiteApiNamespace( + val result = builder.buildSiteApiNamespace( shouldUseWPComRestApi = false, siteId = 123L, siteUrl = "https://example.com" @@ -229,7 +237,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `namespace includes site ID and host for WPCom sites`() { - val result = GutenbergKitSettingsBuilder.buildSiteApiNamespace( + val result = builder.buildSiteApiNamespace( shouldUseWPComRestApi = true, siteId = 456L, siteUrl = "https://example.wordpress.com" @@ -243,7 +251,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `namespace includes only site ID when host extraction fails`() { - val result = GutenbergKitSettingsBuilder.buildSiteApiNamespace( + val result = builder.buildSiteApiNamespace( shouldUseWPComRestApi = true, siteId = 789L, siteUrl = "not-a-valid-url" @@ -257,7 +265,7 @@ class GutenbergKitSettingsBuilderTest { @Test fun `extractHost returns host from valid URL`() { assertThat( - GutenbergKitSettingsBuilder.extractHost( + builder.extractHost( "https://example.wordpress.com" ) ).isEqualTo("example.wordpress.com") @@ -266,14 +274,14 @@ class GutenbergKitSettingsBuilderTest { @Test fun `extractHost returns null for invalid URL`() { assertThat( - GutenbergKitSettingsBuilder.extractHost("not-a-url") + builder.extractHost("not-a-url") ).isNull() } @Test fun `extractHost strips path from URL`() { assertThat( - GutenbergKitSettingsBuilder.extractHost( + builder.extractHost( "https://example.com/blog/page" ) ).isEqualTo("example.com") @@ -515,6 +523,107 @@ class GutenbergKitSettingsBuilderTest { .isEqualTo("https://example.wordpress.com") } + // ===== buildCachedHosts (via buildPostConfiguration) ===== + + @Test + fun `cached hosts includes site host for subdirectory URL`() { + val config = buildWPComConfig( + siteUrl = "https://example.com/blog" + ) + + assertThat(config.cachedAssetHosts).containsExactlyInAnyOrder( + "s0.wp.com", + "example.com" + ) + } + + @Test + fun `cached hosts only includes s0 wp com for empty URL`() { + val config = buildWPComConfig(siteUrl = "") + + assertThat(config.cachedAssetHosts) + .containsExactly("s0.wp.com") + } + + // ===== buildEditorAssetsEndpoint (via buildPostConfiguration) ===== + + @Test + fun `editor assets endpoint uses first namespace`() { + val config = buildWPComConfig(siteId = 55L) + + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://public-api.wordpress.com/" + + "wpcom/v2/sites/55/editor-assets" + ) + } + + @Test + fun `editor assets endpoint is null for non-WPCom site`() { + val config = buildSelfHostedConfig() + + assertThat(config.editorAssetsEndpoint).isNull() + } + + // ===== buildSiteApiNamespace edge cases ===== + + @Test + fun `namespace with empty URL returns only site ID`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 321L, + siteUrl = "" + ) + + assertThat(result).containsExactly("sites/321/") + } + + // ===== Post type and ID edge cases ===== + + @Test + fun `page post results in page post type`() { + val site = SiteModel().apply { + url = "https://example.wordpress.com" + siteId = 123L + setIsWPCom(true) + setIsJetpackConnected(false) + origin = SiteModel.ORIGIN_WPCOM_REST + } + val post = PostModel().apply { + setIsPage(true) + } + val config = GutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) + + assertThat(config.postType).isEqualTo("page") + } + + @Test + fun `published post sets remote post ID`() { + val site = SiteModel().apply { + url = "https://example.wordpress.com" + siteId = 123L + setIsWPCom(true) + setIsJetpackConnected(false) + origin = SiteModel.ORIGIN_WPCOM_REST + } + val post = PostModel().apply { + setIsLocalDraft(false) + setRemotePostId(42L) + } + val config = GutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) + + assertThat(config.postId).isEqualTo(42) + } + // ===== Helpers ===== private fun buildWPComConfig( @@ -529,7 +638,7 @@ class GutenbergKitSettingsBuilderTest { setIsJetpackConnected(false) origin = SiteModel.ORIGIN_WPCOM_REST } - return GutenbergKitSettingsBuilder.buildPostConfiguration( + return builder.buildPostConfiguration( site = site, accessToken = accessToken ) @@ -550,7 +659,7 @@ class GutenbergKitSettingsBuilderTest { apiRestPasswordPlain = applicationPassword apiRestUsernamePlain = apiRestUsername } - return GutenbergKitSettingsBuilder.buildPostConfiguration( + return builder.buildPostConfiguration( site = site, accessToken = null ) From bab37b1a0e863d83763e4b20ad44f9b7271e31b7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:38:58 -0700 Subject: [PATCH 15/42] Fetch editor settings during preloading and use preloaded dependencies GutenbergEditorPreloader now fetches editor settings support before building the editor configuration, ensuring the config reflects the latest server state. GutenbergKitEditorFragment uses the preloaded dependencies instead of passing null. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergEditorPreloader.kt | 18 ++++++++++++------ .../posts/editor/GutenbergKitEditorFragment.kt | 3 +-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 07b64d425446..448887741cdf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.launch import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog import org.wordpress.android.util.EditorDependencyStore import org.wordpress.android.util.SiteUtils @@ -22,6 +23,8 @@ class GutenbergEditorPreloader @Inject constructor( @ApplicationContext private val appContext: Context, private val accountStore: AccountStore, private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, + private val gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder, + private val editorSettingsRepository: EditorSettingsRepository, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) { private var lastPreloadedSiteId: Long = -1 @@ -29,15 +32,18 @@ class GutenbergEditorPreloader @Inject constructor( private var cachedDependencies: EditorDependencies? = null fun preloadIfNeeded(site: SiteModel, scope: CoroutineScope) { - if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled()) return - if (!SiteUtils.isBlockEditorDefaultForNewPost(site)) return -// if (site.siteId == lastPreloadedSiteId) return - if (preloadJob?.isActive == true) return + if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled() || + !SiteUtils.isBlockEditorDefaultForNewPost(site) || + site.siteId == lastPreloadedSiteId || + preloadJob?.isActive == true + ) return lastPreloadedSiteId = site.siteId preloadJob = scope.launch(bgDispatcher) { try { - val config = GutenbergKitSettingsBuilder + editorSettingsRepository + .fetchSupportsEditorSettingsForSite(site) + val config = gutenbergKitSettingsBuilder .buildPostConfiguration( site = site, accessToken = accountStore.accessToken @@ -48,7 +54,7 @@ class GutenbergEditorPreloader @Inject constructor( AppLog.T.EDITOR, "Editor dependencies preloaded for site ${site.siteId}" ) - } catch (e: Exception) { + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { AppLog.e( AppLog.T.EDITOR, "Failed to preload editor dependencies", diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index ecdec0936517..c5a9ce41469e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -156,13 +156,12 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { ARG_GUTENBERG_KIT_SETTINGS, EditorConfiguration::class.java ) ).toBuilder() - .setCookies(mapOf()) .setPlugins(false) // Temporarily disabled during editor integration .build() val gutenbergView = GutenbergView( configuration = configuration, - dependencies = null, + dependencies = gutenbergEditorPreloader.getDependencies(), coroutineScope = this.lifecycleScope, context = requireContext() ) From 5a07d20f6d7b4deb89847661691ee8659ba18b1e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:11:39 -0700 Subject: [PATCH 16/42] Add editor assets support check and use SiteModel for pref APIs Check for both wp-block-editor/v1/settings and wpcom/v2/editor-assets routes in a single API root request via fetchEditorCapabilitiesForSite. Add getSupportsEditorAssetsForSite for reading the cached result. Change all new pref methods to take SiteModel instead of a raw ID to prevent misuse of siteId vs local ID. The prefs key internally using the local database ID which is always unique across site types. Co-Authored-By: Claude Opus 4.6 --- .../repositories/EditorSettingsRepository.kt | 84 ++++++++++++------- .../ui/posts/GutenbergEditorPreloader.kt | 4 +- .../ui/posts/GutenbergKitSettingsBuilder.kt | 2 +- .../editor/GutenbergKitEditorFragment.kt | 4 +- .../wordpress/android/ui/prefs/AppPrefs.java | 27 ++++-- .../android/ui/prefs/AppPrefsWrapper.kt | 15 +++- 6 files changed, 89 insertions(+), 47 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index 42b89b7b02c5..4d78a88da818 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -26,18 +26,18 @@ class EditorSettingsRepository @Inject constructor( * Returns whether the site is known to support the * `wp-block-editor/v1/settings` endpoint, based on * cached editor settings or a previously persisted - * result from [fetchSupportsEditorSettingsForSite]. + * result from [fetchEditorCapabilitiesForSite]. */ fun getSupportsEditorSettingsForSite(site: SiteModel): Boolean { val hasExistingEditorSettings = editorSettingsSqlUtils.getEditorSettingsForSite(site) != null val cachedPref = - appPrefsWrapper.getSiteSupportsEditorSettings(site.siteId) + appPrefsWrapper.getSiteSupportsEditorSettings(site) val supports = hasExistingEditorSettings || cachedPref AppLog.d( T.EDITOR, "EditorSettingsRepository: getSupportsEditorSettings" + - " siteId=${site.siteId}" + + " site=${site.name}" + " hasExistingEditorSettings=$hasExistingEditorSettings" + " cachedPref=$cachedPref" + " result=$supports" @@ -45,39 +45,67 @@ class EditorSettingsRepository @Inject constructor( return supports } + /** + * Returns whether the site is known to support the + * `wpcom/v2/editor-assets` endpoint, based on a + * previously persisted result from + * [fetchEditorCapabilitiesForSite]. + */ + fun getSupportsEditorAssetsForSite(site: SiteModel): Boolean { + val supports = + appPrefsWrapper.getSiteSupportsEditorAssets(site) + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: getSupportsEditorAssets" + + " site=${site.name}" + + " result=$supports" + ) + return supports + } + /** * Queries the site's REST API root index to check - * whether the `wp-block-editor/v1/settings` route - * is available. The result is persisted so that - * [getSupportsEditorSettingsForSite] can return it + * whether the `wp-block-editor/v1/settings` and + * `wpcom/v2/editor-assets` routes are available. + * Both results are persisted so that + * [getSupportsEditorSettingsForSite] and + * [getSupportsEditorAssetsForSite] can return them * synchronously on future calls. - * - * Returns `false` if the API root request fails - * (e.g. network error, missing application password). */ - suspend fun fetchSupportsEditorSettingsForSite( + suspend fun fetchEditorCapabilitiesForSite( site: SiteModel - ): Boolean = withContext(ioDispatcher) { + ) = withContext(ioDispatcher) { AppLog.d( T.EDITOR, - "EditorSettingsRepository: fetching editor settings" + - " support for site=${site.name}" + "EditorSettingsRepository: fetching editor" + + " capabilities for site=${site.name}" ) val client = wpApiClientProvider.getWpApiClient(site) val response = client.request { it.apiRoot().get() } - val supports = when (response) { + when (response) { is WpRequestResult.Success -> { - val hasRoute = response.response.data + val data = response.response.data + val supportsSettings = data .hasRoute("/wp-block-editor/v1/settings") + val supportsAssets = data + .hasRoute("/wpcom/v2/editor-assets") + AppLog.d( T.EDITOR, "EditorSettingsRepository: API root fetched" + - " for siteId=${site.name}" + - " hasEditorSettingsRoute=$hasRoute" + " for site=${site.name}" + + " supportsEditorSettings=$supportsSettings" + + " supportsEditorAssets=$supportsAssets" + ) + + appPrefsWrapper.setSiteSupportsEditorSettings( + site, supportsSettings + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, supportsAssets ) - hasRoute } else -> { AppLog.w( @@ -86,20 +114,14 @@ class EditorSettingsRepository @Inject constructor( " failed for site=${site.name}" + " response=$response" ) - false + + appPrefsWrapper.setSiteSupportsEditorSettings( + site, false + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, false + ) } } - - appPrefsWrapper.setSiteSupportsEditorSettings( - site.siteId, supports - ) - AppLog.d( - T.EDITOR, - "EditorSettingsRepository: persisted" + - " siteSupportsEditorSettings=$supports" + - " for site=${site.name}" - ) - - supports } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 448887741cdf..9ede577b4701 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -42,7 +42,7 @@ class GutenbergEditorPreloader @Inject constructor( preloadJob = scope.launch(bgDispatcher) { try { editorSettingsRepository - .fetchSupportsEditorSettingsForSite(site) + .fetchEditorCapabilitiesForSite(site) val config = gutenbergKitSettingsBuilder .buildPostConfiguration( site = site, @@ -52,7 +52,7 @@ class GutenbergEditorPreloader @Inject constructor( cachedDependencies = store.fetch(config) AppLog.d( AppLog.T.EDITOR, - "Editor dependencies preloaded for site ${site.siteId}" + "Editor dependencies preloaded for site ${site.name}" ) } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { AppLog.e( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 0540a51af731..d7881bb63e68 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -68,7 +68,7 @@ class GutenbergKitSettingsBuilder @Inject constructor( ) ) setThemeStyles(editorSettingsRepository.getSupportsEditorSettingsForSite(site)) - setPlugins(false) + setPlugins(editorSettingsRepository.getSupportsEditorAssetsForSite(site)) setLocale("en") setCookies(emptyMap()) setEnableAssetCaching(true) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index c5a9ce41469e..647db3d51ea5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -155,9 +155,7 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { requireArguments().getParcelable( ARG_GUTENBERG_KIT_SETTINGS, EditorConfiguration::class.java ) - ).toBuilder() - .setPlugins(false) // Temporarily disabled during editor integration - .build() + ) val gutenbergView = GutenbergView( configuration = configuration, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index bed8eaa8a651..6343ab902f6d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -16,6 +16,7 @@ import org.wordpress.android.analytics.AnalyticsTracker; import org.wordpress.android.analytics.AnalyticsTracker.Stat; import org.wordpress.android.fluxc.model.JetpackCapability; +import org.wordpress.android.fluxc.model.SiteModel; import org.wordpress.android.models.ReaderTag; import org.wordpress.android.models.ReaderTagType; import org.wordpress.android.ui.ActivityId; @@ -201,6 +202,7 @@ public enum DeletablePrefKey implements PrefKey { // Whether a share flow is pending (for self-hosted login) IS_SHARE_FLOW_PENDING, SITE_SUPPORTS_EDITOR_SETTINGS, + SITE_SUPPORTS_EDITOR_ASSETS, } /** @@ -1868,16 +1870,29 @@ public static void setShareFlowPending(boolean pending) { setBoolean(DeletablePrefKey.IS_SHARE_FLOW_PENDING, pending); } - public static boolean getSiteSupportsEditorSettings(long siteId) { - return prefs().getBoolean(getSiteSupportsEditorSettingsKey(siteId), false); + public static boolean getSiteSupportsEditorSettings(@NonNull SiteModel site) { + return prefs().getBoolean(getSiteSupportsEditorSettingsKey(site), false); } - public static void setSiteSupportsEditorSettings(long siteId, boolean supports) { - prefs().edit().putBoolean(getSiteSupportsEditorSettingsKey(siteId), supports).apply(); + public static void setSiteSupportsEditorSettings(@NonNull SiteModel site, boolean supports) { + prefs().edit().putBoolean(getSiteSupportsEditorSettingsKey(site), supports).apply(); } @NonNull - private static String getSiteSupportsEditorSettingsKey(long siteId) { - return DeletablePrefKey.SITE_SUPPORTS_EDITOR_SETTINGS.name() + siteId; + private static String getSiteSupportsEditorSettingsKey(@NonNull SiteModel site) { + return DeletablePrefKey.SITE_SUPPORTS_EDITOR_SETTINGS.name() + site.getId(); + } + + public static boolean getSiteSupportsEditorAssets(@NonNull SiteModel site) { + return prefs().getBoolean(getSiteSupportsEditorAssetsKey(site), false); + } + + public static void setSiteSupportsEditorAssets(@NonNull SiteModel site, boolean supports) { + prefs().edit().putBoolean(getSiteSupportsEditorAssetsKey(site), supports).apply(); + } + + @NonNull + private static String getSiteSupportsEditorAssetsKey(@NonNull SiteModel site) { + return DeletablePrefKey.SITE_SUPPORTS_EDITOR_ASSETS.name() + site.getId(); } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 947edd70076b..55c722c7fc95 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -3,6 +3,7 @@ package org.wordpress.android.ui.prefs import androidx.core.content.edit import com.google.gson.Gson import org.wordpress.android.fluxc.model.JetpackCapability +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.models.ReaderTag import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhase import org.wordpress.android.ui.posts.AuthorFilterSelection @@ -515,11 +516,17 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra get() = AppPrefs.getSupportEmail() set(value) = AppPrefs.setSupportEmail(value) - fun getSiteSupportsEditorSettings(siteId: Long): Boolean = - AppPrefs.getSiteSupportsEditorSettings(siteId) + fun getSiteSupportsEditorSettings(site: SiteModel): Boolean = + AppPrefs.getSiteSupportsEditorSettings(site) - fun setSiteSupportsEditorSettings(siteId: Long, supports: Boolean) = - AppPrefs.setSiteSupportsEditorSettings(siteId, supports) + fun setSiteSupportsEditorSettings(site: SiteModel, supports: Boolean) = + AppPrefs.setSiteSupportsEditorSettings(site, supports) + + fun getSiteSupportsEditorAssets(site: SiteModel): Boolean = + AppPrefs.getSiteSupportsEditorAssets(site) + + fun setSiteSupportsEditorAssets(site: SiteModel, supports: Boolean) = + AppPrefs.setSiteSupportsEditorAssets(site, supports) var isTrackNetworkRequestsEnabled: Boolean get() = AppPrefs.isTrackNetworkRequestsEnabled() From 5560b6c16f27999a6783a8644b2591740e6cc02b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:42:33 -0700 Subject: [PATCH 17/42] Add theme block style detection and site settings UI Fetch current theme during editor capability detection to determine if it is a block theme, persisting the result alongside editor settings and editor assets support. In Site Settings, show contextual messages under "Use Theme Styles" when the site lacks editor settings support (disabled) or when the active theme is not a block theme. Co-Authored-By: Claude Opus 4.6 --- .../repositories/EditorSettingsRepository.kt | 56 +++++++++++++++++-- .../wordpress/android/ui/prefs/AppPrefs.java | 14 +++++ .../android/ui/prefs/AppPrefsWrapper.kt | 6 ++ .../ui/prefs/SiteSettingsFragment.java | 17 ++++++ WordPress/src/main/res/values/strings.xml | 4 +- 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index 4d78a88da818..2b6d935b7af5 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -1,6 +1,8 @@ package org.wordpress.android.repositories import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider @@ -18,6 +20,7 @@ import javax.inject.Singleton class EditorSettingsRepository @Inject constructor( private val wpApiClientProvider: WpApiClientProvider, private val appPrefsWrapper: AppPrefsWrapper, + private val themeRepository: ThemeRepository, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher ) { private val editorSettingsSqlUtils = EditorSettingsSqlUtils() @@ -63,13 +66,32 @@ class EditorSettingsRepository @Inject constructor( return supports } + /** + * Returns whether the site's active theme is a block + * theme, based on a previously persisted result from + * [fetchEditorCapabilitiesForSite]. + */ + fun getThemeSupportsBlockStyles(site: SiteModel): Boolean { + val supports = + appPrefsWrapper.getSiteThemeIsBlockTheme(site) + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: getThemeSupportsBlockStyles" + + " site=${site.name}" + + " result=$supports" + ) + return supports + } + /** * Queries the site's REST API root index to check * whether the `wp-block-editor/v1/settings` and - * `wpcom/v2/editor-assets` routes are available. - * Both results are persisted so that - * [getSupportsEditorSettingsForSite] and - * [getSupportsEditorAssetsForSite] can return them + * `wpcom/v2/editor-assets` routes are available, + * and fetches the current theme to determine if it + * is a block theme. All results are persisted so + * that [getSupportsEditorSettingsForSite], + * [getSupportsEditorAssetsForSite], and + * [getThemeSupportsBlockStyles] can return them * synchronously on future calls. */ suspend fun fetchEditorCapabilitiesForSite( @@ -81,6 +103,15 @@ class EditorSettingsRepository @Inject constructor( " capabilities for site=${site.name}" ) + // supervisorScope so that a failure in one fetch + // doesn't cancel the other + supervisorScope { + launch { fetchRouteSupport(site) } + launch { fetchThemeBlockStyleSupport(site) } + } + } + + private suspend fun fetchRouteSupport(site: SiteModel) { val client = wpApiClientProvider.getWpApiClient(site) val response = client.request { it.apiRoot().get() } @@ -124,4 +155,21 @@ class EditorSettingsRepository @Inject constructor( } } } + + private suspend fun fetchThemeBlockStyleSupport( + site: SiteModel + ) { + val theme = themeRepository.fetchCurrentTheme(site) + val isBlockTheme = theme?.isBlockTheme ?: false + + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: theme fetched" + + " for site=${site.name}" + + " themeName=${theme?.name}" + + " isBlockTheme=$isBlockTheme" + ) + + appPrefsWrapper.setSiteThemeIsBlockTheme(site, isBlockTheme) + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 6343ab902f6d..bfc38d98b22c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -203,6 +203,7 @@ public enum DeletablePrefKey implements PrefKey { IS_SHARE_FLOW_PENDING, SITE_SUPPORTS_EDITOR_SETTINGS, SITE_SUPPORTS_EDITOR_ASSETS, + SITE_THEME_IS_BLOCK_THEME, } /** @@ -1895,4 +1896,17 @@ public static void setSiteSupportsEditorAssets(@NonNull SiteModel site, boolean private static String getSiteSupportsEditorAssetsKey(@NonNull SiteModel site) { return DeletablePrefKey.SITE_SUPPORTS_EDITOR_ASSETS.name() + site.getId(); } + + public static boolean getSiteThemeIsBlockTheme(@NonNull SiteModel site) { + return prefs().getBoolean(getSiteThemeIsBlockThemeKey(site), false); + } + + public static void setSiteThemeIsBlockTheme(@NonNull SiteModel site, boolean isBlockTheme) { + prefs().edit().putBoolean(getSiteThemeIsBlockThemeKey(site), isBlockTheme).apply(); + } + + @NonNull + private static String getSiteThemeIsBlockThemeKey(@NonNull SiteModel site) { + return DeletablePrefKey.SITE_THEME_IS_BLOCK_THEME.name() + site.getId(); + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 55c722c7fc95..be0c0114cdf3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -528,6 +528,12 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setSiteSupportsEditorAssets(site: SiteModel, supports: Boolean) = AppPrefs.setSiteSupportsEditorAssets(site, supports) + fun getSiteThemeIsBlockTheme(site: SiteModel): Boolean = + AppPrefs.getSiteThemeIsBlockTheme(site) + + fun setSiteThemeIsBlockTheme(site: SiteModel, isBlockTheme: Boolean) = + AppPrefs.setSiteThemeIsBlockTheme(site, isBlockTheme) + var isTrackNetworkRequestsEnabled: Boolean get() = AppPrefs.isTrackNetworkRequestsEnabled() set(value) = AppPrefs.setTrackNetworkRequestsEnabled(value) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java index 5657f3a850b6..4bc065442605 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java @@ -80,6 +80,7 @@ import org.wordpress.android.ui.bloggingreminders.BloggingRemindersViewModel; import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper; import org.wordpress.android.util.PlansConstants; +import org.wordpress.android.repositories.EditorSettingsRepository; import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker; import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation.ValidationType; import org.wordpress.android.ui.prefs.SiteSettingsFormatDialog.FormatType; @@ -194,6 +195,7 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; + @Inject EditorSettingsRepository mEditorSettingsRepository; private BloggingRemindersViewModel mBloggingRemindersViewModel; @@ -1081,6 +1083,21 @@ public void initPreferences() { // hide theme styles preference if GutenbergKit is not enabled if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled()) { WPPrefUtils.removePreference(this, R.string.pref_key_site_editor, R.string.pref_key_use_theme_styles); + } else if (!mEditorSettingsRepository.getSupportsEditorSettingsForSite(mSite)) { + mUseThemeStylesPref.setEnabled(false); + mUseThemeStylesPref.setSummary( + getString(R.string.site_settings_use_theme_styles_summary) + + "\n\n" + + getString(R.string.site_settings_use_theme_styles_unsupported) + ); + } else if (!mEditorSettingsRepository.getThemeSupportsBlockStyles(mSite)) { + mUseThemeStylesPref.setSummary( + getString(R.string.site_settings_use_theme_styles_summary) + + "\n\n" + + getString( + R.string.site_settings_use_theme_styles_not_block_theme + ) + ); } // hide Admin options depending of capabilities on this site diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 0d3138275746..6f79026f4514 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -699,7 +699,9 @@ Use Block Editor Edit new posts and pages with the block editor Use Theme Styles - Make the block editor look like your theme + Make the block editor look like your theme. + Install the Gutenberg Plugin on your site to activate theme style support. + Your site isn\'t using a Block Theme, so the editor might not match your content correctly. If things aren\'t looking right, you can disable editor styles. Password updated To reconnect the app to your self-hosted site, enter the site\'s new password here. Homepage Settings From a056ed67b6d0bb56cc36f41fb7034650ea7bf224 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:49:12 -0700 Subject: [PATCH 18/42] Use site's "Use Theme Styles" setting for editor configuration Read the actual checkbox value from the local site settings DB instead of just checking whether the site supports editor settings. Falls back to false when editor settings are not supported. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergKitSettingsBuilder.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index d7881bb63e68..ea686a13cd07 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -3,6 +3,8 @@ package org.wordpress.android.ui.posts import android.util.Base64 import org.wordpress.android.fluxc.model.PostImmutableModel import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.datasets.SiteSettingsTable +import org.wordpress.android.models.SiteSettingsModel import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog import org.wordpress.gutenberg.model.EditorConfiguration @@ -67,7 +69,7 @@ class GutenbergKitSettingsBuilder @Inject constructor( "/wpcom/v2/following/mine" ) ) - setThemeStyles(editorSettingsRepository.getSupportsEditorSettingsForSite(site)) + setThemeStyles(getUseThemeStyles(site)) setPlugins(editorSettingsRepository.getSupportsEditorAssetsForSite(site)) setLocale("en") setCookies(emptyMap()) @@ -146,6 +148,21 @@ class GutenbergKitSettingsBuilder @Inject constructor( } } + private fun getUseThemeStyles(site: SiteModel): Boolean { + if (!editorSettingsRepository.getSupportsEditorSettingsForSite(site)) { + return false + } + val cursor = SiteSettingsTable.getSettings(site.id.toLong()) + if (cursor != null && cursor.moveToFirst()) { + val model = SiteSettingsModel() + model.deserializeOptionsDatabaseCursor(cursor, null) + cursor.close() + return model.useThemeStyles + } + cursor?.close() + return true + } + private fun buildCachedHosts(siteUrl: String): Set { val siteHost = extractHost(siteUrl) return if (!siteHost.isNullOrEmpty()) { From dc9f05899c9fe92b3823fefdbc7d5fcefef73484 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:14:16 -0700 Subject: [PATCH 19/42] Add "Use Third-Party Blocks" site setting for editor plugin support Adds a new site setting that lets users control whether third-party blocks and styles from installed plugins are loaded in the editor. The setting is gated behind GutenbergKit being enabled and the site supporting editor assets. Includes DB migration, settings UI toggle, and integration with GutenbergKitSettingsBuilder. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/android/WordPressDB.java | 5 +++- .../android/models/SiteSettingsModel.java | 9 +++++++ .../ui/posts/GutenbergKitSettingsBuilder.kt | 17 ++++++++++++- .../ui/prefs/SiteSettingsFragment.java | 25 ++++++++++++++++++- .../ui/prefs/SiteSettingsInterface.java | 8 ++++++ .../android/ui/prefs/WPComSiteSettings.java | 2 ++ WordPress/src/main/res/values/key_strings.xml | 1 + WordPress/src/main/res/values/strings.xml | 3 +++ WordPress/src/main/res/xml/site_settings.xml | 7 ++++++ 9 files changed, 74 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/WordPressDB.java b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java index 1b5e7f751d83..efee99035afb 100755 --- a/WordPress/src/main/java/org/wordpress/android/WordPressDB.java +++ b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java @@ -22,7 +22,7 @@ import java.io.OutputStream; public class WordPressDB { - private static final int DATABASE_VERSION = 70; + private static final int DATABASE_VERSION = 71; // Warning renaming DATABASE_NAME could break previous App backups (see: xml/backup_scheme.xml) @@ -187,6 +187,9 @@ public WordPressDB(Context ctx) { case 69: // add editor theme styles site setting mDb.execSQL(SiteSettingsModel.ADD_USE_THEME_STYLES); + case 70: + // add third-party blocks setting + mDb.execSQL(SiteSettingsModel.ADD_USE_THIRD_PARTY_BLOCKS); } mDb.setVersion(DATABASE_VERSION); } diff --git a/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java b/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java index 106d1a3a7473..30e34101dc1c 100644 --- a/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java +++ b/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java @@ -70,6 +70,7 @@ public class SiteSettingsModel { private static final String JETPACK_SEARCH_SUPPORTED_COLUMN_NAME = "jetpackSearchSupported"; private static final String JETPACK_SEARCH_ENABLED_COLUMN_NAME = "jetpackSearchEnabled"; private static final String USE_THEME_STYLES_COLUMN_NAME = "useThemeStyles"; + private static final String USE_THIRD_PARTY_BLOCKS_COLUMN_NAME = "useThirdPartyBlocks"; public static final String SETTINGS_TABLE_NAME = "site_settings"; @@ -107,6 +108,9 @@ public class SiteSettingsModel { + " add " + SITE_ICON_COLUMN_NAME + " INTEGER;"; public static final String ADD_USE_THEME_STYLES = "alter table " + SETTINGS_TABLE_NAME + " add " + USE_THEME_STYLES_COLUMN_NAME + " BOOLEAN DEFAULT 1;"; + public static final String ADD_USE_THIRD_PARTY_BLOCKS = "alter table " + SETTINGS_TABLE_NAME + + " add " + USE_THIRD_PARTY_BLOCKS_COLUMN_NAME + + " BOOLEAN DEFAULT 0;"; public static final String CREATE_SETTINGS_TABLE_SQL = "CREATE TABLE IF NOT EXISTS " @@ -198,6 +202,7 @@ public class SiteSettingsModel { public boolean jetpackSearchSupported; public boolean jetpackSearchEnabled; public boolean useThemeStyles = true; + public boolean useThirdPartyBlocks = false; public String quotaDiskSpace; @Override @@ -243,6 +248,7 @@ && equals(timezone, otherModel.timezone) && jetpackSearchEnabled == otherModel.jetpackSearchEnabled && jetpackSearchSupported == otherModel.jetpackSearchSupported && useThemeStyles == otherModel.useThemeStyles + && useThirdPartyBlocks == otherModel.useThirdPartyBlocks && maxLinks == otherModel.maxLinks && equals(defaultPostFormat, otherModel.defaultPostFormat) && holdForModeration != null @@ -309,6 +315,7 @@ public void copyFrom(SiteSettingsModel other) { jetpackSearchSupported = other.jetpackSearchSupported; jetpackSearchEnabled = other.jetpackSearchEnabled; useThemeStyles = other.useThemeStyles; + useThirdPartyBlocks = other.useThirdPartyBlocks; if (other.holdForModeration != null) { holdForModeration = new ArrayList<>(other.holdForModeration); } @@ -374,6 +381,7 @@ public void deserializeOptionsDatabaseCursor(Cursor cursor, SparseArrayCompat { val siteHost = extractHost(siteUrl) return if (!siteHost.isNullOrEmpty()) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java index 4bc065442605..abd63e4b8b04 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java @@ -230,6 +230,7 @@ public class SiteSettingsFragment extends PreferenceFragment // Writing settings private WPSwitchPreference mGutenbergDefaultForNewPosts; private WPSwitchPreference mUseThemeStylesPref; + private WPSwitchPreference mUseThirdPartyBlocksPref; private DetailListPreference mCategoryPref; private DetailListPreference mFormatPref; private WPPreference mDateFormatPref; @@ -850,6 +851,8 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { AnalyticsUtils.refreshMetadata(mAccountStore, mSiteStore); } else if (preference == mUseThemeStylesPref) { mSiteSettings.setUseThemeStyles((Boolean) newValue); + } else if (preference == mUseThirdPartyBlocksPref) { + mSiteSettings.setUseThirdPartyBlocks((Boolean) newValue); } else if (preference == mBloggingPromptsPref) { final boolean isEnabled = (boolean) newValue; mPromptsSettingsHelper.updatePromptsCardEnabledBlocking(mSite.getId(), isEnabled); @@ -1039,6 +1042,10 @@ public void initPreferences() { (WPSwitchPreference) getChangePref(R.string.pref_key_use_theme_styles); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); + mUseThirdPartyBlocksPref = + (WPSwitchPreference) getChangePref(R.string.pref_key_use_third_party_blocks); + mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); + mSiteAcceleratorSettings = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings); mSiteAcceleratorSettingsNested = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings_nested); @@ -1100,6 +1107,20 @@ public void initPreferences() { ); } + // hide third-party blocks preference if GutenbergKit is not enabled + if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled()) { + WPPrefUtils.removePreference( + this, R.string.pref_key_site_editor, R.string.pref_key_use_third_party_blocks + ); + } else if (!mEditorSettingsRepository.getSupportsEditorAssetsForSite(mSite)) { + mUseThirdPartyBlocksPref.setEnabled(false); + mUseThirdPartyBlocksPref.setSummary( + getString(R.string.site_settings_use_third_party_blocks_summary) + + "\n\n" + + getString(R.string.site_settings_use_third_party_blocks_unsupported) + ); + } + // hide Admin options depending of capabilities on this site if ((!isAccessedViaWPComRest && !mSite.isSelfHostedAdmin()) || (isAccessedViaWPComRest && !mSite.getHasCapabilityManageOptions())) { @@ -1224,7 +1245,8 @@ public void setEditingEnabled(boolean enabled) { mDateFormatPref, mTimeFormatPref, mTimezonePref, mBloggingRemindersPref, mPostsPerPagePref, mAmpPref, mDeleteSitePref, mJpMonitorActivePref, mJpMonitorEmailNotesPref, mJpSsoPref, mJpMonitorWpNotesPref, mJpBruteForcePref, mJpAllowlistPref, mJpMatchEmailPref, mJpUseTwoFactorPref, - mGutenbergDefaultForNewPosts, mUseThemeStylesPref, mHomepagePref, mBloggingPromptsPref + mGutenbergDefaultForNewPosts, mUseThemeStylesPref, mUseThirdPartyBlocksPref, + mHomepagePref, mBloggingPromptsPref }; for (Preference preference : editablePreference) { @@ -1569,6 +1591,7 @@ public void setPreferencesFromSiteSettings() { mWeekStartPref.setSummary(mWeekStartPref.getEntry()); mGutenbergDefaultForNewPosts.setChecked(SiteUtils.isBlockEditorDefaultForNewPost(mSite)); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); + mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); setAdFreeHostingChecked(mSiteSettings.isAdFreeHostingEnabled()); boolean checked = mSiteSettings.isImprovedSearchEnabled() || mSiteSettings.getJetpackSearchEnabled(); mImprovedSearch.setChecked(checked); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java index adf1a19c456c..f68f87774683 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java @@ -628,6 +628,14 @@ public void setUseThemeStyles(boolean enabled) { mSettings.useThemeStyles = enabled; } + public boolean getUseThirdPartyBlocks() { + return mSettings.useThirdPartyBlocks; + } + + public void setUseThirdPartyBlocks(boolean enabled) { + mSettings.useThirdPartyBlocks = enabled; + } + public boolean isJetpackMonitorEnabled() { return mJpSettings.monitorActive; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java index beba14352ea5..85d8d0fa213a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java @@ -182,10 +182,12 @@ public void onResponse(JSONObject response) { // Local settings boolean location = mSettings.location; boolean useThemeStyles = mSettings.useThemeStyles; + boolean useThirdPartyBlocks = mSettings.useThirdPartyBlocks; mSettings.copyFrom(mRemoteSettings); mSettings.postFormats = currentPostFormats; mSettings.location = location; mSettings.useThemeStyles = useThemeStyles; + mSettings.useThirdPartyBlocks = useThirdPartyBlocks; SiteSettingsTable.saveSettings(mSettings); } diff --git a/WordPress/src/main/res/values/key_strings.xml b/WordPress/src/main/res/values/key_strings.xml index efee36015b10..8d84013f5305 100644 --- a/WordPress/src/main/res/values/key_strings.xml +++ b/WordPress/src/main/res/values/key_strings.xml @@ -56,6 +56,7 @@ wp_pref_key_optimize_video wp_pref_key_gutenberg_default_for_new_posts wp_pref_key_use_theme_styles + wp_pref_key_use_third_party_blocks wp_pref_site_default_video_width wp_pref_site_default_encoder_bitrate wp_pref_site_discussion diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 6f79026f4514..e6cd753ce582 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -702,6 +702,9 @@ Make the block editor look like your theme. Install the Gutenberg Plugin on your site to activate theme style support. Your site isn\'t using a Block Theme, so the editor might not match your content correctly. If things aren\'t looking right, you can disable editor styles. + Use Third-Party Blocks (Beta) + Load third-party blocks and styles from plugins installed on your site. + Your site doesn\'t support loading third-party blocks in the editor. Password updated To reconnect the app to your self-hosted site, enter the site\'s new password here. Homepage Settings diff --git a/WordPress/src/main/res/xml/site_settings.xml b/WordPress/src/main/res/xml/site_settings.xml index fc885caed522..467b91d41808 100644 --- a/WordPress/src/main/res/xml/site_settings.xml +++ b/WordPress/src/main/res/xml/site_settings.xml @@ -138,6 +138,13 @@ android:summary="@string/site_settings_use_theme_styles_summary" android:title="@string/site_settings_use_theme_styles" /> + + From 81441fa2330fe00e974a4e10a604c12a7b486744 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:09:17 -0700 Subject: [PATCH 20/42] Add WP.com simple site support to WpApiClient Add `isWPComSimpleSite()` to SiteModel and use it to route WP.com simple sites through the public-api.wordpress.com proxy with bearer token authentication. This enables the Rust WP API client to make authenticated requests for WPCom sites that don't use application passwords. Co-Authored-By: Claude Opus 4.6 --- .../android/fluxc/model/SiteModel.java | 8 ++++++++ .../rest/wpapi/rs/WpApiClientProvider.kt | 19 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java index 0635d78e55a2..cbf55451786a 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java @@ -438,6 +438,10 @@ public void setXmlRpcUrl(String xmlRpcUrl) { } public String getWpApiRestUrl() { + if (isWPComSimpleSite()) { + return "https://public-api.wordpress.com/wp/v2/sites/" + + mSiteId; + } return mWpApiRestUrl; } @@ -926,6 +930,10 @@ public boolean hasDiskSpaceQuotaInformation() { return mSpaceAllowed > 0; } + public boolean isWPComSimpleSite() { + return isWPCom() && !isWPComAtomic(); + } + public boolean isWPComAtomic() { return mIsWPComAtomic; } diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt index 12d670496720..e0b524143899 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt @@ -16,9 +16,11 @@ import uniffi.wp_api.CookiesNonceAuthenticationProvider import uniffi.wp_api.WpAppNotifier import uniffi.wp_api.WpAuthentication import uniffi.wp_api.WpAuthenticationProvider +import uniffi.wp_api.ParsedUrl import uniffi.wp_api.WpComBaseUrl import uniffi.wp_api.WpComDotOrgApiUrlResolver as WpComUrlResolver // checkstyle ignore import uniffi.wp_api.WpDynamicAuthenticationProvider +import uniffi.wp_api.WpOrgSiteApiUrlResolver import java.net.URL import javax.inject.Inject import javax.inject.Named @@ -61,13 +63,26 @@ class WpApiClientProvider @Inject constructor( site: SiteModel, uploadListener: WpRequestExecutor.UploadListener?, ): WpApiClient { - val authProvider = + val authProvider = if (site.isWPComSimpleSite) { + createWpComAuthProvider(accountStore) + } else { WpAuthenticationProvider.staticWithUsernameAndPassword( username = site.apiRestUsernamePlain, password = site.apiRestPasswordPlain, ) + } + + val urlResolver = if (site.isWPComSimpleSite) { + WpComUrlResolver( + siteId = site.siteId.toString(), + baseUrl = WpComBaseUrl.Production + ) + } else { + WpOrgSiteApiUrlResolver(ParsedUrl.parse(site.buildUrl())) + } + return WpApiClient( - wpOrgSiteApiRootUrl = URL(site.buildUrl()), + apiUrlResolver = urlResolver, authProvider = authProvider, requestExecutor = WpRequestExecutor( interceptors = interceptors.toList(), From d6044b99977dd2a03914380840a0f280ea7dfd60 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:09:41 -0700 Subject: [PATCH 21/42] Build editor assets endpoint for self-hosted sites Remove the requirement for a non-empty namespace when building the editor assets endpoint URL. Self-hosted sites have an empty namespace but the wpcom/v2/editor-assets endpoint is still valid for them. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 3dc277746395..2b25989e41df 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -191,8 +191,8 @@ class GutenbergKitSettingsBuilder @Inject constructor( siteApiRoot: String, siteApiNamespace: Array ): String? { + if (siteApiRoot.isEmpty()) return null val firstNamespace = siteApiNamespace.firstOrNull() ?: "" - if (firstNamespace.isEmpty() || siteApiRoot.isEmpty()) return null return "${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets" } From e372a10e83c5c834f8ba605effef95eaf5292904 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:49:45 -0700 Subject: [PATCH 22/42] Fix deprecation warning --- .../android/ui/posts/editor/GutenbergKitEditorFragment.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 647db3d51ea5..1fa8b2888e1a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -14,6 +14,7 @@ import android.view.View import android.view.ViewGroup import android.webkit.URLUtil import android.widget.FrameLayout +import androidx.core.os.BundleCompat import androidx.core.util.Pair import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope @@ -152,8 +153,10 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { val gutenbergViewContainer = rootView!!.findViewById(R.id.gutenberg_view_container) val configuration = requireNotNull( - requireArguments().getParcelable( - ARG_GUTENBERG_KIT_SETTINGS, EditorConfiguration::class.java + BundleCompat.getParcelable( + requireArguments(), + ARG_GUTENBERG_KIT_SETTINGS, + EditorConfiguration::class.java ) ) From 26fbb8bcfd24c49fac4f6072e6ace7aa6b68b659 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:36:18 -0700 Subject: [PATCH 23/42] Stabilize Editor Preloader --- .../android/ui/mysite/MySiteViewModel.kt | 14 ++- .../ui/posts/GutenbergEditorPreloader.kt | 87 +++++++++++++++++-- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt index 50e343f39c8f..33ff864e0be7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt @@ -166,9 +166,8 @@ class MySiteViewModel @Inject constructor( selectedSiteRepository.getSelectedSite()?.let { site -> if (isPullToRefresh) { siteCapabilityChecker.clearCacheForSite(site.siteId) - gutenbergEditorPreloader.clear() } - buildDashboardOrSiteItems(site) + buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh) } ?: run { accountDataViewModelSlice.onRefresh() } @@ -282,7 +281,10 @@ class MySiteViewModel @Inject constructor( } } - private fun buildDashboardOrSiteItems(site: SiteModel) { + private fun buildDashboardOrSiteItems( + site: SiteModel, + forceRefresh: Boolean = false + ) { siteInfoHeaderCardViewModelSlice.buildCard(site) applicationPasswordViewModelSlice.buildCard(site) if (shouldShowDashboard(site)) { @@ -292,7 +294,11 @@ class MySiteViewModel @Inject constructor( dashboardItemsViewModelSlice.buildItems(site) dashboardCardsViewModelSlice.clearValue() } - gutenbergEditorPreloader.preloadIfNeeded(site, viewModelScope) + if (forceRefresh) { + gutenbergEditorPreloader.refreshPreloading(site, viewModelScope) + } else { + gutenbergEditorPreloader.preloadIfNeeded(site, viewModelScope) + } } private fun onSitePicked(site: SiteModel) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 9ede577b4701..bfe92aa735e2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -1,10 +1,12 @@ package org.wordpress.android.ui.posts import android.content.Context +import android.os.Looper import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore @@ -18,6 +20,38 @@ import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton +/** + * Opportunistically preloads GutenbergKit editor dependencies in the + * background so the editor opens faster. + * + * ## Usage + * + * - [preloadIfNeeded] — idempotent; call whenever a site becomes + * visible. Skips work if the site was already preloaded or a job + * is in flight. + * - [refreshPreloading] — discards any cached result and + * re-preloads from scratch (e.g. on pull-to-refresh). + * - [getDependencies] — returns the cached result, or `null` if + * preloading has not completed. Callers must handle `null` + * gracefully by loading dependencies themselves. + * - [clear] — cancels in-flight work and releases cached data. + * Call when the driving scope is being destroyed. + * + * ## Threading + * + * [preloadIfNeeded], [refreshPreloading], and [clear] must be + * called from the main thread. The background coroutine writes + * [cachedDependencies] on [bgDispatcher]; the field is + * [@Volatile] so the main-thread read in [getDependencies] has a + * happens-before guarantee. + * + * ## Deduplication + * + * Preloading is skipped when the site's **local** database ID + * ([SiteModel.id]) matches the last successfully preloaded site, + * or when a preload job is already in flight. On failure the guard + * is reset so the next visit retries automatically. + */ @Singleton class GutenbergEditorPreloader @Inject constructor( @ApplicationContext private val appContext: Context, @@ -27,18 +61,33 @@ class GutenbergEditorPreloader @Inject constructor( private val editorSettingsRepository: EditorSettingsRepository, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) { - private var lastPreloadedSiteId: Long = -1 + private fun checkMainThread() = check( + Looper.getMainLooper() == Looper.myLooper() + ) { "Must be called from the main thread" } + + private var lastPreloadedSiteLocalId: Int = -1 private var preloadJob: Job? = null + + @Volatile private var cachedDependencies: EditorDependencies? = null + /** + * Starts a background preload for [site] if one hasn't already + * been performed for this site and no job is currently in flight. + * + * [scope] is the caller's [CoroutineScope] (typically + * `viewModelScope`); the launched coroutine is cancelled when + * that scope is cancelled. + */ fun preloadIfNeeded(site: SiteModel, scope: CoroutineScope) { + checkMainThread() if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled() || !SiteUtils.isBlockEditorDefaultForNewPost(site) || - site.siteId == lastPreloadedSiteId || + site.id == lastPreloadedSiteLocalId || preloadJob?.isActive == true ) return - lastPreloadedSiteId = site.siteId + lastPreloadedSiteLocalId = site.id preloadJob = scope.launch(bgDispatcher) { try { editorSettingsRepository @@ -49,10 +98,14 @@ class GutenbergEditorPreloader @Inject constructor( accessToken = accountStore.accessToken ) val store = EditorDependencyStore(appContext, scope) - cachedDependencies = store.fetch(config) + val result = store.fetch(config) + if (isActive) { + cachedDependencies = result + } AppLog.d( AppLog.T.EDITOR, - "Editor dependencies preloaded for site ${site.name}" + "Editor dependencies preloaded for" + + " site ${site.name}" ) } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { AppLog.e( @@ -61,16 +114,38 @@ class GutenbergEditorPreloader @Inject constructor( e ) cachedDependencies = null + lastPreloadedSiteLocalId = -1 } } } + /** + * Discards any cached result and re-preloads dependencies for + * [site] from scratch. Use for pull-to-refresh or any scenario + * where the caller wants to force a fresh fetch. + */ + fun refreshPreloading(site: SiteModel, scope: CoroutineScope) { + checkMainThread() + clear() + preloadIfNeeded(site, scope) + } + + /** + * Returns the preloaded dependencies, or `null` if preloading + * has not completed (or failed). Callers must handle `null` + * gracefully by loading dependencies themselves. + */ fun getDependencies(): EditorDependencies? = cachedDependencies + /** + * Cancels any in-flight preload and discards cached results. + * Call when the driving scope is being destroyed. + */ fun clear() { + checkMainThread() preloadJob?.cancel() preloadJob = null cachedDependencies = null - lastPreloadedSiteId = -1 + lastPreloadedSiteLocalId = -1 } } From bf71abbe059ec37d652c2327dc66d171df97c77c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:39:04 -0700 Subject: [PATCH 24/42] Use firstOrNull() in ThemeRepository to avoid crash on empty response The active-theme endpoint could return an empty list, causing .first() to throw NoSuchElementException. Using .firstOrNull() matches the nullable return type and lets callers handle absence gracefully. Co-Authored-By: Claude Opus 4.6 --- .../java/org/wordpress/android/repositories/ThemeRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt index 21bbd0fa803e..d5816f5b3d03 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt @@ -33,7 +33,7 @@ class ThemeRepository @Inject constructor( when (response) { is WpRequestResult.Success -> - response.response.data.first() + response.response.data.firstOrNull() else -> null } } From 484e3b3ecb77d219a0b6cb1a285b6236699e57dd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:39:12 -0700 Subject: [PATCH 25/42] Fix cursor leak and test compile errors in GutenbergKitSettingsBuilder Wrap cursor usage in try/finally in getUseThemeStyles and getUseThirdPartyBlocks so the cursor is always closed, even if deserialization throws. Also fix four tests that were calling buildPostConfiguration as a static method instead of on the injected instance. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergKitSettingsBuilder.kt | 34 +++++++++------ .../posts/GutenbergKitSettingsBuilderTest.kt | 42 +++++++++---------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 2b25989e41df..dd809126a053 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -149,32 +149,42 @@ class GutenbergKitSettingsBuilder @Inject constructor( } private fun getUseThemeStyles(site: SiteModel): Boolean { - if (!editorSettingsRepository.getSupportsEditorSettingsForSite(site)) { + if (!editorSettingsRepository + .getSupportsEditorSettingsForSite(site) + ) { return false } val cursor = SiteSettingsTable.getSettings(site.id.toLong()) - if (cursor != null && cursor.moveToFirst()) { - val model = SiteSettingsModel() - model.deserializeOptionsDatabaseCursor(cursor, null) + ?: return true + try { + if (cursor.moveToFirst()) { + val model = SiteSettingsModel() + model.deserializeOptionsDatabaseCursor(cursor, null) + return model.useThemeStyles + } + } finally { cursor.close() - return model.useThemeStyles } - cursor?.close() return true } private fun getUseThirdPartyBlocks(site: SiteModel): Boolean { - if (!editorSettingsRepository.getSupportsEditorAssetsForSite(site)) { + if (!editorSettingsRepository + .getSupportsEditorAssetsForSite(site) + ) { return false } val cursor = SiteSettingsTable.getSettings(site.id.toLong()) - if (cursor != null && cursor.moveToFirst()) { - val model = SiteSettingsModel() - model.deserializeOptionsDatabaseCursor(cursor, null) + ?: return false + try { + if (cursor.moveToFirst()) { + val model = SiteSettingsModel() + model.deserializeOptionsDatabaseCursor(cursor, null) + return model.useThirdPartyBlocks + } + } finally { cursor.close() - return model.useThirdPartyBlocks } - cursor?.close() return false } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index de92507e6bc2..4fc3b187495f 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -398,11 +398,10 @@ class GutenbergKitSettingsBuilderTest { apiRestPasswordPlain = "app_pass" apiRestUsernamePlain = "admin" } - val config = GutenbergKitSettingsBuilder - .buildPostConfiguration( - site = site, - accessToken = "wpcom_token" - ) + val config = builder.buildPostConfiguration( + site = site, + accessToken = "wpcom_token" + ) assertThat(config.siteApiRoot) .isEqualTo("https://mysite.com/wp-json/") @@ -453,12 +452,11 @@ class GutenbergKitSettingsBuilderTest { setIsLocalDraft(true) setRemotePostId(99L) } - val config = GutenbergKitSettingsBuilder - .buildPostConfiguration( - site = site, - post = post, - accessToken = "test_token" - ) + val config = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) assertThat(config.postId).isNull() } @@ -591,12 +589,11 @@ class GutenbergKitSettingsBuilderTest { val post = PostModel().apply { setIsPage(true) } - val config = GutenbergKitSettingsBuilder - .buildPostConfiguration( - site = site, - post = post, - accessToken = "test_token" - ) + val config = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) assertThat(config.postType).isEqualTo("page") } @@ -614,12 +611,11 @@ class GutenbergKitSettingsBuilderTest { setIsLocalDraft(false) setRemotePostId(42L) } - val config = GutenbergKitSettingsBuilder - .buildPostConfiguration( - site = site, - post = post, - accessToken = "test_token" - ) + val config = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) assertThat(config.postId).isEqualTo(42) } From c7068eff5296eb71412336f4d41f2b64b67cb1b0 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:39:20 -0700 Subject: [PATCH 26/42] Add isLoadingMore to LaunchedEffect keys in ObserveLoadMore Without this key the effect captures a stale isLoadingMore value and may trigger duplicate load-more calls or skip them entirely when the loading state changes. Co-Authored-By: Claude Opus 4.6 --- .../ui/navmenus/screens/ObserveScrollDirectionForFab.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt b/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt index 0cb7bb357193..e1384ad601b2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt @@ -58,7 +58,12 @@ fun ObserveLoadMore( } } - LaunchedEffect(lastVisibleItemIndex.value, itemCount, canLoadMore) { + LaunchedEffect( + lastVisibleItemIndex.value, + itemCount, + canLoadMore, + isLoadingMore + ) { val shouldLoadMore = lastVisibleItemIndex.value >= itemCount - 1 && canLoadMore && From 8aaa5f7e30cab67509c62b1edd966c8ceb931601 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:05:03 -0700 Subject: [PATCH 27/42] Add `ThemeRepositoryTest` --- .../repositories/ThemeRepositoryTest.kt | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt new file mode 100644 index 000000000000..61e159985ac4 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt @@ -0,0 +1,109 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import rs.wordpress.api.kotlin.WpApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.ThemeWithEditContext +import uniffi.wp_api.ThemesRequestListWithEditContextResponse +import uniffi.wp_api.WpNetworkHeaderMap + +@ExperimentalCoroutinesApi +class ThemeRepositoryTest : BaseUnitTest() { + @Mock + lateinit var wpApiClientProvider: WpApiClientProvider + + @Mock + lateinit var wpApiClient: WpApiClient + + private lateinit var repository: ThemeRepository + + private val testSite = SiteModel().apply { + id = 1 + url = "https://test.wordpress.com" + } + + @Before + fun setUp() { + whenever(wpApiClientProvider.getWpApiClient(testSite)) + .thenReturn(wpApiClient) + + repository = ThemeRepository( + wpApiClientProvider = wpApiClientProvider, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `returns theme when API succeeds with non-empty list`() = + runTest { + val theme = mock() + mockSuccessResponse(listOf(theme)) + + val result = repository.fetchCurrentTheme(testSite) + + assertThat(result).isEqualTo(theme) + } + + @Test + fun `returns null when API succeeds with empty list`() = + runTest { + mockSuccessResponse(emptyList()) + + val result = repository.fetchCurrentTheme(testSite) + + assertThat(result).isNull() + } + + @Test + fun `returns first theme when API returns multiple`() = + runTest { + val first = mock() + val second = mock() + mockSuccessResponse(listOf(first, second)) + + val result = repository.fetchCurrentTheme(testSite) + + assertThat(result).isEqualTo(first) + } + + @Test + fun `returns null on API error`() = runTest { + val error = WpRequestResult.UnknownError( + statusCode = 500u, + response = "Internal Server Error" + ) + whenever(wpApiClient.request(any())) + .thenReturn(error) + + val result = repository.fetchCurrentTheme(testSite) + + assertThat(result).isNull() + } + + @Suppress("UNCHECKED_CAST") + private suspend fun mockSuccessResponse( + themes: List + ) { + val response = ThemesRequestListWithEditContextResponse( + data = themes, + headerMap = mock() + ) + val success = WpRequestResult.Success(response) + whenever(wpApiClient.request(any())) + .thenReturn( + success + as WpRequestResult + ) + } +} From e219c7f220a91e46b601e7eb3d537d40b7c442af Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:54:36 -0700 Subject: [PATCH 28/42] Extract SiteSettingsProvider interface for testability Replace direct SiteSettingsTable.getSettings() calls in GutenbergKitSettingsBuilder with an injectable SiteSettingsProvider that returns SiteSettingsModel directly, encapsulating cursor lifecycle in the implementation. Also fixes three pre-existing test failures: editor assets endpoint tests now expect a URL for self-hosted sites, and the app-password test uses a Jetpack-connected site (the realistic scenario). Co-Authored-By: Claude Opus 4.6 --- .../android/datasets/SiteSettingsProvider.kt | 11 +++ .../datasets/SiteSettingsProviderImpl.kt | 24 +++++ .../wordpress/android/modules/PostModule.kt | 11 ++- .../ui/posts/GutenbergKitSettingsBuilder.kt | 38 ++------ .../posts/GutenbergKitSettingsBuilderTest.kt | 92 +++++++++++++++++-- 5 files changed, 138 insertions(+), 38 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt new file mode 100644 index 000000000000..07aa4f92e09e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.datasets + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.models.SiteSettingsModel + +/** + * Provides [SiteSettingsModel] for a given site. + */ +interface SiteSettingsProvider { + fun getSettings(site: SiteModel): SiteSettingsModel? +} diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt new file mode 100644 index 000000000000..a4ca212dadf6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt @@ -0,0 +1,24 @@ +package org.wordpress.android.datasets + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.models.SiteSettingsModel +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SiteSettingsProviderImpl @Inject constructor() : + SiteSettingsProvider { + override fun getSettings(site: SiteModel): SiteSettingsModel? { + val cursor = SiteSettingsTable.getSettings(site.id.toLong()) + ?: return null + return cursor.use { + if (it.moveToFirst()) { + SiteSettingsModel().also { model -> + model.deserializeOptionsDatabaseCursor(it, null) + } + } else { + null + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt index 14d034b274b5..71ec39788245 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt +++ b/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt @@ -4,6 +4,8 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.datasets.SiteSettingsProviderImpl import org.wordpress.android.ui.posts.IPostFreshnessChecker import org.wordpress.android.ui.posts.PostFreshnessCheckerImpl import javax.inject.Singleton @@ -13,5 +15,12 @@ import javax.inject.Singleton class PostModule { @Singleton @Provides - fun providePostFreshnessChecker(): IPostFreshnessChecker = PostFreshnessCheckerImpl() + fun providePostFreshnessChecker(): IPostFreshnessChecker = + PostFreshnessCheckerImpl() + + @Singleton + @Provides + fun provideSiteSettingsProvider( + impl: SiteSettingsProviderImpl + ): SiteSettingsProvider = impl } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index dd809126a053..e2a2c1c7331d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -1,10 +1,9 @@ package org.wordpress.android.ui.posts import android.util.Base64 +import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.PostImmutableModel import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.datasets.SiteSettingsTable -import org.wordpress.android.models.SiteSettingsModel import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog import org.wordpress.gutenberg.model.EditorConfiguration @@ -14,9 +13,9 @@ import javax.inject.Singleton @Singleton class GutenbergKitSettingsBuilder @Inject constructor( - private val editorSettingsRepository: EditorSettingsRepository + private val editorSettingsRepository: EditorSettingsRepository, + private val siteSettingsProvider: SiteSettingsProvider ) { - fun buildPostConfiguration( site: SiteModel, post: PostImmutableModel? = null, @@ -154,18 +153,8 @@ class GutenbergKitSettingsBuilder @Inject constructor( ) { return false } - val cursor = SiteSettingsTable.getSettings(site.id.toLong()) - ?: return true - try { - if (cursor.moveToFirst()) { - val model = SiteSettingsModel() - model.deserializeOptionsDatabaseCursor(cursor, null) - return model.useThemeStyles - } - } finally { - cursor.close() - } - return true + return siteSettingsProvider + .getSettings(site)?.useThemeStyles ?: true } private fun getUseThirdPartyBlocks(site: SiteModel): Boolean { @@ -174,18 +163,8 @@ class GutenbergKitSettingsBuilder @Inject constructor( ) { return false } - val cursor = SiteSettingsTable.getSettings(site.id.toLong()) - ?: return false - try { - if (cursor.moveToFirst()) { - val model = SiteSettingsModel() - model.deserializeOptionsDatabaseCursor(cursor, null) - return model.useThirdPartyBlocks - } - } finally { - cursor.close() - } - return false + return siteSettingsProvider + .getSettings(site)?.useThirdPartyBlocks ?: false } private fun buildCachedHosts(siteUrl: String): Set { @@ -217,6 +196,7 @@ class GutenbergKitSettingsBuilder @Inject constructor( companion object { private const val AUTH_BEARER_PREFIX = "Bearer " private const val AUTH_BASIC_PREFIX = "Basic " - private const val WPCOM_API_ROOT = "https://public-api.wordpress.com/" + private const val WPCOM_API_ROOT = + "https://public-api.wordpress.com/" } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index 4fc3b187495f..4cd4a4fbc40c 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -5,8 +5,14 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.models.SiteSettingsModel import org.wordpress.android.repositories.EditorSettingsRepository @RunWith(MockitoJUnitRunner::class) @@ -14,8 +20,14 @@ class GutenbergKitSettingsBuilderTest { @Mock lateinit var editorSettingsRepository: EditorSettingsRepository + @Mock + lateinit var siteSettingsProvider: SiteSettingsProvider + private val builder by lazy { - GutenbergKitSettingsBuilder(editorSettingsRepository) + GutenbergKitSettingsBuilder( + editorSettingsRepository, + siteSettingsProvider + ) } // ===== Auth Header Tests ===== @@ -378,10 +390,12 @@ class GutenbergKitSettingsBuilderTest { } @Test - fun `self-hosted site has null editor assets endpoint`() { + fun `self-hosted site builds editor assets endpoint from API root`() { val config = buildSelfHostedConfig() - assertThat(config.editorAssetsEndpoint).isNull() + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://mysite.com/wp-json/wpcom/v2/editor-assets" + ) } // --- Application password overrides WPCom REST API --- @@ -389,10 +403,10 @@ class GutenbergKitSettingsBuilderTest { @Test fun `app password forces non-WPCom API even if site uses WPCom REST`() { val site = SiteModel().apply { - url = "https://mysite.wordpress.com" + url = "https://mysite.com" siteId = 123L - setIsWPCom(true) - setIsJetpackConnected(false) + setIsWPCom(false) + setIsJetpackConnected(true) origin = SiteModel.ORIGIN_WPCOM_REST wpApiRestUrl = "https://mysite.com/wp-json/" apiRestPasswordPlain = "app_pass" @@ -556,10 +570,12 @@ class GutenbergKitSettingsBuilderTest { } @Test - fun `editor assets endpoint is null for non-WPCom site`() { + fun `editor assets endpoint for non-WPCom site uses API root`() { val config = buildSelfHostedConfig() - assertThat(config.editorAssetsEndpoint).isNull() + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://mysite.com/wp-json/wpcom/v2/editor-assets" + ) } // ===== buildSiteApiNamespace edge cases ===== @@ -620,6 +636,66 @@ class GutenbergKitSettingsBuilderTest { assertThat(config.postId).isEqualTo(42) } + // ===== SiteSettingsProvider Tests ===== + + @Test + fun `theme styles defaults to true when settings are null`() { + whenever( + editorSettingsRepository + .getSupportsEditorSettingsForSite(any()) + ).thenReturn(true) + whenever( + siteSettingsProvider.getSettings(any()) + ).thenReturn(null) + + val config = buildWPComConfig() + + assertThat(config.themeStyles).isTrue() + } + + @Test + fun `third party blocks defaults to false when settings are null`() { + whenever( + editorSettingsRepository + .getSupportsEditorAssetsForSite(any()) + ).thenReturn(true) + whenever( + siteSettingsProvider.getSettings(any()) + ).thenReturn(null) + + val config = buildWPComConfig() + + assertThat(config.plugins).isFalse() + } + + @Test + fun `feature unsupported skips settings provider for theme styles`() { + whenever( + editorSettingsRepository + .getSupportsEditorSettingsForSite(any()) + ).thenReturn(false) + + val config = buildWPComConfig() + + assertThat(config.themeStyles).isFalse() + verify(siteSettingsProvider, never()) + .getSettings(any()) + } + + @Test + fun `feature unsupported skips settings provider for plugins`() { + whenever( + editorSettingsRepository + .getSupportsEditorAssetsForSite(any()) + ).thenReturn(false) + + val config = buildWPComConfig() + + assertThat(config.plugins).isFalse() + verify(siteSettingsProvider, never()) + .getSettings(any()) + } + // ===== Helpers ===== private fun buildWPComConfig( From af3039ce930418720a0958c2a1243ddc3a45d331 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:55:27 -0700 Subject: [PATCH 29/42] Extract shouldPreload method in GutenbergEditorPreloader Co-Authored-By: Claude Opus 4.6 --- .../android/ui/posts/GutenbergEditorPreloader.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index bfe92aa735e2..8b1f25f441c4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -81,11 +81,7 @@ class GutenbergEditorPreloader @Inject constructor( */ fun preloadIfNeeded(site: SiteModel, scope: CoroutineScope) { checkMainThread() - if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled() || - !SiteUtils.isBlockEditorDefaultForNewPost(site) || - site.id == lastPreloadedSiteLocalId || - preloadJob?.isActive == true - ) return + if (!shouldPreload(site)) return lastPreloadedSiteLocalId = site.id preloadJob = scope.launch(bgDispatcher) { @@ -148,4 +144,14 @@ class GutenbergEditorPreloader @Inject constructor( cachedDependencies = null lastPreloadedSiteLocalId = -1 } + + private fun shouldPreload(site: SiteModel): Boolean { + val isEnabled = + gutenbergKitFeatureChecker.isGutenbergKitEnabled() && + SiteUtils.isBlockEditorDefaultForNewPost(site) + val isAlreadyHandled = + site.id == lastPreloadedSiteLocalId || + preloadJob?.isActive == true + return isEnabled && !isAlreadyHandled + } } From 18d8baa20cde9f2f8c746d74b2df3b1b2913d069 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:55:52 -0700 Subject: [PATCH 30/42] Clean up EditorDependencyStore and WpApiClientProvider Remove unused dependencies field and read() method from EditorDependencyStore. Add checkstyle suppression comments for WpComDotOrgApiUrlResolver references in WpApiClientProvider. Co-Authored-By: Claude Opus 4.6 --- .../android/util/EditorDependencyStore.kt | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt index eded9fc1156c..331a32d3a83b 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt @@ -6,21 +6,18 @@ import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.services.EditorService -class EditorDependencyStore(val context: Context, val coroutineScope: CoroutineScope) { - var dependencies: EditorDependencies? = null - - suspend fun fetch(configuration: EditorConfiguration): EditorDependencies { +class EditorDependencyStore( + val context: Context, + val coroutineScope: CoroutineScope +) { + suspend fun fetch( + configuration: EditorConfiguration + ): EditorDependencies { val service = EditorService.create( context = context, configuration = configuration, coroutineScope = coroutineScope ) - - val dependencies = service.prepare(null) - return dependencies - } - - fun read(configuration: EditorConfiguration): EditorDependencies { - return EditorDependencies.empty + return service.prepare(null) } } From 11fca9dda5cc73b058aee1fa6ad0c94ed7a9bd5c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:00:46 -0700 Subject: [PATCH 31/42] Remove EditorDependencyStore and inline into GutenbergEditorPreloader EditorDependencyStore was a stateless wrapper around EditorService with a single call site. Inline the two calls directly and delete the class. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergEditorPreloader.kt | 10 +++++--- .../android/util/EditorDependencyStore.kt | 23 ------------------- 2 files changed, 7 insertions(+), 26 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 8b1f25f441c4..9eaff90d040d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -13,9 +13,9 @@ import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog -import org.wordpress.android.util.EditorDependencyStore import org.wordpress.android.util.SiteUtils import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.services.EditorService import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -93,8 +93,12 @@ class GutenbergEditorPreloader @Inject constructor( site = site, accessToken = accountStore.accessToken ) - val store = EditorDependencyStore(appContext, scope) - val result = store.fetch(config) + val service = EditorService.create( + context = appContext, + configuration = config, + coroutineScope = scope + ) + val result = service.prepare(null) if (isActive) { cachedDependencies = result } diff --git a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt b/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt deleted file mode 100644 index 331a32d3a83b..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/util/EditorDependencyStore.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.wordpress.android.util - -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import org.wordpress.gutenberg.model.EditorConfiguration -import org.wordpress.gutenberg.model.EditorDependencies -import org.wordpress.gutenberg.services.EditorService - -class EditorDependencyStore( - val context: Context, - val coroutineScope: CoroutineScope -) { - suspend fun fetch( - configuration: EditorConfiguration - ): EditorDependencies { - val service = EditorService.create( - context = context, - configuration = configuration, - coroutineScope = coroutineScope - ) - return service.prepare(null) - } -} From d18dab2ed3f3d610e11c2cc8077251890fef4126 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:41:44 -0700 Subject: [PATCH 32/42] Refactor GutenbergEditorPreloader for multi-site caching Replace single-site fields with a ConcurrentHashMap of sealed PreloadState entries (Loading | Ready) keyed by site local ID. This fixes a bug where switching sites discarded previously preloaded dependencies, and eliminates the need for withContext(mainDispatcher) thread-hopping. Add @MainThread annotations to all public methods so Android Lint flags any call-site violations. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergEditorPreloader.kt | 126 ++++++++++-------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 9eaff90d040d..5259585947dc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -1,12 +1,11 @@ package org.wordpress.android.ui.posts import android.content.Context -import android.os.Looper +import androidx.annotation.MainThread import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore @@ -16,6 +15,7 @@ import org.wordpress.android.util.AppLog import org.wordpress.android.util.SiteUtils import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.services.EditorService +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -24,33 +24,36 @@ import javax.inject.Singleton * Opportunistically preloads GutenbergKit editor dependencies in the * background so the editor opens faster. * + * Cached dependencies are keyed by site local ID, so switching + * between sites does not discard previously preloaded results. + * * ## Usage * * - [preloadIfNeeded] — idempotent; call whenever a site becomes * visible. Skips work if the site was already preloaded or a job * is in flight. - * - [refreshPreloading] — discards any cached result and - * re-preloads from scratch (e.g. on pull-to-refresh). - * - [getDependencies] — returns the cached result, or `null` if - * preloading has not completed. Callers must handle `null` - * gracefully by loading dependencies themselves. - * - [clear] — cancels in-flight work and releases cached data. - * Call when the driving scope is being destroyed. + * - [refreshPreloading] — discards the cached result for a site + * and re-preloads from scratch (e.g. on pull-to-refresh). + * - [getDependencies] — returns the cached result for a site, or + * `null` if preloading has not completed. Callers must handle + * `null` gracefully by loading dependencies themselves. + * - [clear] — cancels all in-flight work and releases all cached + * data. Call when the driving scope is being destroyed. * * ## Threading * - * [preloadIfNeeded], [refreshPreloading], and [clear] must be - * called from the main thread. The background coroutine writes - * [cachedDependencies] on [bgDispatcher]; the field is - * [@Volatile] so the main-thread read in [getDependencies] has a - * happens-before guarantee. + * Public methods are annotated [@MainThread] and must only be + * called from the main thread. [state] is a [ConcurrentHashMap], + * so the background coroutine can safely write [Ready] or remove + * entries without thread-hopping. * * ## Deduplication * - * Preloading is skipped when the site's **local** database ID - * ([SiteModel.id]) matches the last successfully preloaded site, - * or when a preload job is already in flight. On failure the guard - * is reset so the next visit retries automatically. + * Preloading is skipped when the site already has a cached result + * or an in-flight job. On failure the entry is removed so the + * next visit retries automatically. If a caller's coroutine scope + * is cancelled externally, [shouldPreload] detects the dead + * [Loading] entry and allows a fresh attempt. */ @Singleton class GutenbergEditorPreloader @Inject constructor( @@ -61,30 +64,30 @@ class GutenbergEditorPreloader @Inject constructor( private val editorSettingsRepository: EditorSettingsRepository, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) { - private fun checkMainThread() = check( - Looper.getMainLooper() == Looper.myLooper() - ) { "Must be called from the main thread" } - - private var lastPreloadedSiteLocalId: Int = -1 - private var preloadJob: Job? = null + private sealed class PreloadState { + data class Loading(val job: Job) : PreloadState() + data class Ready( + val dependencies: EditorDependencies + ) : PreloadState() + } - @Volatile - private var cachedDependencies: EditorDependencies? = null + private val state = ConcurrentHashMap() /** * Starts a background preload for [site] if one hasn't already - * been performed for this site and no job is currently in flight. + * been performed for this site and no job is currently in + * flight for it. * * [scope] is the caller's [CoroutineScope] (typically * `viewModelScope`); the launched coroutine is cancelled when * that scope is cancelled. */ + @MainThread fun preloadIfNeeded(site: SiteModel, scope: CoroutineScope) { - checkMainThread() if (!shouldPreload(site)) return - lastPreloadedSiteLocalId = site.id - preloadJob = scope.launch(bgDispatcher) { + val siteId = site.id + val job = scope.launch(bgDispatcher) { try { editorSettingsRepository .fetchEditorCapabilitiesForSite(site) @@ -99,63 +102,76 @@ class GutenbergEditorPreloader @Inject constructor( coroutineScope = scope ) val result = service.prepare(null) - if (isActive) { - cachedDependencies = result - } + state[siteId] = PreloadState.Ready(result) AppLog.d( AppLog.T.EDITOR, "Editor dependencies preloaded for" + " site ${site.name}" ) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception + ) { AppLog.e( AppLog.T.EDITOR, "Failed to preload editor dependencies", e ) - cachedDependencies = null - lastPreloadedSiteLocalId = -1 + state.remove(siteId) } } + state[siteId] = PreloadState.Loading(job) } /** - * Discards any cached result and re-preloads dependencies for - * [site] from scratch. Use for pull-to-refresh or any scenario - * where the caller wants to force a fresh fetch. + * Discards any cached result for [site] and re-preloads from + * scratch. Use for pull-to-refresh or any scenario where the + * caller wants to force a fresh fetch. */ + @MainThread fun refreshPreloading(site: SiteModel, scope: CoroutineScope) { - checkMainThread() - clear() + clearSite(site) preloadIfNeeded(site, scope) } /** - * Returns the preloaded dependencies, or `null` if preloading - * has not completed (or failed). Callers must handle `null` - * gracefully by loading dependencies themselves. + * Returns the preloaded dependencies for [site], or `null` if + * preloading has not completed (or failed). Callers must handle + * `null` gracefully by loading dependencies themselves. */ - fun getDependencies(): EditorDependencies? = cachedDependencies + @MainThread + fun getDependencies(site: SiteModel): EditorDependencies? = + getDependencies(site.id) + + @MainThread + fun getDependencies(siteLocalId: Int): EditorDependencies? = + (state[siteLocalId] as? PreloadState.Ready)?.dependencies /** - * Cancels any in-flight preload and discards cached results. - * Call when the driving scope is being destroyed. + * Cancels all in-flight preloads and discards all cached + * results. Call when the driving scope is being destroyed. */ + @MainThread fun clear() { - checkMainThread() - preloadJob?.cancel() - preloadJob = null - cachedDependencies = null - lastPreloadedSiteLocalId = -1 + state.values.forEach { entry -> + if (entry is PreloadState.Loading) entry.job.cancel() + } + state.clear() + } + + private fun clearSite(site: SiteModel) { + val entry = state.remove(site.id) + if (entry is PreloadState.Loading) entry.job.cancel() } private fun shouldPreload(site: SiteModel): Boolean { val isEnabled = gutenbergKitFeatureChecker.isGutenbergKitEnabled() && SiteUtils.isBlockEditorDefaultForNewPost(site) - val isAlreadyHandled = - site.id == lastPreloadedSiteLocalId || - preloadJob?.isActive == true + val isAlreadyHandled = when (val entry = state[site.id]) { + is PreloadState.Loading -> entry.job.isActive + is PreloadState.Ready -> true + null -> false + } return isEnabled && !isAlreadyHandled } } From 103ef6112efee10a594c0d0e11bc27bcc505d044 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:42:57 -0700 Subject: [PATCH 33/42] Pass SiteModel through GutenbergKitEditorFragment.newInstance Accept SiteModel instead of nothing, store the site local ID as a fragment argument, and use it to look up preloaded dependencies from the multi-site cache in GutenbergEditorPreloader. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/posts/GutenbergKitActivity.kt | 3 ++- .../editor/GutenbergKitEditorFragment.kt | 20 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 7b48ab09613a..dade47fd3d6d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -2215,7 +2215,8 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene val configuration = buildEditorConfiguration(siteModel, post) return GutenbergKitEditorFragment.newInstance( - configuration + configuration, + siteModel ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 1fa8b2888e1a..25fc7d0ad9a4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -26,6 +26,7 @@ import org.wordpress.android.editor.EditorEditMediaListener import org.wordpress.android.editor.EditorFragmentAbstract import org.wordpress.android.editor.EditorImagePreviewListener import org.wordpress.android.editor.LiveTextWatcher +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.util.AppLog import org.wordpress.android.util.PermissionUtils @@ -160,10 +161,12 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { ) ) + val siteLocalId = requireArguments().getInt(ARG_SITE_LOCAL_ID) val gutenbergView = GutenbergView( configuration = configuration, - dependencies = gutenbergEditorPreloader.getDependencies(), - coroutineScope = this.lifecycleScope, + dependencies = gutenbergEditorPreloader + .getDependencies(siteLocalId), + coroutineScope = this.lifecycleScope, context = requireContext() ) @@ -474,17 +477,24 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { private const val GUTENBERG_EDITOR_NAME = "gutenberg" private const val KEY_HTML_MODE_ENABLED = "KEY_HTML_MODE_ENABLED" const val ARG_FEATURED_IMAGE_ID: String = "featured_image_id" - const val ARG_GUTENBERG_KIT_SETTINGS: String = "gutenberg_kit_settings" + const val ARG_GUTENBERG_KIT_SETTINGS: String = + "gutenberg_kit_settings" + private const val ARG_SITE_LOCAL_ID = "site_local_id" private const val CAPTURE_PHOTO_PERMISSION_REQUEST_CODE = 101 private const val CAPTURE_VIDEO_PERMISSION_REQUEST_CODE = 102 fun newInstance( - configuration: EditorConfiguration + configuration: EditorConfiguration, + site: SiteModel ): GutenbergKitEditorFragment { val fragment = GutenbergKitEditorFragment() val args = Bundle() - args.putParcelable(ARG_GUTENBERG_KIT_SETTINGS, configuration) + args.putParcelable( + ARG_GUTENBERG_KIT_SETTINGS, + configuration + ) + args.putInt(ARG_SITE_LOCAL_ID, site.id) fragment.arguments = args return fragment } From 07b3d6beced6e0a4abdaa7ac5f598fb1df36197b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:45:05 -0700 Subject: [PATCH 34/42] Remove unused SiteSettingsModel import from test Co-Authored-By: Claude Opus 4.6 --- .../android/ui/posts/GutenbergKitSettingsBuilderTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index 4cd4a4fbc40c..73a978d89d8e 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -12,7 +12,6 @@ import org.mockito.kotlin.whenever import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.models.SiteSettingsModel import org.wordpress.android.repositories.EditorSettingsRepository @RunWith(MockitoJUnitRunner::class) From f55ad9752a7c3eb5d7c7fc05be337f6c7c92d510 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:36:23 -0700 Subject: [PATCH 35/42] Move isBlockEditorDefault into SiteSettingsProvider Inline the logic from SiteUtils.isBlockEditorDefaultForNewPost into SiteSettingsProviderImpl so the preloader can use it via injection rather than a static call. Deprecate the old static methods and suppress warnings at the three remaining call sites. Co-Authored-By: Claude Opus 4.6 --- .../android/datasets/SiteSettingsProvider.kt | 3 ++- .../android/datasets/SiteSettingsProviderImpl.kt | 11 +++++++++++ .../android/ui/posts/EditPostCustomerSupportHelper.kt | 1 + .../org/wordpress/android/ui/posts/EditorLauncher.kt | 1 + .../java/org/wordpress/android/util/SiteUtils.java | 8 ++++++++ .../viewmodel/mlp/ModalLayoutPickerViewModel.kt | 1 + 6 files changed, 24 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt index 07aa4f92e09e..6688e4593a60 100644 --- a/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt +++ b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt @@ -4,8 +4,9 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.models.SiteSettingsModel /** - * Provides [SiteSettingsModel] for a given site. + * Provides site-level settings for a given [SiteModel]. */ interface SiteSettingsProvider { fun getSettings(site: SiteModel): SiteSettingsModel? + fun isBlockEditorDefault(site: SiteModel): Boolean } diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt index a4ca212dadf6..562d8368536f 100644 --- a/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt @@ -21,4 +21,15 @@ class SiteSettingsProviderImpl @Inject constructor() : } } } + + override fun isBlockEditorDefault(site: SiteModel): Boolean { + val editor = site.mobileEditor + if (editor.isNullOrEmpty()) return true + val isWpComSimple = site.isWPCom && !site.isWPComAtomic + return isWpComSimple || editor == GUTENBERG_EDITOR_NAME + } + + private companion object { + const val GUTENBERG_EDITOR_NAME = "gutenberg" + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt index 774de5cee8e3..82560ad9cbea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt @@ -37,6 +37,7 @@ object EditPostCustomerSupportHelper { private fun getTagsList(site: SiteModel): List? = // Append the "mobile_gutenberg_is_default" tag if gutenberg is set to default for new posts + @Suppress("DEPRECATION") if (SiteUtils.isBlockEditorDefaultForNewPost(site)) { listOf(ZendeskExtraTags.gutenbergIsDefault) } else { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt index 3206849776e2..c1e024664d42 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt @@ -156,6 +156,7 @@ class EditorLauncher @Inject constructor( site: SiteModel ) { val hasGutenbergBlocks = PostUtils.contentContainsGutenbergBlocks(postContent) + @Suppress("DEPRECATION") val isBlockEditorDefaultForNewPosts = SiteUtils.isBlockEditorDefaultForNewPost(site) val postInfo = if (post != null) { diff --git a/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java b/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java index a16b615d0b94..2b01cb78d876 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java +++ b/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java @@ -206,6 +206,10 @@ public static void disableBlockEditor(Dispatcher dispatcher, SiteModel siteModel new DesignateMobileEditorPayload(siteModel, AZTEC_EDITOR_NAME))); } + /** + * @deprecated Use {@code SiteSettingsProvider.isBlockEditorDefault()} instead. + */ + @Deprecated public static boolean isBlockEditorDefaultForNewPost(@Nullable SiteModel site) { if (site == null) { return true; @@ -218,6 +222,10 @@ public static boolean isBlockEditorDefaultForNewPost(@Nullable SiteModel site) { } } + /** + * @deprecated Use {@code SiteSettingsProvider.isBlockEditorDefault()} instead. + */ + @Deprecated public static boolean alwaysDefaultToGutenberg(SiteModel site) { return site.isWPCom() && !site.isWPComAtomic(); } diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt index e4e475f05276..ce3931a01adc 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt @@ -144,6 +144,7 @@ class ModalLayoutPickerViewModel @Inject constructor( * at this point the only requirement is to have the block editor enabled * @return true if the Modal Layout Picker can be shown */ + @Suppress("DEPRECATION") fun canShowModalLayoutPicker() = SiteUtils.isBlockEditorDefaultForNewPost(site) /** From 8fbcc8445c15985ff19d8f3753d4727a0cf11265 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:36:51 -0700 Subject: [PATCH 36/42] Extract EditorServiceProvider to make preloader testable Wrap the static EditorService.create() + prepare() calls behind an injectable interface so GutenbergEditorPreloader can be tested with a mock. Wire the binding through Hilt in PostModule. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/android/modules/PostModule.kt | 8 ++++++ .../android/ui/posts/EditorServiceProvider.kt | 19 ++++++++++++++ .../ui/posts/EditorServiceProviderImpl.kt | 26 +++++++++++++++++++ .../ui/posts/GutenbergEditorPreloader.kt | 10 +++---- 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt diff --git a/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt index 71ec39788245..c119306d5dca 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt +++ b/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt @@ -6,6 +6,8 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.datasets.SiteSettingsProviderImpl +import org.wordpress.android.ui.posts.EditorServiceProvider +import org.wordpress.android.ui.posts.EditorServiceProviderImpl import org.wordpress.android.ui.posts.IPostFreshnessChecker import org.wordpress.android.ui.posts.PostFreshnessCheckerImpl import javax.inject.Singleton @@ -23,4 +25,10 @@ class PostModule { fun provideSiteSettingsProvider( impl: SiteSettingsProviderImpl ): SiteSettingsProvider = impl + + @Singleton + @Provides + fun provideEditorServiceProvider( + impl: EditorServiceProviderImpl + ): EditorServiceProvider = impl } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt new file mode 100644 index 000000000000..99869816e25c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies + +/** + * Abstracts the creation and preparation of the GutenbergKit + * [EditorService] so callers can be tested without the real + * service. + */ +interface EditorServiceProvider { + suspend fun prepare( + context: Context, + configuration: EditorConfiguration, + coroutineScope: CoroutineScope + ): EditorDependencies +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt new file mode 100644 index 000000000000..5f5b744e8b70 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.services.EditorService +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EditorServiceProviderImpl @Inject constructor() : + EditorServiceProvider { + override suspend fun prepare( + context: Context, + configuration: EditorConfiguration, + coroutineScope: CoroutineScope + ): EditorDependencies { + val service = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + return service.prepare(null) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 5259585947dc..00cba88ced3e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -7,14 +7,13 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog -import org.wordpress.android.util.SiteUtils import org.wordpress.gutenberg.model.EditorDependencies -import org.wordpress.gutenberg.services.EditorService import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Named @@ -61,6 +60,8 @@ class GutenbergEditorPreloader @Inject constructor( private val accountStore: AccountStore, private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, private val gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder, + private val siteSettingsProvider: SiteSettingsProvider, + private val editorServiceProvider: EditorServiceProvider, private val editorSettingsRepository: EditorSettingsRepository, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) { @@ -96,12 +97,11 @@ class GutenbergEditorPreloader @Inject constructor( site = site, accessToken = accountStore.accessToken ) - val service = EditorService.create( + val result = editorServiceProvider.prepare( context = appContext, configuration = config, coroutineScope = scope ) - val result = service.prepare(null) state[siteId] = PreloadState.Ready(result) AppLog.d( AppLog.T.EDITOR, @@ -166,7 +166,7 @@ class GutenbergEditorPreloader @Inject constructor( private fun shouldPreload(site: SiteModel): Boolean { val isEnabled = gutenbergKitFeatureChecker.isGutenbergKitEnabled() && - SiteUtils.isBlockEditorDefaultForNewPost(site) + siteSettingsProvider.isBlockEditorDefault(site) val isAlreadyHandled = when (val entry = state[site.id]) { is PreloadState.Loading -> entry.job.isActive is PreloadState.Ready -> true From d4715d679d49c56d16bcf776f9a2edf182ab86f7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:37:43 -0700 Subject: [PATCH 37/42] Add GutenbergEditorPreloaderTest 19 tests covering: feature gating, successful preload, failure cleanup, deduplication (completed and in-flight), scope cancellation recovery, multi-site caching, refresh, and clear semantics. Co-Authored-By: Claude Opus 4.6 --- .../ui/posts/GutenbergEditorPreloaderTest.kt | 464 ++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt new file mode 100644 index 000000000000..bbcc17202ca7 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -0,0 +1,464 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.gutenberg.model.EditorDependencies + +@ExperimentalCoroutinesApi +class GutenbergEditorPreloaderTest : + BaseUnitTest(StandardTestDispatcher()) { + @Mock + lateinit var appContext: Context + + @Mock + lateinit var accountStore: AccountStore + + @Mock + lateinit var gutenbergKitFeatureChecker: GutenbergKitFeatureChecker + + @Mock + lateinit var gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder + + @Mock + lateinit var siteSettingsProvider: SiteSettingsProvider + + @Mock + lateinit var editorServiceProvider: EditorServiceProvider + + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + + @Mock + lateinit var editorDependencies: EditorDependencies + + private lateinit var preloader: GutenbergEditorPreloader + + private fun createSite(id: Int = 1): SiteModel { + val site = SiteModel() + site.id = id + site.name = "Site $id" + return site + } + + @Before + fun setUp() { + preloader = GutenbergEditorPreloader( + appContext = appContext, + accountStore = accountStore, + gutenbergKitFeatureChecker = gutenbergKitFeatureChecker, + gutenbergKitSettingsBuilder = gutenbergKitSettingsBuilder, + siteSettingsProvider = siteSettingsProvider, + editorServiceProvider = editorServiceProvider, + editorSettingsRepository = editorSettingsRepository, + bgDispatcher = testDispatcher() + ) + } + + private fun enablePreloading(site: SiteModel) { + whenever( + gutenbergKitFeatureChecker.isGutenbergKitEnabled() + ).thenReturn(true) + whenever( + siteSettingsProvider.isBlockEditorDefault(site) + ).thenReturn(true) + } + + private fun stubSuccessfulPreload() { + whenever( + gutenbergKitSettingsBuilder.buildPostConfiguration( + site = any(), + post = anyOrNull(), + accessToken = anyOrNull() + ) + ).thenReturn(mock()) + } + + private suspend fun stubEditorService() { + whenever( + editorServiceProvider.prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + ).thenReturn(editorDependencies) + } + + // region getDependencies + + @Test + fun `getDependencies returns null when nothing preloaded`() { + val site = createSite() + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `getDependencies by ID returns null when nothing preloaded`() { + assertThat(preloader.getDependencies(99)).isNull() + } + + // endregion + + // region preloadIfNeeded — gating + + @Test + fun `skips preload when feature is disabled`() = test { + val site = createSite() + whenever( + gutenbergKitFeatureChecker.isGutenbergKitEnabled() + ).thenReturn(false) + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + verify(editorServiceProvider, never()).prepare( + context = any(), + configuration = any(), + coroutineScope = any() + ) + } + + @Test + fun `skips preload when block editor is not default`() = test { + val site = createSite() + whenever( + gutenbergKitFeatureChecker.isGutenbergKitEnabled() + ).thenReturn(true) + whenever( + siteSettingsProvider.isBlockEditorDefault(site) + ).thenReturn(false) + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + verify(editorServiceProvider, never()).prepare( + context = any(), + configuration = any(), + coroutineScope = any() + ) + } + + // endregion + + // region preloadIfNeeded — success + + @Test + fun `successful preload caches dependencies`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(editorDependencies) + } + + @Test + fun `successful preload fetches editor capabilities`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + verify(editorSettingsRepository) + .fetchEditorCapabilitiesForSite(site) + } + + @Test + fun `getDependencies by ID returns cached result`() = test { + val site = createSite(id = 42) + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(42)) + .isSameAs(editorDependencies) + } + + // endregion + + // region preloadIfNeeded — failure + + @Test + fun `failed preload removes entry`() = test { + val site = createSite() + enablePreloading(site) + whenever( + gutenbergKitSettingsBuilder.buildPostConfiguration( + site = any(), + post = anyOrNull(), + accessToken = anyOrNull() + ) + ).thenThrow(RuntimeException("network error")) + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + } + + // endregion + + // region deduplication + + @Test + fun `second preload for same site is skipped`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + verify(editorServiceProvider).prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + } + + @Test + fun `in-flight preload blocks duplicate request`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + // Job is in-flight — don't advance + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + verify(editorServiceProvider).prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + } + + @Test + fun `getDependencies returns null while preload is in-flight`() = + test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + // Job is in-flight — don't advance + + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `cancelled scope allows fresh preload attempt`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + // Launch in a separate scope and cancel it + val expendableScope = TestScope(testDispatcher()) + preloader.preloadIfNeeded(site, expendableScope) + expendableScope.cancel() + + // The dead Loading entry should not block a retry + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(editorDependencies) + } + + // endregion + + // region multi-site caching + + @Test + fun `preloading site B does not discard site A`() = test { + val siteA = createSite(id = 1) + val siteB = createSite(id = 2) + enablePreloading(siteA) + enablePreloading(siteB) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(siteA, this) + advanceUntilIdle() + preloader.preloadIfNeeded(siteB, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(siteA)) + .isSameAs(editorDependencies) + assertThat(preloader.getDependencies(siteB)) + .isSameAs(editorDependencies) + } + + @Test + fun `concurrent in-flight preloads for different sites`() = + test { + val siteA = createSite(id = 1) + val siteB = createSite(id = 2) + enablePreloading(siteA) + enablePreloading(siteB) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(siteA, this) + preloader.preloadIfNeeded(siteB, this) + // Both in-flight — now advance + advanceUntilIdle() + + assertThat(preloader.getDependencies(siteA)) + .isSameAs(editorDependencies) + assertThat(preloader.getDependencies(siteB)) + .isSameAs(editorDependencies) + } + + // endregion + + // region refreshPreloading + + @Test + fun `refresh discards cached result and re-preloads`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + // Now make the service return a different result + val freshDependencies = org.mockito.Mockito.mock( + EditorDependencies::class.java + ) + whenever( + editorServiceProvider.prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + ).thenReturn(freshDependencies) + + preloader.refreshPreloading(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(freshDependencies) + } + + @Test + fun `failed refresh removes previously cached result`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + assertThat(preloader.getDependencies(site)).isNotNull + + // Make the refresh fail + whenever( + gutenbergKitSettingsBuilder.buildPostConfiguration( + site = any(), + post = anyOrNull(), + accessToken = anyOrNull() + ) + ).thenThrow(RuntimeException("refresh failed")) + + preloader.refreshPreloading(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `refresh on never-preloaded site works`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.refreshPreloading(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(editorDependencies) + } + + // endregion + + // region clear + + @Test + fun `clear during in-flight preload discards result`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + // Job is in-flight — clear before it completes + preloader.clear() + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `clear removes all cached dependencies`() = test { + val siteA = createSite(id = 1) + val siteB = createSite(id = 2) + enablePreloading(siteA) + enablePreloading(siteB) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(siteA, this) + preloader.preloadIfNeeded(siteB, this) + advanceUntilIdle() + + preloader.clear() + + assertThat(preloader.getDependencies(siteA)).isNull() + assertThat(preloader.getDependencies(siteB)).isNull() + } + + // endregion + + private inline fun mock(): T = + org.mockito.Mockito.mock(T::class.java) +} From 4e756c4ab5e397f049367ff2dc0033d278eddedc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:58:11 -0700 Subject: [PATCH 38/42] Replace mock with real data class instance The custom DoNotMockDataClass lint rule flags mocking of data classes. Construct real ThemeWithEditContext instances via a buildTheme() helper instead. Co-Authored-By: Claude Opus 4.6 --- .../repositories/ThemeRepositoryTest.kt | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt index 61e159985ac4..542b8bd7123e 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt @@ -14,6 +14,14 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import rs.wordpress.api.kotlin.WpApiClient import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.ThemeAuthor +import uniffi.wp_api.ThemeAuthorUri +import uniffi.wp_api.ThemeDescription +import uniffi.wp_api.ThemeName +import uniffi.wp_api.ThemeStatus +import uniffi.wp_api.ThemeStylesheet +import uniffi.wp_api.ThemeTags +import uniffi.wp_api.ThemeUri import uniffi.wp_api.ThemeWithEditContext import uniffi.wp_api.ThemesRequestListWithEditContextResponse import uniffi.wp_api.WpNetworkHeaderMap @@ -47,7 +55,7 @@ class ThemeRepositoryTest : BaseUnitTest() { @Test fun `returns theme when API succeeds with non-empty list`() = runTest { - val theme = mock() + val theme = buildTheme(stylesheet = "twentytwentyfive") mockSuccessResponse(listOf(theme)) val result = repository.fetchCurrentTheme(testSite) @@ -68,8 +76,8 @@ class ThemeRepositoryTest : BaseUnitTest() { @Test fun `returns first theme when API returns multiple`() = runTest { - val first = mock() - val second = mock() + val first = buildTheme(stylesheet = "first") + val second = buildTheme(stylesheet = "second") mockSuccessResponse(listOf(first, second)) val result = repository.fetchCurrentTheme(testSite) @@ -106,4 +114,30 @@ class ThemeRepositoryTest : BaseUnitTest() { as WpRequestResult ) } + + private fun buildTheme( + stylesheet: String = "test-theme", + isBlockTheme: Boolean = false + ) = ThemeWithEditContext( + stylesheet = ThemeStylesheet(stylesheet), + template = stylesheet, + requiresPhp = "", + requiresWp = "", + textdomain = stylesheet, + version = "1.0", + screenshot = "", + author = ThemeAuthor(raw = "", rendered = ""), + authorUri = ThemeAuthorUri(raw = "", rendered = ""), + description = ThemeDescription( + raw = "", rendered = "" + ), + name = ThemeName(raw = stylesheet, rendered = stylesheet), + tags = ThemeTags(raw = emptyList(), rendered = ""), + themeUri = ThemeUri(raw = "", rendered = ""), + status = ThemeStatus.Active, + isBlockTheme = isBlockTheme, + stylesheetUri = "", + templateUri = "", + themeSupports = null + ) } From ed26a2bd7efa23abaf5153b1ad0a7b89a97e107f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:11:08 -0700 Subject: [PATCH 39/42] Add SiteSettingsProviderImplTest Six tests covering the isBlockEditorDefault decision matrix: null/empty editor, gutenberg editor, non-gutenberg on self-hosted, WPCom simple, and WPCom Atomic. Co-Authored-By: Claude Opus 4.6 --- .../datasets/SiteSettingsProviderImplTest.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/datasets/SiteSettingsProviderImplTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/datasets/SiteSettingsProviderImplTest.kt b/WordPress/src/test/java/org/wordpress/android/datasets/SiteSettingsProviderImplTest.kt new file mode 100644 index 000000000000..a7b15cc68714 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/datasets/SiteSettingsProviderImplTest.kt @@ -0,0 +1,77 @@ +package org.wordpress.android.datasets + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.model.SiteModel + +class SiteSettingsProviderImplTest { + private val provider = SiteSettingsProviderImpl() + + private fun site( + mobileEditor: String? = null, + isWPCom: Boolean = false, + isWPComAtomic: Boolean = false + ) = SiteModel().apply { + setMobileEditor(mobileEditor) + setIsWPCom(isWPCom) + setIsWPComAtomic(isWPComAtomic) + } + + @Test + fun `null editor defaults to block editor`() { + assertThat( + provider.isBlockEditorDefault(site(mobileEditor = null)) + ).isTrue() + } + + @Test + fun `empty editor defaults to block editor`() { + assertThat( + provider.isBlockEditorDefault(site(mobileEditor = "")) + ).isTrue() + } + + @Test + fun `gutenberg editor returns true`() { + assertThat( + provider.isBlockEditorDefault( + site(mobileEditor = "gutenberg") + ) + ).isTrue() + } + + @Test + fun `non-gutenberg editor on self-hosted returns false`() { + assertThat( + provider.isBlockEditorDefault( + site(mobileEditor = "aztec") + ) + ).isFalse() + } + + @Test + fun `non-gutenberg editor on WPCom simple returns true`() { + assertThat( + provider.isBlockEditorDefault( + site( + mobileEditor = "aztec", + isWPCom = true, + isWPComAtomic = false + ) + ) + ).isTrue() + } + + @Test + fun `non-gutenberg editor on WPCom Atomic returns false`() { + assertThat( + provider.isBlockEditorDefault( + site( + mobileEditor = "aztec", + isWPCom = true, + isWPComAtomic = true + ) + ) + ).isFalse() + } +} From 85dc17c06ba351e35c7b4d3bd747c426f58a20ab Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:36:07 -0700 Subject: [PATCH 40/42] Replace mock EditorDependencies with real data class instance DoNotMockDataClass lint rule flagged the @Mock annotation on EditorDependencies. Use EditorDependencies.empty for the shared field and construct a real instance for the refresh test. Co-Authored-By: Claude Opus 4.6 --- .../android/ui/posts/GutenbergEditorPreloaderTest.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt index bbcc17202ca7..5072ba52a430 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -20,7 +20,9 @@ import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.gutenberg.model.EditorAssetBundle import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorSettings @ExperimentalCoroutinesApi class GutenbergEditorPreloaderTest : @@ -46,8 +48,7 @@ class GutenbergEditorPreloaderTest : @Mock lateinit var editorSettingsRepository: EditorSettingsRepository - @Mock - lateinit var editorDependencies: EditorDependencies + private val editorDependencies = EditorDependencies.empty private lateinit var preloader: GutenbergEditorPreloader @@ -361,8 +362,10 @@ class GutenbergEditorPreloaderTest : advanceUntilIdle() // Now make the service return a different result - val freshDependencies = org.mockito.Mockito.mock( - EditorDependencies::class.java + val freshDependencies = EditorDependencies( + editorSettings = EditorSettings.undefined, + assetBundle = EditorAssetBundle.empty, + preloadList = null ) whenever( editorServiceProvider.prepare( From defe1c7b5cc2bf8ba31aa0ce76a9a84868d04a6e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:38:10 -0700 Subject: [PATCH 41/42] Update GutenbergKit to integrate/wp-android branch Point to GutenbergKit PR #316 build artifact for WP Android integration support. Co-Authored-By: Claude Opus 4.6 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b76bfab1ee1d..a7fa1db67037 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ google-play-review = '2.0.2' google-services = '4.4.4' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = 'v0.13.2' +gutenberg-kit = '316-34bc052ce3435b4189a4cbcec18d5d7b63ee34b3' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' From 2218b4c90e1b7fc5a4590b5e6b9b39c52aaf4be2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:23:08 -0600 Subject: [PATCH 42/42] work-in-progress --- .../repositories/EditorSettingsRepository.kt | 104 +++++++++++++++--- .../repositories/ThemeRepositoryTest.kt | 7 +- gradle/libs.versions.toml | 2 +- .../rest/wpapi/rs/WpApiClientProvider.kt | 71 +++++++++++- 4 files changed, 167 insertions(+), 17 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index 2b6d935b7af5..02522d0f8aa2 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -112,25 +112,103 @@ class EditorSettingsRepository @Inject constructor( } private suspend fun fetchRouteSupport(site: SiteModel) { + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: fetching routes" + + " isWPCom=${site.isWPCom}" + ) + + if (site.isWPCom || site.isUsingWpComRestApi) { + fetchRouteSupportViaManifest(site) + } else if ( + !site.apiRestUsernamePlain.isNullOrEmpty() && + !site.apiRestPasswordPlain.isNullOrEmpty() + ) { + fetchRouteSupportViaClient(site) + } else { + fetchRouteSupportViaSiteManifest(site) + } + } + + private suspend fun fetchRouteSupportViaManifest( + site: SiteModel + ) { + val routes = wpApiClientProvider + .fetchWpComManifestRoutes(site) + applyManifestRoutes(site, routes) + } + + private suspend fun fetchRouteSupportViaSiteManifest( + site: SiteModel + ) { + val routes = wpApiClientProvider + .fetchSiteManifestRoutes(site) + applyManifestRoutes(site, routes) + } + + private fun applyManifestRoutes( + site: SiteModel, + routes: Set? + ) { + if (routes != null) { + val supportsSettings = routes.any { + it.contains("wp-block-editor/") && + it.endsWith("/settings") + } + val supportsAssets = routes.any { + it.endsWith("/editor-assets") + } + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: manifest fetched" + + " for site=${site.name}" + + " routeCount=${routes.size}" + + " supportsEditorSettings=$supportsSettings" + + " supportsEditorAssets=$supportsAssets" + ) + appPrefsWrapper.setSiteSupportsEditorSettings( + site, supportsSettings + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, supportsAssets + ) + } else { + AppLog.w( + T.EDITOR, + "EditorSettingsRepository: manifest request" + + " failed for site=${site.name}" + ) + appPrefsWrapper.setSiteSupportsEditorSettings( + site, false + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, false + ) + } + } + + private suspend fun fetchRouteSupportViaClient( + site: SiteModel + ) { val client = wpApiClientProvider.getWpApiClient(site) val response = client.request { it.apiRoot().get() } when (response) { is WpRequestResult.Success -> { val data = response.response.data - val supportsSettings = data - .hasRoute("/wp-block-editor/v1/settings") - val supportsAssets = data - .hasRoute("/wpcom/v2/editor-assets") - + val supportsSettings = data.hasRoute( + "/wp-block-editor/v1/settings" + ) + val supportsAssets = data.hasRoute( + "/wpcom/v2/editor-assets" + ) AppLog.d( T.EDITOR, - "EditorSettingsRepository: API root fetched" + - " for site=${site.name}" + - " supportsEditorSettings=$supportsSettings" + - " supportsEditorAssets=$supportsAssets" + "EditorSettingsRepository: API root" + + " fetched for site=${site.name}" + + " supportsSettings=$supportsSettings" + + " supportsAssets=$supportsAssets" ) - appPrefsWrapper.setSiteSupportsEditorSettings( site, supportsSettings ) @@ -141,11 +219,11 @@ class EditorSettingsRepository @Inject constructor( else -> { AppLog.w( T.EDITOR, - "EditorSettingsRepository: API root request" + - " failed for site=${site.name}" + + "EditorSettingsRepository: API root" + + " request failed for" + + " site=${site.name}" + " response=$response" ) - appPrefsWrapper.setSiteSupportsEditorSettings( site, false ) diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt index 542b8bd7123e..7677153385d1 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt @@ -89,7 +89,9 @@ class ThemeRepositoryTest : BaseUnitTest() { fun `returns null on API error`() = runTest { val error = WpRequestResult.UnknownError( statusCode = 500u, - response = "Internal Server Error" + response = "Internal Server Error", + requestUrl = "https://test.wordpress.com/wp-json", + requestMethod = uniffi.wp_api.RequestMethod.GET ) whenever(wpApiClient.request(any())) .thenReturn(error) @@ -138,6 +140,7 @@ class ThemeRepositoryTest : BaseUnitTest() { isBlockTheme = isBlockTheme, stylesheetUri = "", templateUri = "", - themeSupports = null + themeSupports = null, + defaultTemplateTypes = emptyList() ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a7fa1db67037..5210d53beb25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ google-play-review = '2.0.2' google-services = '4.4.4' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = '316-34bc052ce3435b4189a4cbcec18d5d7b63ee34b3' +gutenberg-kit = '339-913043cb0ba486850c9cb90c72059b92c1900577' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt index e0b524143899..1cabf84bff96 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt @@ -48,7 +48,8 @@ class WpApiClientProvider @Inject constructor( site: SiteModel, uploadListener: WpRequestExecutor.UploadListener? = null ): WpApiClient = when { - site.isWPCom -> getWpComApiClient(site) + site.isWPCom || site.isUsingWpComRestApi -> + getWpComApiClient(site) // Skip caching when an upload listener is provided — // upload flows need a dedicated client with progress // callbacks. @@ -164,6 +165,74 @@ class WpApiClientProvider @Inject constructor( } } + /** + * Fetches the WP.com site manifest (API root index) and + * returns the set of route paths. The normal WP.com + * client's URL resolver only exposes one namespace, so + * we hit the public-api manifest endpoint directly. + * + * Returns `null` on failure. + */ + suspend fun fetchWpComManifestRoutes( + site: SiteModel + ): Set? { + val siteHost = URL(site.url).host + val manifestUrl = + "https://public-api.wordpress.com" + + "/wp-json/?rest_route=/sites/$siteHost" + val token = accountStore.accessToken ?: return null + return fetchManifestRoutes(manifestUrl, "Bearer $token") + } + + /** + * Fetches the site's API root index directly (no auth) + * and returns the set of route paths. + * + * Returns `null` on failure. + */ + suspend fun fetchSiteManifestRoutes( + site: SiteModel + ): Set? { + val manifestUrl = site.buildUrl() + return fetchManifestRoutes(manifestUrl) + } + + private suspend fun fetchManifestRoutes( + url: String, + authHeader: String? = null + ): Set? { + val request = okhttp3.Request.Builder() + .url(url) + .apply { + if (authHeader != null) { + addHeader("Authorization", authHeader) + } + } + .build() + val client = OkHttpClient.Builder().build() + return kotlinx.coroutines.withContext( + kotlinx.coroutines.Dispatchers.IO + ) { + try { + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val body = resp.body?.string() + ?: return@use null + val json = org.json.JSONObject(body) + val routes = + json.optJSONObject("routes") + ?: return@use null + routes.keys().asSequence().toSet() + } + } catch ( + @Suppress("TooGenericExceptionCaught") + e: Exception + ) { + null + } + } + } + fun getApiRootUrlFrom(site: SiteModel): String = site.buildUrl() private fun SiteModel.buildUrl(): String =