From 91dc79a7e276e59d30f81fbc4f67369c7f0efb08 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:48:38 -0600 Subject: [PATCH 01/13] Add site settings for third-party blocks and theme style gating Add a new "Use Third-Party Blocks" toggle in Site Settings, gated behind GutenbergKit and editor assets support. Enhance the existing "Use Theme Styles" toggle with contextual warnings when the site lacks editor settings support or uses a non-block theme. Includes SiteSettingsProvider interface for injectable access to site settings from the local DB, replacing static SiteUtils calls for block editor default detection. Co-Authored-By: Claude Opus 4.6 --- .../org/wordpress/android/WordPressDB.java | 5 +- .../android/datasets/SiteSettingsProvider.kt | 12 +++ .../datasets/SiteSettingsProviderImpl.kt | 35 +++++++++ .../android/models/SiteSettingsModel.java | 9 +++ .../wordpress/android/modules/PostModule.kt | 11 ++- .../wordpress/android/ui/prefs/AppPrefs.java | 58 ++++++++++++++ .../android/ui/prefs/AppPrefsWrapper.kt | 19 +++++ .../ui/prefs/SiteSettingsFragment.java | 35 ++++++++- .../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 | 7 +- WordPress/src/main/res/xml/site_settings.xml | 6 ++ .../datasets/SiteSettingsProviderImplTest.kt | 77 +++++++++++++++++++ 14 files changed, 281 insertions(+), 4 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 create mode 100644 WordPress/src/test/java/org/wordpress/android/datasets/SiteSettingsProviderImplTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/WordPressDB.java b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java index 1b5e7f751d83..83adadad0f3c 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 site setting + mDb.execSQL(SiteSettingsModel.ADD_USE_THIRD_PARTY_BLOCKS); } mDb.setVersion(DATABASE_VERSION); } 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..6688e4593a60 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProvider.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.datasets + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.models.SiteSettingsModel + +/** + * 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 new file mode 100644 index 000000000000..562d8368536f --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/datasets/SiteSettingsProviderImpl.kt @@ -0,0 +1,35 @@ +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 + } + } + } + + 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/models/SiteSettingsModel.java b/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java index 106d1a3a7473..20353a2dc9bc 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; 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, SparseArrayCompatwp_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 a05deea3b4fb..fa13e8ff6fc2 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -699,7 +699,12 @@ 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. + 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..fbe62957730e 100644 --- a/WordPress/src/main/res/xml/site_settings.xml +++ b/WordPress/src/main/res/xml/site_settings.xml @@ -138,6 +138,12 @@ android:summary="@string/site_settings_use_theme_styles_summary" android:title="@string/site_settings_use_theme_styles" /> + + 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 099c9a888bba2fd0f096d62580d1b987c492d611 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:52:45 -0600 Subject: [PATCH 02/13] Address review feedback for third-party blocks toggle Gate the "Use Third-Party Blocks" toggle behind the remote gutenberg_kit_plugins feature flag in addition to the existing GutenbergKit and editor assets checks. Also simplify the summary string by removing "and styles" per reviewer feedback. Co-Authored-By: Claude Opus 4.6 --- .../wordpress/android/ui/prefs/SiteSettingsFragment.java | 7 +++++-- WordPress/src/main/res/values/strings.xml | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) 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 310f873b87f2..c1a9a781a0e5 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 @@ -81,6 +81,7 @@ import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper; import org.wordpress.android.util.PlansConstants; import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker; +import org.wordpress.android.util.config.GutenbergKitPluginsFeature; import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation.ValidationType; import org.wordpress.android.ui.prefs.SiteSettingsFormatDialog.FormatType; import org.wordpress.android.ui.prefs.homepage.HomepageSettingsDialog; @@ -194,6 +195,7 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; + @Inject GutenbergKitPluginsFeature mGutenbergKitPluginsFeature; @Inject AppPrefsWrapper mAppPrefsWrapper; private BloggingRemindersViewModel mBloggingRemindersViewModel; @@ -1100,8 +1102,9 @@ public void initPreferences() { + getString(R.string.site_settings_use_theme_styles_not_block_theme)); } - // hide third-party blocks preference if GutenbergKit is not enabled - if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled()) { + // hide third-party blocks preference if GutenbergKit or plugins feature is not enabled + if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled() + || !mGutenbergKitPluginsFeature.isEnabled()) { WPPrefUtils.removePreference( this, R.string.pref_key_site_editor, R.string.pref_key_use_third_party_blocks); diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index fa13e8ff6fc2..e5a9dcf98a68 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -703,7 +703,7 @@ 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. + Load third-party blocks 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. From 9aca388464bdd5a8ac71047573b7fe02a1f718af Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:58:32 -0600 Subject: [PATCH 03/13] Add ThemeRepository and EditorSettingsRepository for capability detection Add ThemeRepository to fetch the active theme via WP API and determine if it is a block theme. Add EditorSettingsRepository to discover editor settings and editor assets route support via manifest/API root queries. Wire SiteSettingsFragment to use EditorSettingsRepository for gating theme styles and third-party blocks toggles. Also adds manifest route fetching methods to WpApiClientProvider for discovering available REST routes on WP.com and self-hosted sites. Co-Authored-By: Claude Opus 4.6 --- .../repositories/EditorSettingsRepository.kt | 189 ++++++++++++++++++ .../android/repositories/ThemeRepository.kt | 40 ++++ .../ui/prefs/SiteSettingsFragment.java | 9 +- .../repositories/ThemeRepositoryTest.kt | 146 ++++++++++++++ .../rest/wpapi/rs/WpApiClientProvider.kt | 62 ++++++ 5 files changed, 442 insertions(+), 4 deletions(-) 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 create mode 100644 WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.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..685d5b7761b0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -0,0 +1,189 @@ +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 +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.util.AppLog +import org.wordpress.android.util.AppLog.T +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, + private val themeRepository: ThemeRepository, + @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 [fetchEditorCapabilitiesForSite]. + */ + fun getSupportsEditorSettingsForSite(site: SiteModel): Boolean { + val hasExisting = + editorSettingsSqlUtils.getEditorSettingsForSite(site) != null + val cachedPref = + appPrefsWrapper.getSiteSupportsEditorSettings(site) + return hasExisting || cachedPref + } + + /** + * 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 = + appPrefsWrapper.getSiteSupportsEditorAssets(site) + + /** + * Returns whether the site's active theme is a block + * theme, based on a previously persisted result from + * [fetchEditorCapabilitiesForSite]. + */ + fun getThemeSupportsBlockStyles( + site: SiteModel + ): Boolean = + appPrefsWrapper.getSiteThemeIsBlockTheme(site) + + /** + * Queries the site's REST API to check whether the + * `wp-block-editor/v1/settings` and + * `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] return them + * synchronously on future calls. + */ + suspend fun fetchEditorCapabilitiesForSite( + site: SiteModel + ) = withContext(ioDispatcher) { + supervisorScope { + launch { fetchRouteSupport(site) } + launch { fetchThemeBlockStyleSupport(site) } + } + } + + private suspend fun fetchRouteSupport(site: SiteModel) { + 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") + } + appPrefsWrapper.setSiteSupportsEditorSettings( + site, supportsSettings + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, supportsAssets + ) + } else { + 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" + ) + appPrefsWrapper.setSiteSupportsEditorSettings( + site, supportsSettings + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, supportsAssets + ) + } + else -> { + appPrefsWrapper.setSiteSupportsEditorSettings( + site, false + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, false + ) + } + } + } + + 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/repositories/ThemeRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt new file mode 100644 index 000000000000..d5816f5b3d03 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/repositories/ThemeRepository.kt @@ -0,0 +1,40 @@ +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.firstOrNull() + else -> null + } + } +} 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 c1a9a781a0e5..ee6f8a038ff8 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 @@ -81,6 +81,7 @@ import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper; import org.wordpress.android.util.PlansConstants; import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker; +import org.wordpress.android.repositories.EditorSettingsRepository; import org.wordpress.android.util.config.GutenbergKitPluginsFeature; import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation.ValidationType; import org.wordpress.android.ui.prefs.SiteSettingsFormatDialog.FormatType; @@ -196,7 +197,7 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; @Inject GutenbergKitPluginsFeature mGutenbergKitPluginsFeature; - @Inject AppPrefsWrapper mAppPrefsWrapper; + @Inject EditorSettingsRepository mEditorSettingsRepository; private BloggingRemindersViewModel mBloggingRemindersViewModel; @@ -1091,12 +1092,12 @@ 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 (!mAppPrefsWrapper.getSiteSupportsEditorSettings(mSite)) { + } 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 (!mAppPrefsWrapper.getSiteThemeIsBlockTheme(mSite)) { + } 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)); @@ -1108,7 +1109,7 @@ public void initPreferences() { WPPrefUtils.removePreference( this, R.string.pref_key_site_editor, R.string.pref_key_use_third_party_blocks); - } else if (!mAppPrefsWrapper.getSiteSupportsEditorAssets(mSite)) { + } else if (!mEditorSettingsRepository.getSupportsEditorAssetsForSite(mSite)) { mUseThirdPartyBlocksPref.setEnabled(false); mUseThirdPartyBlocksPref.setSummary( getString(R.string.site_settings_use_third_party_blocks_summary) 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..7677153385d1 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt @@ -0,0 +1,146 @@ +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.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 + +@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 = buildTheme(stylesheet = "twentytwentyfive") + 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 = buildTheme(stylesheet = "first") + val second = buildTheme(stylesheet = "second") + 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", + requestUrl = "https://test.wordpress.com/wp-json", + requestMethod = uniffi.wp_api.RequestMethod.GET + ) + 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 + ) + } + + 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, + defaultTemplateTypes = emptyList() + ) +} 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 cf3f67009245..1980c8120583 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 @@ -166,6 +166,68 @@ class WpApiClientProvider @Inject constructor( } } + /** + * Fetches the WP.com site manifest (API root index) and + * returns the set of route paths. + * 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) + } + + @Suppress("TooGenericExceptionCaught") + 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 (e: Exception) { + null + } + } + } + fun getApiRootUrlFrom(site: SiteModel): String = site.buildUrl() private fun SiteModel.buildUrl(): String = From 4292ddb25ce92120afdb22c70f00e6fd199dbe69 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:01:12 -0600 Subject: [PATCH 04/13] Use WpApiClient apiRoot for route discovery instead of manifest fetching Remove fetchWpComManifestRoutes and fetchSiteManifestRoutes from WpApiClientProvider. EditorSettingsRepository now uses the standard WpApiClient.request { it.apiRoot().get() } for all site types, which already handles WP.com vs self-hosted URL resolution. Co-Authored-By: Claude Opus 4.6 --- .../repositories/EditorSettingsRepository.kt | 59 ------------------ .../rest/wpapi/rs/WpApiClientProvider.kt | 62 ------------------- 2 files changed, 121 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 685d5b7761b0..832c48b3c90d 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -81,65 +81,6 @@ class EditorSettingsRepository @Inject constructor( } private suspend fun fetchRouteSupport(site: SiteModel) { - 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") - } - appPrefsWrapper.setSiteSupportsEditorSettings( - site, supportsSettings - ) - appPrefsWrapper.setSiteSupportsEditorAssets( - site, supportsAssets - ) - } else { - 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() } 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 1980c8120583..cf3f67009245 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 @@ -166,68 +166,6 @@ class WpApiClientProvider @Inject constructor( } } - /** - * Fetches the WP.com site manifest (API root index) and - * returns the set of route paths. - * 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) - } - - @Suppress("TooGenericExceptionCaught") - 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 (e: Exception) { - null - } - } - } - fun getApiRootUrlFrom(site: SiteModel): String = site.buildUrl() private fun SiteModel.buildUrl(): String = From 3976d35cbb559abb7b20dfcac82c908fbab67c89 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:23:11 -0600 Subject: [PATCH 05/13] Fetch editor capabilities on My Site resume Call `EditorSettingsRepository.fetchEditorCapabilitiesForSite` when the user lands on My Site so the route-discovery and theme-detection results are available by the time they open Site Settings. On failure, a snackbar is shown and cached prefs retain their previous values so settings degrade to disabled rather than crashing. --- .../repositories/EditorSettingsRepository.kt | 118 ++++++++++++------ .../android/ui/mysite/MySiteViewModel.kt | 18 +++ WordPress/src/main/res/values/strings.xml | 1 + 3 files changed, 98 insertions(+), 39 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 832c48b3c90d..308062edcee6 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -1,5 +1,6 @@ package org.wordpress.android.repositories +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -71,60 +72,99 @@ class EditorSettingsRepository @Inject constructor( * [getThemeSupportsBlockStyles] return them * synchronously on future calls. */ + /** + * Returns `true` when both checks complete without + * transport-level failures. + */ suspend fun fetchEditorCapabilitiesForSite( site: SiteModel - ) = withContext(ioDispatcher) { + ): Boolean = withContext(ioDispatcher) { + var routeOk = true + var themeOk = true supervisorScope { - launch { fetchRouteSupport(site) } - launch { fetchThemeBlockStyleSupport(site) } + launch { + routeOk = fetchRouteSupport(site) + } + launch { + themeOk = + fetchThemeBlockStyleSupport(site) + } } + routeOk && themeOk } - private suspend fun fetchRouteSupport(site: SiteModel) { - val client = wpApiClientProvider.getWpApiClient(site) - val response = client.request { it.apiRoot().get() } + @Suppress("TooGenericExceptionCaught") + private suspend fun fetchRouteSupport( + site: SiteModel + ): Boolean = try { + 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" + if (response is WpRequestResult.Success) { + val data = response.response.data + appPrefsWrapper + .setSiteSupportsEditorSettings( + site, + data.hasRoute( + "/wp-block-editor/v1/settings" + ) ) - appPrefsWrapper.setSiteSupportsEditorSettings( - site, supportsSettings + appPrefsWrapper + .setSiteSupportsEditorAssets( + site, + data.hasRoute( + "/wpcom/v2/editor-assets" + ) ) - appPrefsWrapper.setSiteSupportsEditorAssets( - site, supportsAssets - ) - } - else -> { - appPrefsWrapper.setSiteSupportsEditorSettings( - site, false - ) - appPrefsWrapper.setSiteSupportsEditorAssets( - site, false - ) - } + true + } else { + false } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e( + T.EDITOR, + "Failed to fetch route support" + + " for site=${site.name}", + e + ) + false } + @Suppress("TooGenericExceptionCaught") private suspend fun fetchThemeBlockStyleSupport( site: SiteModel - ) { - val theme = themeRepository.fetchCurrentTheme(site) - val isBlockTheme = theme?.isBlockTheme ?: false - AppLog.d( + ): Boolean = try { + val theme = + themeRepository.fetchCurrentTheme(site) + if (theme != null) { + AppLog.d( + T.EDITOR, + "EditorSettingsRepository:" + + " theme fetched" + + " for site=${site.name}" + + " themeName=${theme.name}" + + " isBlockTheme=${theme.isBlockTheme}" + ) + appPrefsWrapper.setSiteThemeIsBlockTheme( + site, theme.isBlockTheme + ) + true + } else { + false + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e( T.EDITOR, - "EditorSettingsRepository: theme fetched" + - " for site=${site.name}" + - " themeName=${theme?.name}" + - " isBlockTheme=$isBlockTheme" - ) - appPrefsWrapper.setSiteThemeIsBlockTheme( - site, isBlockTheme + "Failed to fetch theme info" + + " for site=${site.name}", + e ) + false } } 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..32ec25da5ef4 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 @@ -45,6 +45,7 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper import org.wordpress.android.ui.utils.UiString +import org.wordpress.android.repositories.EditorSettingsRepository @Suppress("LargeClass", "LongMethod", "LongParameterList") class MySiteViewModel @Inject constructor( @@ -66,6 +67,7 @@ class MySiteViewModel @Inject constructor( private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice, private val gutenbergKitWarmupHelper: GutenbergKitWarmupHelper, private val siteCapabilityChecker: SiteCapabilityChecker, + private val editorSettingsRepository: EditorSettingsRepository, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() private val _onNavigation = MutableLiveData>() @@ -179,6 +181,22 @@ class MySiteViewModel @Inject constructor( selectedSiteRepository.updateSiteSettingsIfNecessary() selectedSiteRepository.getSelectedSite()?.let { buildDashboardOrSiteItems(it) + launch { + val ok = editorSettingsRepository + .fetchEditorCapabilitiesForSite(it) + if (!ok) { + _onSnackbarMessage.postValue( + Event( + SnackbarMessageHolder( + UiString.UiStringRes( + R.string + .site_settings_fetch_failed + ) + ) + ) + ) + } + } } ?: run { accountDataViewModelSlice.onResume() } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index e5a9dcf98a68..3982b7b66f51 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -705,6 +705,7 @@ Use Third-Party Blocks (Beta) Load third-party blocks from plugins installed on your site. Your site doesn\'t support loading third-party blocks in the editor. + Failed to fetch site settings – some editor functionality may be limited. Password updated To reconnect the app to your self-hosted site, enter the site\'s new password here. Homepage Settings From 8a8721529cac1d8fd18e8392ac2a1c8a3dde3694 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:36:51 -0600 Subject: [PATCH 06/13] Add EditorSettingsRepository tests 12 tests covering pref delegation, route discovery persistence, theme detection, API errors, transport-level error isolation (one fetch failing doesn't block the other), and the both-fail case. Also fix MySiteViewModelTest for new constructor param. --- .../EditorSettingsRepositoryTest.kt | 221 ++++++++++++++++++ .../android/ui/mysite/MySiteViewModelTest.kt | 7 + 2 files changed, 228 insertions(+) create mode 100644 WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt new file mode 100644 index 000000000000..cd136117e4f4 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt @@ -0,0 +1,221 @@ +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.never +import org.mockito.kotlin.verify +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 org.wordpress.android.ui.prefs.AppPrefsWrapper +import rs.wordpress.api.kotlin.WpApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.ApiRootRequestGetResponse +import uniffi.wp_api.WpApiDetails +import uniffi.wp_api.WpNetworkHeaderMap + +@ExperimentalCoroutinesApi +class EditorSettingsRepositoryTest : BaseUnitTest() { + @Mock + lateinit var wpApiClientProvider: WpApiClientProvider + + @Mock + lateinit var appPrefsWrapper: AppPrefsWrapper + + @Mock + lateinit var themeRepository: ThemeRepository + + @Mock + lateinit var wpApiClient: WpApiClient + + private lateinit var repository: EditorSettingsRepository + + private val testSite = SiteModel().apply { + id = 1 + url = "https://test.wordpress.com" + } + + @Before + fun setUp() { + whenever(wpApiClientProvider.getWpApiClient(testSite)) + .thenReturn(wpApiClient) + + repository = EditorSettingsRepository( + wpApiClientProvider = wpApiClientProvider, + appPrefsWrapper = appPrefsWrapper, + themeRepository = themeRepository, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `fetch persists true when routes are present`() = + runTest { + mockApiRootResponse( + hasEditorSettings = true, + hasEditorAssets = true + ) + mockThemeResponse(isBlockTheme = false) + + val result = + repository.fetchEditorCapabilitiesForSite( + testSite + ) + + assertThat(result).isTrue() + verify(appPrefsWrapper) + .setSiteSupportsEditorSettings(testSite, true) + verify(appPrefsWrapper) + .setSiteSupportsEditorAssets(testSite, true) + } + + @Test + fun `fetch does not persist routes on API error`() = runTest { + mockApiRootError() + mockThemeResponse(isBlockTheme = false) + + repository.fetchEditorCapabilitiesForSite(testSite) + + verify(appPrefsWrapper, never()) + .setSiteSupportsEditorSettings(any(), any()) + verify(appPrefsWrapper, never()) + .setSiteSupportsEditorAssets(any(), any()) + } + + @Test + fun `fetch does not persist block theme when theme is null`() = + runTest { + mockApiRootResponse( + hasEditorSettings = true, + hasEditorAssets = true + ) + whenever( + themeRepository.fetchCurrentTheme(testSite) + ).thenReturn(null) + + repository.fetchEditorCapabilitiesForSite( + testSite + ) + + verify(appPrefsWrapper, never()) + .setSiteThemeIsBlockTheme(any(), any()) + } + + @Test + fun `route failure does not prevent theme update`() = + runTest { + whenever(wpApiClient.request(any())) + .thenThrow(RuntimeException("network error")) + mockThemeResponse(isBlockTheme = true) + + val result = + repository.fetchEditorCapabilitiesForSite( + testSite + ) + + assertThat(result).isFalse() + verify(appPrefsWrapper) + .setSiteThemeIsBlockTheme(testSite, true) + } + + @Test + fun `theme failure does not prevent route update`() = + runTest { + mockApiRootResponse( + hasEditorSettings = true, + hasEditorAssets = true + ) + whenever( + themeRepository.fetchCurrentTheme(testSite) + ).thenThrow(RuntimeException("network error")) + + val result = + repository.fetchEditorCapabilitiesForSite( + testSite + ) + + assertThat(result).isFalse() + verify(appPrefsWrapper) + .setSiteSupportsEditorSettings(testSite, true) + verify(appPrefsWrapper) + .setSiteSupportsEditorAssets(testSite, true) + } + + @Test + fun `both failures returns false without writing prefs`() = + runTest { + whenever(wpApiClient.request(any())) + .thenThrow(RuntimeException("network error")) + whenever( + themeRepository.fetchCurrentTheme(testSite) + ).thenThrow(RuntimeException("network error")) + + val result = + repository.fetchEditorCapabilitiesForSite( + testSite + ) + + assertThat(result).isFalse() + verify(appPrefsWrapper, never()) + .setSiteSupportsEditorSettings(any(), any()) + verify(appPrefsWrapper, never()) + .setSiteSupportsEditorAssets(any(), any()) + verify(appPrefsWrapper, never()) + .setSiteThemeIsBlockTheme(any(), any()) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun mockApiRootResponse( + hasEditorSettings: Boolean, + hasEditorAssets: Boolean + ) { + val apiDetails = mock() + whenever( + apiDetails.hasRoute( + "/wp-block-editor/v1/settings" + ) + ).thenReturn(hasEditorSettings) + whenever( + apiDetails.hasRoute("/wpcom/v2/editor-assets") + ).thenReturn(hasEditorAssets) + + val response = ApiRootRequestGetResponse( + data = apiDetails, + headerMap = mock() + ) + whenever(wpApiClient.request(any())) + .thenReturn( + WpRequestResult.Success(response) + as WpRequestResult + ) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun mockApiRootError() { + val error = WpRequestResult.UnknownError( + statusCode = 500u, + response = "Internal Server Error", + requestUrl = "https://test.wordpress.com/wp-json", + requestMethod = uniffi.wp_api.RequestMethod.GET + ) + whenever(wpApiClient.request(any())) + .thenReturn(error) + } + + private suspend fun mockThemeResponse( + isBlockTheme: Boolean + ) { + val theme = mock() + whenever(theme.isBlockTheme).thenReturn(isBlockTheme) + whenever( + themeRepository.fetchCurrentTheme(testSite) + ).thenReturn(theme) + } +} 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..c6c0ffc6c628 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 @@ -14,6 +14,7 @@ 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.atLeastOnce import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -39,6 +40,7 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewModelSlice import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker +import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource @@ -103,6 +105,9 @@ class MySiteViewModelTest : BaseUnitTest() { @Mock lateinit var siteCapabilityChecker: SiteCapabilityChecker + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + private lateinit var viewModel: MySiteViewModel private lateinit var uiModels: MutableList private lateinit var snackbars: MutableList @@ -138,6 +143,7 @@ class MySiteViewModelTest : BaseUnitTest() { whenever(dashboardCardsViewModelSlice.uiModel).thenReturn(MutableLiveData()) whenever(dashboardItemsViewModelSlice.uiModel).thenReturn(MutableLiveData()) whenever(applicationPasswordViewModelSlice.uiModel).thenReturn(MutableLiveData()) + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenReturn(true) viewModel = MySiteViewModel( testDispatcher(), @@ -158,6 +164,7 @@ class MySiteViewModelTest : BaseUnitTest() { applicationPasswordViewModelSlice, gutenbergKitWarmupHelper, siteCapabilityChecker, + editorSettingsRepository, ) uiModels = mutableListOf() snackbars = mutableListOf() From 22e13f0796371453696c6827621bdc4bdc12262d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:42:46 -0600 Subject: [PATCH 07/13] Update wordpress-rs to PR #1285 Points at Automattic/wordpress-rs#1285 which fixes route discovery on WP.com simple sites so editor-assets and editor-settings routes appear in the API root response. Also adapts StatsDataSourceImpl to the new StatsUtmKeys API (String replaced with List). --- .../ui/newstats/datasource/StatsDataSourceImpl.kt | 15 ++++++++++++--- gradle/libs.versions.toml | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 1613250c7386..d5f7c02a0489 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -40,6 +40,7 @@ import uniffi.wp_api.SubscribersByUserTypeSortField import uniffi.wp_api.StatsEmailsSummaryParams import uniffi.wp_api.StatsEmailsSummaryPeriod import uniffi.wp_api.StatsEmailsSummarySortField +import uniffi.wp_api.StatsUtmKey import uniffi.wp_api.StatsUtmParams import uniffi.wp_api.WpApiParamOrder import org.wordpress.android.util.AppLog @@ -1356,7 +1357,7 @@ class StatsDataSourceImpl @Inject constructor( max: Int, queryTopPosts: Boolean ): UtmDataResult { - val key = keys.joinToString(",") + val utmKeys = keys.mapNotNull(::toStatsUtmKey) val params = StatsUtmParams( max = max.toUInt(), date = date, @@ -1367,14 +1368,14 @@ class StatsDataSourceImpl @Inject constructor( AppLog.d( T.STATS, "fetchUtm - siteId=$siteId, " + - "key=$key, date=$date, days=$days" + "keys=$keys, date=$date, days=$days" ) val result = getOrCreateClient() .request { requestBuilder -> requestBuilder.statsUtm() .getStatsUtm( wpComSiteId = siteId.toULong(), - statsUtmKeys = key, + statsUtmKeys = utmKeys, params = params ) } @@ -1413,6 +1414,14 @@ class StatsDataSourceImpl @Inject constructor( } } + private fun toStatsUtmKey(key: String): StatsUtmKey? = + when (key) { + "utm_source" -> StatsUtmKey.UTM_SOURCE + "utm_medium" -> StatsUtmKey.UTM_MEDIUM + "utm_campaign" -> StatsUtmKey.UTM_CAMPAIGN + else -> null + } + companion object { private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_FORBIDDEN = 403 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ab340d3e3f0..8538a6d2e17d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-0d94794142482d1b7f9395c0afef57ac991c452e' +wordpress-rs = '1285-b5fdc98b21df1e0ae990b9492cbea8c6e5fc4bb3' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 1dc337b51e71aa136b6e24022e737b4634021a82 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:04:05 -0600 Subject: [PATCH 08/13] Use hasRouteForEndpoint for WP.com route discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from hasRoute (exact path match) to hasRouteForEndpoint (namespace + endpoint) so that WP.com sites — whose route keys include a sites/{site_id} prefix — are correctly detected. Requires wordpress-rs PR #1285 which adds hasRouteForEndpoint to WpApiDetails and the ApiUrlResolver parameter. --- .../repositories/EditorSettingsRepository.kt | 14 ++++++++++---- .../EditorSettingsRepositoryTest.kt | 18 +++++++++++++++--- gradle/libs.versions.toml | 2 +- .../rest/wpapi/rs/WpApiClientProvider.kt | 13 +++++++++++++ 4 files changed, 39 insertions(+), 8 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 308062edcee6..36623c783d3d 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -99,6 +99,8 @@ class EditorSettingsRepository @Inject constructor( ): Boolean = try { val client = wpApiClientProvider.getWpApiClient(site) + val resolver = + wpApiClientProvider.getApiUrlResolver(site) val response = client.request { it.apiRoot().get() } @@ -107,15 +109,19 @@ class EditorSettingsRepository @Inject constructor( appPrefsWrapper .setSiteSupportsEditorSettings( site, - data.hasRoute( - "/wp-block-editor/v1/settings" + data.hasRouteForEndpoint( + resolver, + "/wp-block-editor/v1", + "settings" ) ) appPrefsWrapper .setSiteSupportsEditorAssets( site, - data.hasRoute( - "/wpcom/v2/editor-assets" + data.hasRouteForEndpoint( + resolver, + "/wp-block-editor/v1", + "assets" ) ) true diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt index cd136117e4f4..60683cc36ba1 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt @@ -18,6 +18,7 @@ import org.wordpress.android.ui.prefs.AppPrefsWrapper import rs.wordpress.api.kotlin.WpApiClient import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.ApiRootRequestGetResponse +import uniffi.wp_api.ApiUrlResolver import uniffi.wp_api.WpApiDetails import uniffi.wp_api.WpNetworkHeaderMap @@ -35,6 +36,9 @@ class EditorSettingsRepositoryTest : BaseUnitTest() { @Mock lateinit var wpApiClient: WpApiClient + @Mock + lateinit var apiUrlResolver: ApiUrlResolver + private lateinit var repository: EditorSettingsRepository private val testSite = SiteModel().apply { @@ -46,6 +50,8 @@ class EditorSettingsRepositoryTest : BaseUnitTest() { fun setUp() { whenever(wpApiClientProvider.getWpApiClient(testSite)) .thenReturn(wpApiClient) + whenever(wpApiClientProvider.getApiUrlResolver(testSite)) + .thenReturn(apiUrlResolver) repository = EditorSettingsRepository( wpApiClientProvider = wpApiClientProvider, @@ -178,12 +184,18 @@ class EditorSettingsRepositoryTest : BaseUnitTest() { ) { val apiDetails = mock() whenever( - apiDetails.hasRoute( - "/wp-block-editor/v1/settings" + apiDetails.hasRouteForEndpoint( + apiUrlResolver, + "/wp-block-editor/v1", + "settings" ) ).thenReturn(hasEditorSettings) whenever( - apiDetails.hasRoute("/wpcom/v2/editor-assets") + apiDetails.hasRouteForEndpoint( + apiUrlResolver, + "/wp-block-editor/v1", + "assets" + ) ).thenReturn(hasEditorAssets) val response = ApiRootRequestGetResponse( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8538a6d2e17d..fea666758b81 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '1285-b5fdc98b21df1e0ae990b9492cbea8c6e5fc4bb3' +wordpress-rs = '1285-59fdcdfba0de328157ae28f8bc2519560a0e2837' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' 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 cf3f67009245..5647227ef2e0 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 @@ -166,6 +166,19 @@ class WpApiClientProvider @Inject constructor( } } + fun getApiUrlResolver( + site: SiteModel + ): uniffi.wp_api.ApiUrlResolver = when { + site.isWPCom || site.isUsingWpComRestApi -> + WpComUrlResolver( + siteId = site.siteId.toString(), + baseUrl = WpComBaseUrl.Production + ) + else -> WpOrgSiteApiUrlResolver( + ParsedUrl.parse(site.buildUrl()) + ) + } + fun getApiRootUrlFrom(site: SiteModel): String = site.buildUrl() private fun SiteModel.buildUrl(): String = From 04ab7571b3158446988b887a9f9f7b773c3f8d20 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:58:55 -0600 Subject: [PATCH 09/13] Refresh editor capabilities on pull-to-refresh Call fetchEditorCapabilitiesForSite during pull-to-refresh so editor settings prefs stay current without requiring a full screen resume. Show snackbar on failure in both paths. --- .../repositories/EditorSettingsRepository.kt | 3 ++ .../android/ui/mysite/MySiteViewModel.kt | 46 +++++++++++++------ .../wordpress/android/ui/prefs/AppPrefs.java | 9 ++++ .../android/ui/prefs/AppPrefsWrapper.kt | 3 ++ 4 files changed, 47 insertions(+), 14 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 36623c783d3d..5be39b1ea2dd 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -26,6 +26,9 @@ class EditorSettingsRepository @Inject constructor( ) { private val editorSettingsSqlUtils = EditorSettingsSqlUtils() + fun hasCachedCapabilities(site: SiteModel): Boolean = + appPrefsWrapper.hasSiteEditorCapabilities(site) + /** * Returns whether the site is known to support the * `wp-block-editor/v1/settings` endpoint, based on 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 32ec25da5ef4..479884efdfcf 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 @@ -170,6 +170,12 @@ class MySiteViewModel @Inject constructor( siteCapabilityChecker.clearCacheForSite(site.siteId) } buildDashboardOrSiteItems(site) + launch { + fetchEditorCapabilitiesWithSnackbar( + site, + isUserInitiated = isPullToRefresh + ) + } } ?: run { accountDataViewModelSlice.onRefresh() } @@ -182,26 +188,38 @@ class MySiteViewModel @Inject constructor( selectedSiteRepository.getSelectedSite()?.let { buildDashboardOrSiteItems(it) launch { - val ok = editorSettingsRepository - .fetchEditorCapabilitiesForSite(it) - if (!ok) { - _onSnackbarMessage.postValue( - Event( - SnackbarMessageHolder( - UiString.UiStringRes( - R.string - .site_settings_fetch_failed - ) - ) - ) - ) - } + fetchEditorCapabilitiesWithSnackbar( + it, + isUserInitiated = false + ) } } ?: run { accountDataViewModelSlice.onResume() } } + private suspend fun fetchEditorCapabilitiesWithSnackbar( + site: SiteModel, + isUserInitiated: Boolean + ) { + val ok = editorSettingsRepository + .fetchEditorCapabilitiesForSite(site) + val hasCache = editorSettingsRepository + .hasCachedCapabilities(site) + if (!ok && (isUserInitiated || !hasCache)) { + _onSnackbarMessage.postValue( + Event( + SnackbarMessageHolder( + UiString.UiStringRes( + R.string + .site_settings_fetch_failed + ) + ) + ) + ) + } + } + private fun checkAndShowJetpackFullPluginInstallOnboarding() { selectedSiteRepository.getSelectedSite()?.let { selectedSite -> if (getShowJetpackFullPluginInstallOnboardingUseCase.execute(selectedSite)) { 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 16d0db6fbaa2..343ecf7dc484 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 @@ -1871,6 +1871,15 @@ private static String getStatsUtmCategoryKey(long siteId) { return DeletablePrefKey.STATS_UTM_CATEGORY.name() + siteId; } + public static boolean hasSiteEditorCapabilities( + @NonNull SiteModel site + ) { + return prefs().contains( + DeletablePrefKey.SITE_SUPPORTS_EDITOR_SETTINGS.name() + + site.getId() + ); + } + public static boolean getSiteSupportsEditorSettings( @NonNull SiteModel site ) { 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 8cf06bb6dcae..4e851c660cd1 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 @@ -133,6 +133,9 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setStatsUtmCategory(siteId: Long, category: String?) = AppPrefs.setStatsUtmCategory(siteId, category) + fun hasSiteEditorCapabilities(site: SiteModel): Boolean = + AppPrefs.hasSiteEditorCapabilities(site) + fun getSiteSupportsEditorSettings(site: SiteModel): Boolean = AppPrefs.getSiteSupportsEditorSettings(site) From 305ccc3cd76a6814915b0aafd630bfa40a0d3b13 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:59:05 -0600 Subject: [PATCH 10/13] Update wordpress-rs to alpha-2026-04-20 --- 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 fea666758b81..53f0cbfa4165 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '1285-59fdcdfba0de328157ae28f8bc2519560a0e2837' +wordpress-rs = 'alpha-2026-04-20' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' From 34887aeff48c97bdfd201eab0e71a5591562b598 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:48:54 -0600 Subject: [PATCH 11/13] Add EditorCapabilityResolver to centralize editor-capability gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SiteSettingsFragment` combined the top-level GutenbergKit feature flag, the remote `gutenberg_kit_plugins` flag, the per-site capability cache, and the user's toggle inline — a long conditional chain per capability that's hard to share with other consumers and easy to drift from when those consumers get added. Introduces `EditorCapabilityResolver` as a single source of truth for both theme styles and third-party blocks. Returns a `Resolved` sealed result — `Hidden` / `Unsupported(reason)` / `Available(userEnabled, advisory?)` — that UI callers branch on directly, and non-UI callers collapse via `shouldApplyInEditor`. The theme-not-a-block-theme case is modelled as an advisory, not a gate, matching today's behaviour where the pref stays enabled with an informational summary. Wires `SiteSettingsFragment` through the resolver. The editor-side consumer (the GutenbergKit config builder) will land in a follow-up alongside its refactor to an injectable class. --- .../ui/posts/EditorCapabilityResolver.kt | 117 ++++++++ .../ui/prefs/SiteSettingsFragment.java | 25 +- .../ui/posts/EditorCapabilityResolverTest.kt | 262 ++++++++++++++++++ 3 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt new file mode 100644 index 000000000000..00e8524ebfca --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt @@ -0,0 +1,117 @@ +package org.wordpress.android.ui.posts + +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.util.config.GutenbergKitPluginsFeature +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single source of truth for whether a given editor capability + * applies to a site. Combines: + * + * 1. A global feature-flag gate (the top-level GutenbergKit flag, + * plus any capability-specific remote flag such as + * [GutenbergKitPluginsFeature]). + * 2. A site-level capability cache populated by + * [EditorSettingsRepository.fetchEditorCapabilitiesForSite]. + * 3. The user's per-site toggle stored in + * [SiteSettingsProvider]. + * + * Both the settings UI ([SiteSettingsFragment]) and the editor + * configuration builder consult this resolver so they cannot + * drift out of agreement. + */ +@Singleton +class EditorCapabilityResolver @Inject constructor( + private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, + private val gutenbergKitPluginsFeature: GutenbergKitPluginsFeature, + private val editorSettingsRepository: EditorSettingsRepository, + private val siteSettingsProvider: SiteSettingsProvider, +) { + fun resolveThirdPartyBlocks(site: SiteModel): Resolved = when { + !gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> Resolved.Hidden + !gutenbergKitPluginsFeature.isEnabled() -> Resolved.Hidden + !editorSettingsRepository.getSupportsEditorAssetsForSite(site) -> + Resolved.Unsupported(Resolved.UnsupportedReason.CapabilityMissing) + else -> { + val userEnabled = siteSettingsProvider + .getSettings(site) + ?.useThirdPartyBlocks + ?: DEFAULT_USE_THIRD_PARTY_BLOCKS + Resolved.Available(userEnabled) + } + } + + fun resolveThemeStyles(site: SiteModel): Resolved = when { + !gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> Resolved.Hidden + !editorSettingsRepository.getSupportsEditorSettingsForSite(site) -> + Resolved.Unsupported(Resolved.UnsupportedReason.CapabilityMissing) + else -> { + val userEnabled = siteSettingsProvider + .getSettings(site) + ?.useThemeStyles + ?: DEFAULT_USE_THEME_STYLES + val advisory = if (!editorSettingsRepository.getThemeSupportsBlockStyles(site)) { + Resolved.AdvisoryReason.ThemeNotBlockTheme + } else { + null + } + Resolved.Available(userEnabled, advisory) + } + } + + companion object { + private const val DEFAULT_USE_THIRD_PARTY_BLOCKS = false + private const val DEFAULT_USE_THEME_STYLES = true + } +} + +/** + * Resolved state for an editor capability for a specific site. + * + * [shouldApplyInEditor] collapses the state to a single boolean + * for editor-config callers; the UI layer branches on the full + * sealed hierarchy to pick the correct visibility / disabled / + * advisory-note treatment. + */ +sealed class Resolved { + /** + * Globally disabled (feature flag off). The setting row is + * hidden; the editor does not apply the capability. + */ + data object Hidden : Resolved() + + /** + * Globally enabled, but this site cannot use the capability. + * The setting row is shown but disabled, with a reason the + * UI can surface to the user. + */ + data class Unsupported(val reason: UnsupportedReason) : Resolved() + + /** + * Globally enabled and toggle-able for this site. + * [userEnabled] is the current user preference. + * [advisory] optionally attaches an informational note — the + * toggle is still honoured regardless. + */ + data class Available( + val userEnabled: Boolean, + val advisory: AdvisoryReason? = null, + ) : Resolved() + + enum class UnsupportedReason { CapabilityMissing } + enum class AdvisoryReason { ThemeNotBlockTheme } + + // Accessors for Java callers that can't `is`-match on a + // sealed hierarchy. Kotlin callers should prefer `when`. + val isHidden: Boolean get() = this is Hidden + val isUnsupported: Boolean get() = this is Unsupported + val isAvailable: Boolean get() = this is Available + + val asAvailable: Available? get() = this as? Available + + val shouldApplyInEditor: Boolean + get() = this is Available && userEnabled +} 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 ee6f8a038ff8..378ae490b9eb 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,9 +80,8 @@ import org.wordpress.android.ui.bloggingreminders.BloggingRemindersViewModel; import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper; import org.wordpress.android.util.PlansConstants; -import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker; -import org.wordpress.android.repositories.EditorSettingsRepository; -import org.wordpress.android.util.config.GutenbergKitPluginsFeature; +import org.wordpress.android.ui.posts.EditorCapabilityResolver; +import org.wordpress.android.ui.posts.Resolved; import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation.ValidationType; import org.wordpress.android.ui.prefs.SiteSettingsFormatDialog.FormatType; import org.wordpress.android.ui.prefs.homepage.HomepageSettingsDialog; @@ -195,9 +194,7 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject UiHelpers mUiHelpers; @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; - @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; - @Inject GutenbergKitPluginsFeature mGutenbergKitPluginsFeature; - @Inject EditorSettingsRepository mEditorSettingsRepository; + @Inject EditorCapabilityResolver mEditorCapabilityResolver; private BloggingRemindersViewModel mBloggingRemindersViewModel; @@ -1089,27 +1086,27 @@ public void initPreferences() { WPPrefUtils.removePreference(this, R.string.pref_key_homepage, R.string.pref_key_homepage_settings); } - // hide theme styles preference if GutenbergKit is not enabled - if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled()) { + Resolved themeStyles = mEditorCapabilityResolver.resolveThemeStyles(mSite); + if (themeStyles.isHidden()) { WPPrefUtils.removePreference(this, R.string.pref_key_site_editor, R.string.pref_key_use_theme_styles); - } else if (!mEditorSettingsRepository.getSupportsEditorSettingsForSite(mSite)) { + } else if (themeStyles.isUnsupported()) { 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)) { + } else if (themeStyles.getAsAvailable().getAdvisory() == Resolved.AdvisoryReason.ThemeNotBlockTheme) { + // Available — pref stays enabled, attach advisory summary 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 third-party blocks preference if GutenbergKit or plugins feature is not enabled - if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled() - || !mGutenbergKitPluginsFeature.isEnabled()) { + Resolved thirdPartyBlocks = mEditorCapabilityResolver.resolveThirdPartyBlocks(mSite); + if (thirdPartyBlocks.isHidden()) { WPPrefUtils.removePreference( this, R.string.pref_key_site_editor, R.string.pref_key_use_third_party_blocks); - } else if (!mEditorSettingsRepository.getSupportsEditorAssetsForSite(mSite)) { + } else if (thirdPartyBlocks.isUnsupported()) { mUseThirdPartyBlocksPref.setEnabled(false); mUseThirdPartyBlocksPref.setSummary( getString(R.string.site_settings_use_third_party_blocks_summary) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt new file mode 100644 index 000000000000..a0e25fd194b7 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt @@ -0,0 +1,262 @@ +package org.wordpress.android.ui.posts + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +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.SiteModel +import org.wordpress.android.models.SiteSettingsModel +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.util.config.GutenbergKitPluginsFeature + +@RunWith(MockitoJUnitRunner::class) +class EditorCapabilityResolverTest { + @Mock + lateinit var gutenbergKitFeatureChecker: GutenbergKitFeatureChecker + + @Mock + lateinit var gutenbergKitPluginsFeature: GutenbergKitPluginsFeature + + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + + @Mock + lateinit var siteSettingsProvider: SiteSettingsProvider + + private val site = SiteModel() + + private lateinit var resolver: EditorCapabilityResolver + + @Before + fun setUp() { + resolver = EditorCapabilityResolver( + gutenbergKitFeatureChecker, + gutenbergKitPluginsFeature, + editorSettingsRepository, + siteSettingsProvider, + ) + // Defaults that let resolution reach `Available` unless + // a test overrides them. + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(true) + whenever(gutenbergKitPluginsFeature.isEnabled()).thenReturn(true) + whenever(editorSettingsRepository.getSupportsEditorAssetsForSite(any())).thenReturn(true) + whenever(editorSettingsRepository.getSupportsEditorSettingsForSite(any())).thenReturn(true) + whenever(editorSettingsRepository.getThemeSupportsBlockStyles(any())).thenReturn(true) + } + + // ===== Third-party blocks ===== + + @Test + fun `third-party blocks hidden when GutenbergKit disabled`() { + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(false) + + val result = resolver.resolveThirdPartyBlocks(site) + + assertThat(result).isEqualTo(Resolved.Hidden) + } + + @Test + fun `third-party blocks hidden when plugins feature disabled`() { + whenever(gutenbergKitPluginsFeature.isEnabled()).thenReturn(false) + + val result = resolver.resolveThirdPartyBlocks(site) + + assertThat(result).isEqualTo(Resolved.Hidden) + } + + @Test + fun `third-party blocks unsupported when site capability missing`() { + whenever(editorSettingsRepository.getSupportsEditorAssetsForSite(any())).thenReturn(false) + + val result = resolver.resolveThirdPartyBlocks(site) + + assertThat(result).isEqualTo( + Resolved.Unsupported(Resolved.UnsupportedReason.CapabilityMissing) + ) + } + + @Test + fun `third-party blocks available reflects user preference when set`() { + val settings = SiteSettingsModel().apply { useThirdPartyBlocks = true } + whenever(siteSettingsProvider.getSettings(any())).thenReturn(settings) + + val result = resolver.resolveThirdPartyBlocks(site) + + assertThat(result).isEqualTo(Resolved.Available(userEnabled = true)) + } + + @Test + fun `third-party blocks default off when user preference absent`() { + whenever(siteSettingsProvider.getSettings(any())).thenReturn(null) + + val result = resolver.resolveThirdPartyBlocks(site) + + assertThat(result).isEqualTo(Resolved.Available(userEnabled = false)) + } + + @Test + fun `third-party blocks shouldApplyInEditor follows Available state`() { + whenever(siteSettingsProvider.getSettings(any())).thenReturn( + SiteSettingsModel().apply { useThirdPartyBlocks = true } + ) + + assertThat(resolver.resolveThirdPartyBlocks(site).shouldApplyInEditor).isTrue + } + + @Test + fun `third-party blocks shouldApplyInEditor is false when hidden`() { + whenever(gutenbergKitPluginsFeature.isEnabled()).thenReturn(false) + + assertThat(resolver.resolveThirdPartyBlocks(site).shouldApplyInEditor).isFalse + } + + @Test + fun `third-party blocks shouldApplyInEditor is false when unsupported`() { + whenever(editorSettingsRepository.getSupportsEditorAssetsForSite(any())).thenReturn(false) + + assertThat(resolver.resolveThirdPartyBlocks(site).shouldApplyInEditor).isFalse + } + + // ===== Theme styles ===== + + @Test + fun `theme styles hidden when GutenbergKit disabled`() { + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(false) + + val result = resolver.resolveThemeStyles(site) + + assertThat(result).isEqualTo(Resolved.Hidden) + } + + @Test + fun `theme styles ignores plugins feature flag`() { + val result = resolver.resolveThemeStyles(site) + + assertThat(result).isInstanceOf(Resolved.Available::class.java) + verify(gutenbergKitPluginsFeature, never()).isEnabled() + } + + @Test + fun `theme styles unsupported when site capability missing`() { + whenever(editorSettingsRepository.getSupportsEditorSettingsForSite(any())).thenReturn(false) + + val result = resolver.resolveThemeStyles(site) + + assertThat(result).isEqualTo( + Resolved.Unsupported(Resolved.UnsupportedReason.CapabilityMissing) + ) + } + + @Test + fun `theme styles advisory when theme is not a block theme`() { + whenever(editorSettingsRepository.getThemeSupportsBlockStyles(any())).thenReturn(false) + + val result = resolver.resolveThemeStyles(site) + + assertThat(result).isEqualTo( + Resolved.Available( + userEnabled = true, + advisory = Resolved.AdvisoryReason.ThemeNotBlockTheme, + ) + ) + } + + @Test + fun `theme styles reflects user preference when set`() { + val settings = SiteSettingsModel().apply { useThemeStyles = false } + whenever(siteSettingsProvider.getSettings(any())).thenReturn(settings) + + val result = resolver.resolveThemeStyles(site) + + assertThat(result).isEqualTo(Resolved.Available(userEnabled = false)) + } + + @Test + fun `theme styles default on when user preference absent`() { + whenever(siteSettingsProvider.getSettings(any())).thenReturn(null) + + val result = resolver.resolveThemeStyles(site) + + assertThat(result).isEqualTo(Resolved.Available(userEnabled = true)) + } + + @Test + fun `theme styles shouldApplyInEditor honours user toggle even with advisory`() { + whenever(editorSettingsRepository.getThemeSupportsBlockStyles(any())).thenReturn(false) + whenever(siteSettingsProvider.getSettings(any())).thenReturn( + SiteSettingsModel().apply { useThemeStyles = true } + ) + + assertThat(resolver.resolveThemeStyles(site).shouldApplyInEditor).isTrue + } + + @Test + fun `theme styles shouldApplyInEditor is false when user disabled`() { + whenever(siteSettingsProvider.getSettings(any())).thenReturn( + SiteSettingsModel().apply { useThemeStyles = false } + ) + + assertThat(resolver.resolveThemeStyles(site).shouldApplyInEditor).isFalse + } + + // ===== Resolved accessors (for Java callers) ===== + + @Test + fun `Hidden reports isHidden true and other flags false`() { + val state: Resolved = Resolved.Hidden + + assertThat(state.isHidden).isTrue + assertThat(state.isUnsupported).isFalse + assertThat(state.isAvailable).isFalse + } + + @Test + fun `Unsupported reports isUnsupported true and other flags false`() { + val state: Resolved = Resolved.Unsupported(Resolved.UnsupportedReason.CapabilityMissing) + + assertThat(state.isUnsupported).isTrue + assertThat(state.isHidden).isFalse + assertThat(state.isAvailable).isFalse + } + + @Test + fun `Available reports isAvailable true and other flags false`() { + val state: Resolved = Resolved.Available(userEnabled = true) + + assertThat(state.isAvailable).isTrue + assertThat(state.isHidden).isFalse + assertThat(state.isUnsupported).isFalse + } + + @Test + fun `asAvailable returns the Available instance when available`() { + val available = Resolved.Available( + userEnabled = true, + advisory = Resolved.AdvisoryReason.ThemeNotBlockTheme, + ) + val state: Resolved = available + + assertThat(state.asAvailable).isSameAs(available) + } + + @Test + fun `asAvailable returns null for Hidden`() { + val state: Resolved = Resolved.Hidden + + assertThat(state.asAvailable).isNull() + } + + @Test + fun `asAvailable returns null for Unsupported`() { + val state: Resolved = Resolved.Unsupported(Resolved.UnsupportedReason.CapabilityMissing) + + assertThat(state.asAvailable).isNull() + } +} From b984d31491e2d028b196c6732c0dd40961533ca8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 4 May 2026 14:50:04 -0600 Subject: [PATCH 12/13] Convert GutenbergKitSettingsBuilder to injectable class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes `GutenbergKitSettingsBuilder` from a Kotlin `object` with `buildSettings` returning a `Map` to a `@Singleton` class that returns `EditorConfiguration` directly via `buildPostConfiguration(site, post, accessToken)`. The injectable form is needed so the builder can constructor-inject `EditorCapabilityResolver` and consult per-site theme-styles and third-party-blocks state itself, instead of relying on each call site to assemble a `FeatureConfig`. `EditorConfigurationBuilder` (the intermediate Map → `EditorConfiguration` adapter) is now redundant and removed. `GutenbergKitActivity` adopts the injected builder via a small `buildEditorConfiguration` helper that overlays the per-call locale, cookies, and account fields the builder cannot know about, and drops its now-unused `GutenbergKitPluginsFeature` injection. Settings-builder tests are rewritten against the new ctor and API; capability-gating coverage already lives in `EditorCapabilityResolverTest`. --- .../ui/posts/EditorConfigurationBuilder.kt | 117 --- .../android/ui/posts/GutenbergKitActivity.kt | 60 +- .../ui/posts/GutenbergKitSettingsBuilder.kt | 310 +++--- .../posts/GutenbergKitSettingsBuilderTest.kt | 962 +++++++++--------- 4 files changed, 611 insertions(+), 838 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.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 98912093b939..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt +++ /dev/null @@ -1,117 +0,0 @@ -package org.wordpress.android.ui.posts - -import org.wordpress.android.util.UrlUtils -import org.wordpress.gutenberg.model.EditorConfiguration -import org.wordpress.gutenberg.model.PostTypeDetails - -/** - * 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 - * @return Configured EditorConfiguration instance - */ - fun build( - settings: Map, - ): EditorConfiguration { - val siteURL = settings.getSetting("siteURL") ?: "" - val siteApiRoot = settings.getSetting("siteApiRoot") ?: "" - val postType = settings.getSetting("postType") - ?: PostTypeDetails.post - val siteApiNamespace = settings.getStringArray("siteApiNamespace") - - return EditorConfiguration.builder( - siteURL = siteURL, - siteApiRoot = siteApiRoot, - postType = postType - ).apply { - val postId = settings.getSetting("postId") - ?.let { if (it == 0) null else it.toUInt() } - - // Post settings - setTitle(settings.getSetting("postTitle") ?: "") - setContent(settings.getSetting("postContent") ?: "") - setPostId(postId) - setPostStatus(settings.getSetting("status") ?: "draft") - - // Site settings - 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) - ) - }.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 - 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/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 6c6ee25e5a86..7d9e3bc09b69 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 @@ -208,7 +209,6 @@ import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import org.wordpress.android.util.analytics.AnalyticsUtils import org.wordpress.android.util.analytics.AnalyticsUtils.BlockEditorEnabledSource import org.wordpress.android.util.config.ContactSupportFeatureConfig -import org.wordpress.android.util.config.GutenbergKitPluginsFeature import org.wordpress.android.util.config.PostConflictResolutionFeatureConfig import org.wordpress.android.util.extensions.setLiftOnScrollTargetViewIdAndRequestLayout import org.wordpress.android.util.helpers.MediaFile @@ -381,8 +381,6 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var postConflictResolutionFeatureConfig: PostConflictResolutionFeatureConfig - @Inject lateinit var gutenbergKitPluginsFeature: GutenbergKitPluginsFeature - @Inject lateinit var activityNavigator: ActivityNavigator @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @@ -391,6 +389,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 @@ -2208,36 +2207,37 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene onXpostsSettingsCapability(isXpostsCapable) } - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig.fromSiteModel(siteModel) - val postConfig = GutenbergKitSettingsBuilder.PostConfig.fromPostModel( - editPostRepository.getPost() - ) - val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(), - isThemeStylesFeatureEnabled = siteSettings?.useThemeStyles ?: true, - isNetworkLoggingEnabled = AppPrefs.isTrackNetworkRequestsEnabled() - ) - 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 - ) + val post = editPostRepository.getPost() + val configuration = buildEditorConfiguration(siteModel, post) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = appConfig, - featureConfig = featureConfig + return GutenbergKitEditorFragment.newInstance(configuration) + } + + private fun buildEditorConfiguration( + site: SiteModel, + post: PostImmutableModel? + ): EditorConfiguration { + val base = gutenbergKitSettingsBuilder.buildPostConfiguration( + site = site, + post = post, + accessToken = accountStore.accessToken ) - val configuration = EditorConfigurationBuilder.build(settings) - return GutenbergKitEditorFragment.newInstance(configuration) + val locale = perAppLocaleManager + .getCurrentLocaleLanguageCode() + .replace("_", "-").lowercase() + + return base.toBuilder() + .setLocale(locale) + .setCookies( + editPostAuthViewModel.getCookiesForPrivateSites( + site, privateAtomicCookie + ) + ) + .setEnableNetworkLogging( + AppPrefs.isTrackNetworkRequestsEnabled() + ) + .build() } override fun instantiateItem(container: ViewGroup, position: Int): Any { 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 d35fff16f41f..f2295b2dfca5 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,170 +1,87 @@ 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 org.wordpress.gutenberg.model.PostTypeDetails - -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?, - val status: String? - ) { - companion object { - fun fromPostModel(postModel: PostImmutableModel?): PostConfig { - return PostConfig( - remotePostId = postModel?.remotePostId, - isPage = postModel?.isPage ?: false, - title = postModel?.title, - content = postModel?.content, - status = postModel?.status - ) - } +import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GutenbergKitSettingsBuilder @Inject constructor( + private val editorCapabilityResolver: EditorCapabilityResolver, +) { + 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 = if (shouldUseWPComRestApi) - arrayOf("sites/${siteConfig.siteId}/", "sites/${UrlUtils.removeScheme(siteConfig.url)}/") - else arrayOf() + ) ?: "" - val wpcomLocaleSlug = appConfig.locale.replace("_", "-").lowercase() - - return mutableMapOf( - "postId" to postConfig.remotePostId?.toInt(), - "postType" to if (postConfig.isPage) { - PostTypeDetails.page - } else { - PostTypeDetails.post - }, - "status" to postConfig.status, - "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 siteApiNamespace = buildSiteApiNamespace( + shouldUseWPComRestApi, site.siteId, site.url ) + + val postType = if (post?.isPage == true) PostTypeDetails.page else PostTypeDetails.post + + val cachedHosts = buildCachedHosts(site.url) + val editorAssetsEndpoint = + buildEditorAssetsEndpoint(siteApiRoot, siteApiNamespace) + + return EditorConfiguration.builder( + siteURL = site.url, + siteApiRoot = siteApiRoot, + postType = postType + ).apply { + setTitle(post?.title ?: "") + setContent(post?.content ?: "") + setPostId( + if (post?.isLocalDraft == true) null + else post?.remotePostId?.toUInt() + ) + setPostStatus(post?.status ?: "draft") + setAuthHeader(authHeader) + setSiteApiNamespace(siteApiNamespace) + setNamespaceExcludedPaths( + arrayOf( + "/wpcom/v2/following/recommendations", + "/wpcom/v2/following/mine" + ) + ) + setThemeStyles( + editorCapabilityResolver.resolveThemeStyles(site).shouldApplyInEditor + ) + setPlugins( + editorCapabilityResolver.resolveThirdPartyBlocks(site).shouldApplyInEditor + ) + 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?, @@ -174,7 +91,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 { @@ -187,49 +107,67 @@ 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( - 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())) + internal fun buildSiteApiNamespace( + shouldUseWPComRestApi: Boolean, + siteId: Long, + siteUrl: String + ): Array { + if (!shouldUseWPComRestApi) return arrayOf() + val host = extractHost(siteUrl) + return if (host != null) { + arrayOf("sites/$siteId/", "sites/$host/") + } else { + arrayOf("sites/$siteId/") + } } - /** - * 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 - ) + 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? { + if (siteApiRoot.isEmpty()) return null + val firstNamespace = siteApiNamespace.firstOrNull() ?: "" + return "${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets" + } + + internal fun extractHost(url: String): String? { + return try { + URI(url).host + } catch (_: Exception) { + 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 de5ab603c1e8..4e0a8e7ad5c0 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,688 +1,640 @@ package org.wordpress.android.ui.posts -import android.content.Context import org.assertj.core.api.Assertions.assertThat +import org.junit.Before 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.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.gutenberg.model.PostTypeDetails @RunWith(MockitoJUnitRunner::class) -@Suppress("LargeClass") class GutenbergKitSettingsBuilderTest { - // ===== Plugin Logic Tests ===== @Mock - lateinit var appContext: Context + lateinit var editorCapabilityResolver: EditorCapabilityResolver - @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 - ) + private val builder by lazy { + GutenbergKitSettingsBuilder(editorCapabilityResolver) + } - testCases.forEach { (isWPCom, isJetpack, password) -> - val siteConfig = createSiteConfig( - isWPCom = isWPCom, - isJetpackConnected = isJetpack, - apiRestPasswordPlain = password - ) + @Before + fun setUp() { + whenever(editorCapabilityResolver.resolveThemeStyles(any())) + .thenReturn(Resolved.Hidden) + whenever(editorCapabilityResolver.resolveThirdPartyBlocks(any())) + .thenReturn(Resolved.Hidden) + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + // ===== Auth Header Tests ===== - featureConfig = createFeatureConfig(), // Both features disabled - ) + @Test + fun `WPCom site returns Bearer token header`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = "my_token", + username = null, + password = null + ) - 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 = builder.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 = builder.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 = builder.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 = builder.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 = builder.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 = builder.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 = builder.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 = builder.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 = builder.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 ===== + // ===== Site API Namespace 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 `namespace is empty for non-WPCom sites`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = false, + siteId = 123L, + siteUrl = "https://example.com" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "test_bearer_token"), - - featureConfig = createFeatureConfig(), + assertThat(result).isEmpty() + } + @Test + fun `namespace includes site ID and host for WPCom sites`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 456L, + siteUrl = "https://example.wordpress.com" ) - 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).containsExactly( + "sites/456/", + "sites/example.wordpress.com/" + ) } @Test - fun `Jetpack site with application password uses Basic auth and site API`() { - val siteConfig = createSiteConfig( - url = "https://mysite.com", - siteId = 789, - isJetpackConnected = true, - wpApiRestUrl = "https://mysite.com/wp-json/", - apiRestUsernamePlain = "testuser", - apiRestPasswordPlain = "testpass123" + fun `namespace includes only site ID when host extraction fails`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 789L, + siteUrl = "not-a-valid-url" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "unused_token"), + assertThat(result).containsExactly("sites/789/") + } - featureConfig = createFeatureConfig(), + // ===== Extract Host Tests ===== - ) + @Test + fun `extractHost returns host from valid URL`() { + assertThat( + builder.extractHost( + "https://example.wordpress.com" + ) + ).isEqualTo("example.wordpress.com") + } - assertThat(settings["authHeader"] as String).startsWith("Basic ") - assertThat(settings["siteApiRoot"]).isEqualTo("https://mysite.com/wp-json/") - assertThat(settings["siteApiNamespace"] as Array<*>).isEmpty() + @Test + fun `extractHost returns null for invalid URL`() { + assertThat( + builder.extractHost("not-a-url") + ).isNull() } @Test - fun `Jetpack site without password falls back to Bearer when WPCom REST available`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - isUsingWpComRestApi = true, - apiRestPasswordPlain = null - ) + fun `extractHost strips path from URL`() { + assertThat( + builder.extractHost( + "https://example.com/blog/page" + ) + ).isEqualTo("example.com") + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "fallback_token"), + // ===== buildPostConfiguration Tests ===== - featureConfig = createFeatureConfig(), + // --- WPCom site configuration --- - ) + @Test + fun `WPCom site uses WPCom API root`() { + val config = buildWPComConfig() - assertThat(settings["authHeader"]).isEqualTo("Bearer fallback_token") - assertThat(settings["siteApiRoot"]).isEqualTo("https://public-api.wordpress.com/") + assertThat(config.siteApiRoot) + .isEqualTo("https://public-api.wordpress.com/") } - // ===== Authentication Edge Cases Tests ===== + @Test + fun `WPCom site sets Bearer auth header`() { + val config = buildWPComConfig(accessToken = "wpcom_token") + + assertThat(config.authHeader) + .isEqualTo("Bearer wpcom_token") + } @Test - fun `WPCom site with null access token returns null auth header`() { - val siteConfig = createSiteConfig( - isWPCom = true, - isUsingWpComRestApi = true + 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(accessToken = null), - featureConfig = createFeatureConfig() + assertThat(config.siteApiNamespace).containsExactly( + "sites/42/", + "sites/mysite.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 - ) + fun `WPCom site sets editor assets endpoint`() { + val config = buildWPComConfig(siteId = 100L) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = ""), - featureConfig = createFeatureConfig() + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://public-api.wordpress.com/" + + "wpcom/v2/sites/100/editor-assets" ) + } + + @Test + fun `WPCom site with missing token uses empty auth header`() { + val config = buildWPComConfig(accessToken = null) - assertThat(settings["authHeader"]).isNull() + assertThat(config.authHeader).isEmpty() } + // --- Self-hosted site configuration --- + @Test - fun `Basic auth with null username returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = null, - apiRestPasswordPlain = "password123" + fun `self-hosted site uses wpApiRestUrl as API root`() { + val config = buildSelfHostedConfig( + wpApiRestUrl = "https://mysite.com/wp-json/" ) - 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 falls back to siteUrl wp-json when no REST URL`() { + val config = buildSelfHostedConfig( + siteUrl = "https://mysite.com", + wpApiRestUrl = null ) - assertThat(settings["authHeader"]).isNull() + assertThat(config.siteApiRoot) + .isEqualTo("https://mysite.com/wp-json/") } @Test - fun `Basic auth with empty username returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "", - apiRestPasswordPlain = "password123" + fun `self-hosted site sets Basic auth header`() { + val config = buildSelfHostedConfig( + applicationPassword = "app_pass", + apiRestUsername = "admin" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + assertThat(config.authHeader).startsWith("Basic ") + } + + @Test + fun `self-hosted site has empty namespace`() { + val config = buildSelfHostedConfig() - assertThat(settings["authHeader"]).isNull() + assertThat(config.siteApiNamespace).isEmpty() } @Test - fun `Basic auth with null password returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "username", - apiRestPasswordPlain = null + fun `self-hosted site builds editor assets endpoint from API root`() { + val config = buildSelfHostedConfig() + + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://mysite.com/wp-json/wpcom/v2/editor-assets" ) + } + + // --- Application password overrides WPCom REST API --- - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + @Test + fun `app password forces non-WPCom API even if site uses WPCom REST`() { + val site = SiteModel().apply { + url = "https://mysite.com" + siteId = 123L + setIsWPCom(false) + setIsJetpackConnected(true) + origin = SiteModel.ORIGIN_WPCOM_REST + wpApiRestUrl = "https://mysite.com/wp-json/" + apiRestPasswordPlain = "app_pass" + apiRestUsernamePlain = "admin" + } + val config = builder.buildPostConfiguration( + site = site, + accessToken = "wpcom_token" ) - assertThat(settings["authHeader"]).isNull() + assertThat(config.siteApiRoot) + .isEqualTo("https://mysite.com/wp-json/") + assertThat(config.authHeader).startsWith("Basic ") + assertThat(config.siteApiNamespace).isEmpty() } + // --- Post configuration --- + @Test - fun `Basic auth with empty password returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "username", - apiRestPasswordPlain = "" - ) + fun `post type is post by default`() { + val config = buildWPComConfig() - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + assertThat(config.postType).isEqualTo(PostTypeDetails.post) + } - assertThat(settings["authHeader"]).isNull() + @Test + fun `null post title becomes empty string`() { + val config = buildWPComConfig() + + assertThat(config.title).isEmpty() } @Test - fun `Basic auth with both username and password empty returns null auth header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "", - apiRestPasswordPlain = "" - ) + fun `null post content becomes empty string`() { + val config = buildWPComConfig() - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + assertThat(config.content).isEmpty() + } - assertThat(settings["authHeader"]).isNull() + @Test + fun `null remote ID results in null post ID`() { + val config = buildWPComConfig() + + assertThat(config.postId).isNull() } @Test - fun `Valid WPCom authentication returns proper Bearer header`() { - val siteConfig = createSiteConfig( - isWPCom = true, - isUsingWpComRestApi = true + 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 = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "valid_token_123"), - featureConfig = createFeatureConfig() - ) + assertThat(config.postId).isNull() + } + + @Test + fun `null post status defaults to draft`() { + val config = buildWPComConfig() + + assertThat(config.postStatus).isEqualTo("draft") + } + + // --- Asset caching --- + + @Test + fun `asset caching is always enabled`() { + val config = buildWPComConfig() - assertThat(settings["authHeader"]).isEqualTo("Bearer valid_token_123") + assertThat(config.enableAssetCaching).isTrue() } @Test - fun `Valid Basic auth returns proper Basic header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "testuser", - apiRestPasswordPlain = "testpass" + 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" ) + } + + @Test + fun `cached hosts includes only s0 wp com for invalid URL`() { + val config = buildWPComConfig(siteUrl = "not-a-url") - 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.cachedAssetHosts) + .containsExactly("s0.wp.com") } + // --- Namespace excluded paths --- + @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 `namespace excluded paths are always set`() { + val config = buildWPComConfig() - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + assertThat(config.namespaceExcludedPaths).containsExactly( + "/wpcom/v2/following/recommendations", + "/wpcom/v2/following/mine" ) - - val authHeader = settings["authHeader"] as String? - assertThat(authHeader).isNotNull() - assertThat(authHeader).startsWith("Basic ") } - // ===== Complete Scenario Tests ===== + // --- Site URL passthrough --- @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 `site URL is passed through to configuration`() { + val config = buildWPComConfig( + siteUrl = "https://example.wordpress.com" ) - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = 456L, - isPage = false, - title = "Test Post", - content = "Test Content", - status = "publish" + assertThat(config.siteURL) + .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" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = createAppConfig( - accessToken = "test_token", - cookies = "test_cookies" - ), - featureConfig = createFeatureConfig( - isPluginsFeatureEnabled = true, - isThemeStylesFeatureEnabled = true - ) + assertThat(config.cachedAssetHosts).containsExactlyInAnyOrder( + "s0.wp.com", + "example.com" ) + } - // Verify all settings are correctly configured - assertThat(settings["postId"]).isEqualTo(456) - assertThat(settings["postType"]).isEqualTo(PostTypeDetails.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" - ) - - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = 100L, - isPage = true, - title = "Test Page", - content = "Page Content", - status = "draft" - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = createAppConfig( - accessToken = "unused", - locale = "fr_FR" - ), - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true) - ) - - assertThat(settings["postType"]).isEqualTo(PostTypeDetails.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") - } - - @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" - ) - - testCases.forEach { (input, expected) -> - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = createPostConfig(), - appConfig = createAppConfig(locale = input), - featureConfig = createFeatureConfig() - ) + @Test + fun `cached hosts only includes s0 wp com for empty URL`() { + val config = buildWPComConfig(siteUrl = "") - assertThat(settings["locale"]) - .withFailMessage("Expected $input to transform to $expected") - .isEqualTo(expected) - } + assertThat(config.cachedAssetHosts) + .containsExactly("s0.wp.com") } + // ===== buildEditorAssetsEndpoint (via buildPostConfiguration) ===== + @Test - fun `feature flags control themeStyles and plugins independently`() { - val siteConfig = createSiteConfig(isWPCom = true) + fun `editor assets endpoint uses first namespace`() { + val config = buildWPComConfig(siteId = 55L) - // 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.editorAssetsEndpoint).isEqualTo( + "https://public-api.wordpress.com/" + + "wpcom/v2/sites/55/editor-assets" ) + } - flagCombinations.forEach { (plugins, themes, expected) -> - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig( - isPluginsFeatureEnabled = plugins, - isThemeStylesFeatureEnabled = themes - ), - ) + @Test + fun `editor assets endpoint for non-WPCom site uses API root`() { + val config = buildSelfHostedConfig() - assertThat(settings["plugins"]).isEqualTo(expected.first) - assertThat(settings["themeStyles"]).isEqualTo(expected.second) - } + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://mysite.com/wp-json/wpcom/v2/editor-assets" + ) } + // ===== buildSiteApiNamespace edge cases ===== + @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 `namespace with empty URL returns only site ID`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 321L, + siteUrl = "" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + assertThat(result).containsExactly("sites/321/") + } - featureConfig = createFeatureConfig(), + // ===== 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 = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" ) - assertThat(settings["siteApiRoot"]).isEqualTo("https://selfhosted.org/wp-json/") + assertThat(config.postType).isEqualTo(PostTypeDetails.page) } @Test - fun `namespaceExcludedPaths is always included`() { - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = createPostConfig(), - appConfig = createAppConfig(), + 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 = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) - featureConfig = createFeatureConfig(), + assertThat(config.postId).isEqualTo(42u) + } - ) + // ===== Capability resolver integration ===== - val excludedPaths = settings["namespaceExcludedPaths"] as Array<*> - assertThat(excludedPaths).containsExactly( - "/wpcom/v2/following/recommendations", - "/wpcom/v2/following/mine" - ) + @Test + fun `themeStyles reflects resolver result`() { + whenever(editorCapabilityResolver.resolveThemeStyles(any())) + .thenReturn(Resolved.Available(userEnabled = true)) + + val config = buildWPComConfig() + + assertThat(config.themeStyles).isTrue() } @Test - fun `null post data is handled correctly`() { - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = null, - isPage = false, - title = null, - content = null, - status = null - ) + fun `themeStyles is false when resolver hides the capability`() { + whenever(editorCapabilityResolver.resolveThemeStyles(any())) + .thenReturn(Resolved.Hidden) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = postConfig, - appConfig = createAppConfig(), + val config = buildWPComConfig() - featureConfig = createFeatureConfig(), + assertThat(config.themeStyles).isFalse() + } - ) + @Test + fun `plugins reflects resolver result`() { + whenever(editorCapabilityResolver.resolveThirdPartyBlocks(any())) + .thenReturn(Resolved.Available(userEnabled = true)) + + val config = buildWPComConfig() - assertThat(settings["postId"]).isNull() - assertThat(settings["postTitle"]).isNull() - assertThat(settings["postContent"]).isNull() - assertThat(settings["status"]).isNull() - assertThat(settings["postType"]).isEqualTo(PostTypeDetails.post) // Still defaults to post + assertThat(config.plugins).isTrue() } @Test - fun `post status is included in settings`() { - val testCases = listOf("draft", "publish", "pending", "private", "future", "trash") + fun `plugins is false when resolver hides the capability`() { + whenever(editorCapabilityResolver.resolveThirdPartyBlocks(any())) + .thenReturn(Resolved.Hidden) - testCases.forEach { status -> - val postConfig = createPostConfig(status = status) + val config = buildWPComConfig() - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = postConfig, - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() - ) + assertThat(config.plugins).isFalse() + } - assertThat(settings["status"]) - .withFailMessage("Expected status=$status in settings") - .isEqualTo(status) + // ===== 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 builder.buildPostConfiguration( + site = site, + accessToken = accessToken + ) } - // ===== 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", - status: String? = "draft" - ) = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = remotePostId, - isPage = isPage, - title = title, - content = content, - status = status - ) + 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 builder.buildPostConfiguration( + site = site, + accessToken = null + ) + } } From 12084a6b231957636f32ec6042b9d1dfe580a97e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:07:43 -0600 Subject: [PATCH 13/13] Integrate GutenbergKit editor preloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `GutenbergEditorPreloader`, a `@Singleton` that prepares `EditorDependencies` per site on dashboard refresh so the editor can open with warm dependencies instead of loading them at launch. - Wires preloading into `MySiteViewModel.buildDashboardOrSiteItems`, replacing the previous `GutenbergKitWarmupHelper` warmup flow. The separate `fetchEditorCapabilitiesWithSnackbar` path is unchanged — the preloader and the snackbar flow share the same repository call, deduplicated at the cache layer. - `GutenbergKitActivity` passes the site to `GutenbergKitEditorFragment`; the fragment resolves preloaded `EditorDependencies` from the preloader by site local ID instead of building them inline. - Removes `GutenbergKitWarmupHelper`. --- .../android/modules/AppComponent.java | 3 + .../android/ui/mysite/MySiteViewModel.kt | 25 +- .../android/ui/posts/EditorServiceProvider.kt | 30 ++ .../ui/posts/EditorServiceProviderImpl.kt | 26 + .../ui/posts/GutenbergEditorPreloader.kt | 177 +++++++ .../android/ui/posts/GutenbergKitActivity.kt | 5 +- .../ui/posts/GutenbergKitWarmupHelper.kt | 93 ---- .../editor/GutenbergKitEditorFragment.kt | 16 +- .../android/ui/mysite/MySiteViewModelTest.kt | 23 +- .../ui/posts/GutenbergEditorPreloaderTest.kt | 467 ++++++++++++++++++ 10 files changed, 754 insertions(+), 111 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 create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt delete mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt 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 b25e94a5b64c..70dc7be2a0f5 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; @@ -255,6 +256,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/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt index 479884efdfcf..2d4695f7cb4a 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,8 @@ 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.repositories.EditorSettingsRepository +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,9 +45,7 @@ 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 -import org.wordpress.android.repositories.EditorSettingsRepository @Suppress("LargeClass", "LongMethod", "LongParameterList") class MySiteViewModel @Inject constructor( @@ -65,8 +65,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, private val editorSettingsRepository: EditorSettingsRepository, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() @@ -169,7 +169,7 @@ class MySiteViewModel @Inject constructor( if (isPullToRefresh) { siteCapabilityChecker.clearCacheForSite(site.siteId) } - buildDashboardOrSiteItems(site) + buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh) launch { fetchEditorCapabilitiesWithSnackbar( site, @@ -211,8 +211,7 @@ class MySiteViewModel @Inject constructor( Event( SnackbarMessageHolder( UiString.UiStringRes( - R.string - .site_settings_fetch_failed + R.string.site_settings_fetch_failed ) ) ) @@ -284,7 +283,7 @@ class MySiteViewModel @Inject constructor( dashboardCardsViewModelSlice.onCleared() dashboardItemsViewModelSlice.onCleared() accountDataViewModelSlice.onCleared() - gutenbergKitWarmupHelper.clearWarmupState() + gutenbergEditorPreloader.clear() super.onCleared() } @@ -317,7 +316,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)) { @@ -327,8 +329,11 @@ class MySiteViewModel @Inject constructor( dashboardItemsViewModelSlice.buildItems(site) dashboardCardsViewModelSlice.clearValue() } - // Trigger GutenbergView warmup for the selected site - gutenbergKitWarmupHelper.warmupIfNeeded(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/EditorServiceProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt new file mode 100644 index 000000000000..9a37011c71ae --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +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 +} + +@InstallIn(SingletonComponent::class) +@Module +interface EditorServiceProviderModule { + @Binds + fun bindEditorServiceProvider(impl: EditorServiceProviderImpl): EditorServiceProvider +} 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 new file mode 100644 index 000000000000..00cba88ced3e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -0,0 +1,177 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +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.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.gutenberg.model.EditorDependencies +import java.util.concurrent.ConcurrentHashMap +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. + * + * 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 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 + * + * 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 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( + @ApplicationContext private val appContext: Context, + 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 +) { + private sealed class PreloadState { + data class Loading(val job: Job) : PreloadState() + data class Ready( + val dependencies: EditorDependencies + ) : PreloadState() + } + + 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 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) { + if (!shouldPreload(site)) return + + val siteId = site.id + val job = scope.launch(bgDispatcher) { + try { + editorSettingsRepository + .fetchEditorCapabilitiesForSite(site) + val config = gutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + accessToken = accountStore.accessToken + ) + val result = editorServiceProvider.prepare( + context = appContext, + configuration = config, + coroutineScope = scope + ) + state[siteId] = PreloadState.Ready(result) + AppLog.d( + AppLog.T.EDITOR, + "Editor dependencies preloaded for" + + " site ${site.name}" + ) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception + ) { + AppLog.e( + AppLog.T.EDITOR, + "Failed to preload editor dependencies", + e + ) + state.remove(siteId) + } + } + state[siteId] = PreloadState.Loading(job) + } + + /** + * 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) { + clearSite(site) + preloadIfNeeded(site, scope) + } + + /** + * Returns the preloaded dependencies for [site], or `null` if + * preloading has not completed (or failed). Callers must handle + * `null` gracefully by loading dependencies themselves. + */ + @MainThread + fun getDependencies(site: SiteModel): EditorDependencies? = + getDependencies(site.id) + + @MainThread + fun getDependencies(siteLocalId: Int): EditorDependencies? = + (state[siteLocalId] as? PreloadState.Ready)?.dependencies + + /** + * Cancels all in-flight preloads and discards all cached + * results. Call when the driving scope is being destroyed. + */ + @MainThread + fun clear() { + 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() && + siteSettingsProvider.isBlockEditorDefault(site) + val isAlreadyHandled = when (val entry = state[site.id]) { + is PreloadState.Loading -> entry.job.isActive + is PreloadState.Ready -> true + null -> false + } + return isEnabled && !isAlreadyHandled + } +} 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 7d9e3bc09b69..e884f07e0a0a 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 @@ -2210,7 +2210,10 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene val post = editPostRepository.getPost() val configuration = buildEditorConfiguration(siteModel, post) - return GutenbergKitEditorFragment.newInstance(configuration) + return GutenbergKitEditorFragment.newInstance( + configuration, + siteModel + ) } private fun buildEditorConfiguration( 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 1595c640a965..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.wordpress.android.ui.posts - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.model.SiteModel -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.SiteUtils -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 gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, - @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 - } - - @Suppress("UnusedParameter") - private suspend fun performWarmup(site: SiteModel) { - // GutenbergView.warmup() was removed in GutenbergKit v0.15.0. - // Warmup/preloading needs to be reimplemented using the new API. - AppLog.d( - T.EDITOR, - "GutenbergKitWarmupHelper: Warmup not yet supported in v0.15.0" - ) - } -} 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 9c56674e6b5f..e1ba35ffae81 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,8 @@ 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 import org.wordpress.android.util.ProfilingUtils @@ -40,8 +42,12 @@ import org.wordpress.gutenberg.GutenbergView.TitleAndContentCallback import org.wordpress.gutenberg.Media import org.wordpress.gutenberg.model.EditorConfiguration 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 @@ -57,6 +63,8 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { 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") @@ -164,9 +172,10 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { ) ) + val siteLocalId = requireArguments().getInt(ARG_SITE_LOCAL_ID) val gutenbergView = GutenbergView( configuration = configuration, - dependencies = null, + dependencies = gutenbergEditorPreloader.getDependencies(siteLocalId), coroutineScope = this.lifecycleScope, context = requireContext() ) @@ -537,16 +546,19 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { const val ARG_FEATURED_IMAGE_ID: String = "featured_image_id" 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.putInt(ARG_SITE_LOCAL_ID, site.id) fragment.arguments = args return fragment } 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 c6c0ffc6c628..2fa571b853ad 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,9 +40,9 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewModelSlice import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker -import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.ui.pages.SnackbarMessageHolder -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper +import org.wordpress.android.repositories.EditorSettingsRepository +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 @@ -100,10 +100,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 @Mock lateinit var editorSettingsRepository: EditorSettingsRepository @@ -162,8 +162,8 @@ class MySiteViewModelTest : BaseUnitTest() { dashboardCardsViewModelSlice, dashboardItemsViewModelSlice, applicationPasswordViewModelSlice, - gutenbergKitWarmupHelper, siteCapabilityChecker, + gutenbergEditorPreloader, editorSettingsRepository, ) uiModels = mutableListOf() @@ -406,6 +406,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") 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..5072ba52a430 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -0,0 +1,467 @@ +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.EditorAssetBundle +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorSettings + +@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 + + private val editorDependencies = EditorDependencies.empty + + 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 = EditorDependencies( + editorSettings = EditorSettings.undefined, + assetBundle = EditorAssetBundle.empty, + preloadList = null + ) + 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) +}