diff --git a/WordPress/src/main/java/org/wordpress/android/WordPressDB.java b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java index 1b5e7f751d83..efee99035afb 100755 --- a/WordPress/src/main/java/org/wordpress/android/WordPressDB.java +++ b/WordPress/src/main/java/org/wordpress/android/WordPressDB.java @@ -22,7 +22,7 @@ import java.io.OutputStream; public class WordPressDB { - private static final int DATABASE_VERSION = 70; + private static final int DATABASE_VERSION = 71; // Warning renaming DATABASE_NAME could break previous App backups (see: xml/backup_scheme.xml) @@ -187,6 +187,9 @@ public WordPressDB(Context ctx) { case 69: // add editor theme styles site setting mDb.execSQL(SiteSettingsModel.ADD_USE_THEME_STYLES); + case 70: + // add third-party blocks setting + mDb.execSQL(SiteSettingsModel.ADD_USE_THIRD_PARTY_BLOCKS); } mDb.setVersion(DATABASE_VERSION); } diff --git a/WordPress/src/main/java/org/wordpress/android/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..30e34101dc1c 100644 --- a/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java +++ b/WordPress/src/main/java/org/wordpress/android/models/SiteSettingsModel.java @@ -70,6 +70,7 @@ public class SiteSettingsModel { private static final String JETPACK_SEARCH_SUPPORTED_COLUMN_NAME = "jetpackSearchSupported"; private static final String JETPACK_SEARCH_ENABLED_COLUMN_NAME = "jetpackSearchEnabled"; private static final String USE_THEME_STYLES_COLUMN_NAME = "useThemeStyles"; + private static final String USE_THIRD_PARTY_BLOCKS_COLUMN_NAME = "useThirdPartyBlocks"; public static final String SETTINGS_TABLE_NAME = "site_settings"; @@ -107,6 +108,9 @@ public class SiteSettingsModel { + " add " + SITE_ICON_COLUMN_NAME + " INTEGER;"; public static final String ADD_USE_THEME_STYLES = "alter table " + SETTINGS_TABLE_NAME + " add " + USE_THEME_STYLES_COLUMN_NAME + " BOOLEAN DEFAULT 1;"; + public static final String ADD_USE_THIRD_PARTY_BLOCKS = "alter table " + SETTINGS_TABLE_NAME + + " add " + USE_THIRD_PARTY_BLOCKS_COLUMN_NAME + + " BOOLEAN DEFAULT 0;"; public static final String CREATE_SETTINGS_TABLE_SQL = "CREATE TABLE IF NOT EXISTS " @@ -198,6 +202,7 @@ public class SiteSettingsModel { public boolean jetpackSearchSupported; public boolean jetpackSearchEnabled; public boolean useThemeStyles = true; + public boolean useThirdPartyBlocks = false; public String quotaDiskSpace; @Override @@ -243,6 +248,7 @@ && equals(timezone, otherModel.timezone) && jetpackSearchEnabled == otherModel.jetpackSearchEnabled && jetpackSearchSupported == otherModel.jetpackSearchSupported && useThemeStyles == otherModel.useThemeStyles + && useThirdPartyBlocks == otherModel.useThirdPartyBlocks && maxLinks == otherModel.maxLinks && equals(defaultPostFormat, otherModel.defaultPostFormat) && holdForModeration != null @@ -309,6 +315,7 @@ public void copyFrom(SiteSettingsModel other) { jetpackSearchSupported = other.jetpackSearchSupported; jetpackSearchEnabled = other.jetpackSearchEnabled; useThemeStyles = other.useThemeStyles; + useThirdPartyBlocks = other.useThirdPartyBlocks; if (other.holdForModeration != null) { holdForModeration = new ArrayList<>(other.holdForModeration); } @@ -374,6 +381,7 @@ public void deserializeOptionsDatabaseCursor(Cursor cursor, SparseArrayCompat { + val data = response.response.data + val supportsSettings = data + .hasRoute("/wp-block-editor/v1/settings") + val supportsAssets = data + .hasRoute("/wpcom/v2/editor-assets") + + AppLog.d( + T.EDITOR, + "EditorSettingsRepository: API root fetched" + + " for site=${site.name}" + + " supportsEditorSettings=$supportsSettings" + + " supportsEditorAssets=$supportsAssets" + ) + + appPrefsWrapper.setSiteSupportsEditorSettings( + site, supportsSettings + ) + appPrefsWrapper.setSiteSupportsEditorAssets( + site, supportsAssets + ) + } + else -> { + AppLog.w( + T.EDITOR, + "EditorSettingsRepository: API root request" + + " failed for site=${site.name}" + + " response=$response" + ) + + 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/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt index 0dacfb4cd9a4..33ff864e0be7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt @@ -31,6 +31,7 @@ import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.mediapicker.MediaPickerActivity import org.wordpress.android.ui.posts.BasicDialogViewModel +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -43,7 +44,6 @@ import javax.inject.Inject import javax.inject.Named import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper import org.wordpress.android.ui.utils.UiString @Suppress("LargeClass", "LongMethod", "LongParameterList") @@ -64,8 +64,8 @@ class MySiteViewModel @Inject constructor( private val dashboardCardsViewModelSlice: DashboardCardsViewModelSlice, private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice, private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice, - private val gutenbergKitWarmupHelper: GutenbergKitWarmupHelper, private val siteCapabilityChecker: SiteCapabilityChecker, + private val gutenbergEditorPreloader: GutenbergEditorPreloader, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() private val _onNavigation = MutableLiveData>() @@ -167,7 +167,7 @@ class MySiteViewModel @Inject constructor( if (isPullToRefresh) { siteCapabilityChecker.clearCacheForSite(site.siteId) } - buildDashboardOrSiteItems(site) + buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh) } ?: run { accountDataViewModelSlice.onRefresh() } @@ -248,7 +248,7 @@ class MySiteViewModel @Inject constructor( dashboardCardsViewModelSlice.onCleared() dashboardItemsViewModelSlice.onCleared() accountDataViewModelSlice.onCleared() - gutenbergKitWarmupHelper.clearWarmupState() + gutenbergEditorPreloader.clear() super.onCleared() } @@ -281,7 +281,10 @@ class MySiteViewModel @Inject constructor( } } - private fun buildDashboardOrSiteItems(site: SiteModel) { + private fun buildDashboardOrSiteItems( + site: SiteModel, + forceRefresh: Boolean = false + ) { siteInfoHeaderCardViewModelSlice.buildCard(site) applicationPasswordViewModelSlice.buildCard(site) if (shouldShowDashboard(site)) { @@ -291,8 +294,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/navmenus/screens/ObserveScrollDirectionForFab.kt b/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt index 0cb7bb357193..e1384ad601b2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/navmenus/screens/ObserveScrollDirectionForFab.kt @@ -58,7 +58,12 @@ fun ObserveLoadMore( } } - LaunchedEffect(lastVisibleItemIndex.value, itemCount, canLoadMore) { + LaunchedEffect( + lastVisibleItemIndex.value, + itemCount, + canLoadMore, + isLoadingMore + ) { val shouldLoadMore = lastVisibleItemIndex.value >= itemCount - 1 && canLoadMore && diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt index 774de5cee8e3..82560ad9cbea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostCustomerSupportHelper.kt @@ -37,6 +37,7 @@ object EditPostCustomerSupportHelper { private fun getTagsList(site: SiteModel): List? = // Append the "mobile_gutenberg_is_default" tag if gutenberg is set to default for new posts + @Suppress("DEPRECATION") if (SiteUtils.isBlockEditorDefaultForNewPost(site)) { listOf(ZendeskExtraTags.gutenbergIsDefault) } else { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt deleted file mode 100644 index a1bea3d546b0..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.wordpress.android.ui.posts - -import org.wordpress.android.util.UrlUtils -import org.wordpress.gutenberg.EditorConfiguration - -/** - * Utility object for building EditorConfiguration from settings maps. - * Eliminates duplication between GutenbergKitEditorFragment and GutenbergKitWarmupHelper. - */ -object EditorConfigurationBuilder { - /** - * Builds an EditorConfiguration from the provided settings map. - * - * @param settings The settings map containing all configuration values - * @param editorSettings Optional editor settings string (null for warmup scenarios) - * @return Configured EditorConfiguration instance - */ - fun build( - settings: Map, - editorSettings: String? = null - ): EditorConfiguration { - return EditorConfiguration.Builder().apply { - val postId = settings.getSetting("postId")?.let { if (it == 0) -1 else it } - val siteURL = settings.getSetting("siteURL") ?: "" - val siteApiNamespace = settings.getStringArray("siteApiNamespace") - - // Post settings - setTitle(settings.getSetting("postTitle") ?: "") - setContent(settings.getSetting("postContent") ?: "") - setPostId(postId) - setPostType(settings.getSetting("postType")) - - // Site settings - setSiteURL(siteURL) - setSiteApiRoot(settings.getSetting("siteApiRoot") ?: "") - setSiteApiNamespace(siteApiNamespace) - setNamespaceExcludedPaths(settings.getStringArray("namespaceExcludedPaths")) - setAuthHeader(settings.getSetting("authHeader") ?: "") - - // Features - setThemeStyles(settings.getSettingOrDefault("themeStyles", false)) - setPlugins(settings.getSettingOrDefault("plugins", false)) - setLocale(settings.getSetting("locale") ?: "en") - - // Editor asset caching configuration - configureEditorAssetCaching(settings, siteURL, siteApiNamespace) - - // Cookies - setCookies(settings.getSetting>("cookies") ?: emptyMap()) - - // Network logging for debugging - setEnableNetworkLogging(settings.getSettingOrDefault("enableNetworkLogging", false)) - - // Editor settings (null for warmup scenarios) - setEditorSettings(editorSettings) - }.build() - } - - private fun EditorConfiguration.Builder.configureEditorAssetCaching( - settings: Map, - siteURL: String, - siteApiNamespace: Array - ) { - setEnableAssetCaching(true) - - val siteHost = UrlUtils.getHost(siteURL) - val cachedHosts = if (!siteHost.isNullOrEmpty()) { - setOf("s0.wp.com", siteHost) - } else { - setOf("s0.wp.com") - } - setCachedAssetHosts(cachedHosts) - - val firstNamespace = siteApiNamespace.firstOrNull() ?: "" - val siteApiRoot = settings.getSetting("siteApiRoot") ?: "" - if (firstNamespace.isNotEmpty() && siteApiRoot.isNotEmpty()) { - setEditorAssetsEndpoint("${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets") - } - } - - // Type-safe settings accessors - moved from GutenbergKitEditorFragment - private inline fun Map.getSetting(key: String): T? = this[key] as? T - - private inline fun Map.getSettingOrDefault(key: String, default: T): T = - getSetting(key) ?: default - - private fun Map.getStringArray(key: String): Array = - getSetting>(key)?.asSequence()?.filterNotNull()?.toList()?.toTypedArray() ?: emptyArray() -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt index 3206849776e2..c1e024664d42 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt @@ -156,6 +156,7 @@ class EditorLauncher @Inject constructor( site: SiteModel ) { val hasGutenbergBlocks = PostUtils.contentContainsGutenbergBlocks(postContent) + @Suppress("DEPRECATION") val isBlockEditorDefaultForNewPosts = SiteUtils.isBlockEditorDefaultForNewPost(site) val postInfo = if (post != null) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt new file mode 100644 index 000000000000..99869816e25c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies + +/** + * Abstracts the creation and preparation of the GutenbergKit + * [EditorService] so callers can be tested without the real + * service. + */ +interface EditorServiceProvider { + suspend fun prepare( + context: Context, + configuration: EditorConfiguration, + coroutineScope: CoroutineScope + ): EditorDependencies +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt new file mode 100644 index 000000000000..5f5b744e8b70 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.services.EditorService +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EditorServiceProviderImpl @Inject constructor() : + EditorServiceProvider { + override suspend fun prepare( + context: Context, + configuration: EditorConfiguration, + coroutineScope: CoroutineScope + ): EditorDependencies { + val service = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + return service.prepare(null) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt 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 1ebf3fb74dd4..dade47fd3d6d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -84,11 +84,11 @@ 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 import org.wordpress.android.fluxc.store.EditorSettingsStore.FetchEditorSettingsPayload -import org.wordpress.android.fluxc.store.EditorSettingsStore.OnEditorSettingsChanged import org.wordpress.android.fluxc.store.EditorThemeStore import org.wordpress.android.fluxc.store.MediaStore import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType @@ -313,6 +313,8 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var editorThemeStore: EditorThemeStore + + @Inject lateinit var imageLoader: FluxCImageLoader @Inject lateinit var shortcutUtils: ShortcutUtils @@ -391,6 +393,7 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var editorBloggingPromptsViewModel: EditorBloggingPromptsViewModel @Inject lateinit var editorJetpackSocialViewModel: EditorJetpackSocialViewModel @Inject lateinit var gutenbergKitNetworkLogger: GutenbergKitNetworkLogger + @Inject lateinit var gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder private lateinit var editPostNavigationViewModel: EditPostNavigationViewModel private lateinit var editPostSettingsViewModel: EditPostSettingsViewModel private lateinit var prepublishingViewModel: PrepublishingViewModel @@ -2208,41 +2211,42 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene onXpostsSettingsCapability(isXpostsCapable) } - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig.fromSiteModel(siteModel) - - val postConfig = GutenbergKitSettingsBuilder.PostConfig.fromPostModel( - editPostRepository.getPost() - ) + val post = editPostRepository.getPost() + val configuration = buildEditorConfiguration(siteModel, post) - val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(), - isThemeStylesFeatureEnabled = siteSettings?.useThemeStyles ?: true, - isNetworkLoggingEnabled = AppPrefs.isTrackNetworkRequestsEnabled() + return GutenbergKitEditorFragment.newInstance( + configuration, + siteModel ) + } - val appConfig = GutenbergKitSettingsBuilder.AppConfig( - accessToken = accountStore.accessToken, - locale = perAppLocaleManager.getCurrentLocaleLanguageCode(), - cookies = editPostAuthViewModel.getCookiesForPrivateSites(site, privateAtomicCookie), - accountUserId = accountStore.account.userId, - accountUserName = accountStore.account.userName, - userAgent = userAgent, - isJetpackSsoEnabled = isJetpackSsoEnabled - ) + private fun buildEditorConfiguration( + site: SiteModel, + post: PostImmutableModel? + ): EditorConfiguration { + val base = gutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + post = post, + accessToken = accountStore.accessToken + ) - val config = GutenbergKitSettingsBuilder.GutenbergKitConfig( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = appConfig, - featureConfig = featureConfig - ) + val locale = perAppLocaleManager + .getCurrentLocaleLanguageCode() + .replace("_", "-").lowercase() - return GutenbergKitEditorFragment.newInstanceWithBuilder( - getContext(), - isNewPost, - jetpackFeatureRemovalPhaseHelper.shouldShowJetpackPoweredEditorFeatures(), - config - ) + return base.toBuilder() + .setLocale(locale) + .setCookies( + editPostAuthViewModel + .getCookiesForPrivateSites( + site, privateAtomicCookie + ) + ) + .setEnableNetworkLogging( + AppPrefs.isTrackNetworkRequestsEnabled() + ) + .build() } override fun instantiateItem(container: ViewGroup, position: Int): Any { @@ -3130,13 +3134,6 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene dispatcher.dispatch(EditorSettingsActionBuilder.newFetchEditorSettingsAction(payload)) } - @Suppress("unused") - @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) - fun onEditorSettingsChanged(event: OnEditorSettingsChanged) { - val editorSettingsString = event.editorSettings?.toJsonString() ?: "undefined" - editorFragment?.startWithEditorSettings(editorSettingsString) - } - // EditorDataProvider methods override fun getEditPostRepository() = editPostRepository override fun getSite() = siteModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 8e43e1eb2891..e2a2c1c7331d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -1,162 +1,85 @@ package org.wordpress.android.ui.posts import android.util.Base64 -import org.wordpress.android.editor.gutenberg.GutenbergWebViewAuthorizationData +import org.wordpress.android.datasets.SiteSettingsProvider 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.repositories.EditorSettingsRepository import org.wordpress.android.util.AppLog -import org.wordpress.android.util.UrlUtils - -object GutenbergKitSettingsBuilder { - private const val AUTH_BEARER_PREFIX = "Bearer " - private const val AUTH_BASIC_PREFIX = "Basic " - - data class SiteConfig( - val url: String, - val siteId: Long, - val isWPCom: Boolean, - val isWPComAtomic: Boolean, - val isJetpackConnected: Boolean, - val isUsingWpComRestApi: Boolean, - val wpApiRestUrl: String?, - val apiRestUsernamePlain: String?, - val apiRestPasswordPlain: String?, - val selfHostedSiteId: Long, - val webEditor: String?, - val apiRestUsernameProcessed: String?, - val apiRestPasswordProcessed: String? - ) { - companion object { - fun fromSiteModel(site: SiteModel): SiteConfig { - return SiteConfig( - url = site.url, - siteId = site.siteId, - isWPCom = site.isWPCom, - isWPComAtomic = site.isWPComAtomic, - isJetpackConnected = site.isJetpackConnected, - isUsingWpComRestApi = site.isUsingWpComRestApi, - wpApiRestUrl = site.wpApiRestUrl, - apiRestUsernamePlain = site.apiRestUsernamePlain, - apiRestPasswordPlain = site.apiRestPasswordPlain, - selfHostedSiteId = site.selfHostedSiteId, - webEditor = site.webEditor, - apiRestUsernameProcessed = site.getUserNameProcessed(), - apiRestPasswordProcessed = site.getPasswordProcessed() - ) - } - } - } - - data class PostConfig( - val remotePostId: Long?, - val isPage: Boolean, - val title: String?, - val content: String? - ) { - companion object { - fun fromPostModel(postModel: PostImmutableModel?): PostConfig { - return PostConfig( - remotePostId = postModel?.remotePostId, - isPage = postModel?.isPage ?: false, - title = postModel?.title, - content = postModel?.content - ) - } +import org.wordpress.gutenberg.model.EditorConfiguration +import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GutenbergKitSettingsBuilder @Inject constructor( + private val editorSettingsRepository: EditorSettingsRepository, + private val siteSettingsProvider: SiteSettingsProvider +) { + 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) "page" else "post", - "postTitle" to postConfig.title, - "postContent" to postConfig.content, - "siteURL" to siteConfig.url, - "siteApiRoot" to siteApiRoot, - "namespaceExcludedPaths" to arrayOf("/wpcom/v2/following/recommendations", "/wpcom/v2/following/mine"), - "authHeader" to authHeader, - "siteApiNamespace" to siteApiNamespace, - "themeStyles" to featureConfig.isThemeStylesFeatureEnabled, - "plugins" to shouldUsePlugins( - isFeatureEnabled = featureConfig.isPluginsFeatureEnabled, - isWPComSite = siteConfig.isWPCom, - isJetpackConnected = siteConfig.isJetpackConnected, - applicationPassword = applicationPassword - ), - "locale" to wpcomLocaleSlug, - "cookies" to appConfig.cookies, - "enableNetworkLogging" to featureConfig.isNetworkLoggingEnabled + val siteApiNamespace = buildSiteApiNamespace( + shouldUseWPComRestApi, site.siteId, site.url ) + + val postType = if (post?.isPage == true) "page" else "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?.toInt() + ) + setPostStatus(post?.status ?: "draft") + setAuthHeader(authHeader) + setSiteApiNamespace(siteApiNamespace) + setNamespaceExcludedPaths( + arrayOf( + "/wpcom/v2/following/recommendations", + "/wpcom/v2/following/mine" + ) + ) + setThemeStyles(getUseThemeStyles(site)) + setPlugins(getUseThirdPartyBlocks(site)) + setLocale("en") + setCookies(emptyMap()) + setEnableAssetCaching(true) + setCachedAssetHosts(cachedHosts) + setEditorAssetsEndpoint(editorAssetsEndpoint) + setEnableNetworkLogging(false) + }.build() } - /** - * Builds the authentication header based on the authentication method. - * - * @param shouldUseWPComRestApi True if using WP.com REST API (Bearer auth) - * @param accessToken The OAuth2 access token for WP.com authentication - * @param username The username for Basic auth (application passwords) - * @param password The password for Basic auth (application passwords) - * @return The formatted authentication header string, or null if credentials are invalid - */ - private fun buildAuthHeader( + fun buildAuthHeader( shouldUseWPComRestApi: Boolean, accessToken: String?, username: String?, @@ -166,7 +89,10 @@ object GutenbergKitSettingsBuilder { if (!accessToken.isNullOrEmpty()) { "$AUTH_BEARER_PREFIX$accessToken" } else { - AppLog.w(AppLog.T.EDITOR, "Missing access token for WP.com REST API authentication") + AppLog.w( + AppLog.T.EDITOR, + "Missing access token for WP.com REST API authentication" + ) null } } else { @@ -179,49 +105,98 @@ object GutenbergKitSettingsBuilder { ) "$AUTH_BASIC_PREFIX$encodedCredentials" } catch (e: IllegalArgumentException) { - AppLog.e(AppLog.T.EDITOR, "Failed to encode Basic auth credentials", e) + AppLog.e( + AppLog.T.EDITOR, + "Failed to encode Basic auth credentials", + e + ) null } } else { - AppLog.w(AppLog.T.EDITOR, "Incomplete credentials for Basic authentication") + AppLog.w( + AppLog.T.EDITOR, + "Incomplete credentials for Basic authentication" + ) null } } } - private fun shouldUsePlugins( + fun shouldUsePlugins( isFeatureEnabled: Boolean, isWPComSite: Boolean, isJetpackConnected: Boolean, applicationPassword: String? ): Boolean { - // Enable plugins for: - // 1. WP.com Simple sites (when feature is enabled) - // 2. Jetpack-connected sites with application passwords (when feature is enabled) return isFeatureEnabled && - (isWPComSite || (isJetpackConnected && !applicationPassword.isNullOrEmpty())) + (isWPComSite || + (isJetpackConnected && !applicationPassword.isNullOrEmpty())) } - /** - * Builds Gutenberg WebView authorization data for the fragment. - */ - fun buildAuthorizationData( - siteConfig: SiteConfig, - appConfig: AppConfig - ): GutenbergWebViewAuthorizationData { - return GutenbergWebViewAuthorizationData( - siteConfig.url, - siteConfig.isWPCom || siteConfig.isWPComAtomic, - appConfig.accountUserId, - appConfig.accountUserName, - appConfig.accessToken, - siteConfig.selfHostedSiteId, - siteConfig.apiRestUsernameProcessed, - siteConfig.apiRestPasswordProcessed, - siteConfig.isUsingWpComRestApi, - siteConfig.webEditor, - appConfig.userAgent.webViewUserAgent, - appConfig.isJetpackSsoEnabled - ) + internal fun buildSiteApiNamespace( + shouldUseWPComRestApi: Boolean, + siteId: Long, + siteUrl: String + ): Array { + if (!shouldUseWPComRestApi) return arrayOf() + val host = extractHost(siteUrl) + return if (host != null) { + arrayOf("sites/$siteId/", "sites/$host/") + } else { + arrayOf("sites/$siteId/") + } + } + + private fun getUseThemeStyles(site: SiteModel): Boolean { + if (!editorSettingsRepository + .getSupportsEditorSettingsForSite(site) + ) { + return false + } + return siteSettingsProvider + .getSettings(site)?.useThemeStyles ?: true + } + + private fun getUseThirdPartyBlocks(site: SiteModel): Boolean { + if (!editorSettingsRepository + .getSupportsEditorAssetsForSite(site) + ) { + return false + } + return siteSettingsProvider + .getSettings(site)?.useThirdPartyBlocks ?: false + } + + 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/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt deleted file mode 100644 index f84f326fb1f7..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt +++ /dev/null @@ -1,154 +0,0 @@ -package org.wordpress.android.ui.posts - -import android.content.Context -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.UserAgent -import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.modules.BG_THREAD -import org.wordpress.android.util.AppLog -import org.wordpress.android.util.AppLog.T -import org.wordpress.android.util.PerAppLocaleManager -import org.wordpress.android.util.SiteUtils -import org.wordpress.android.util.config.GutenbergKitPluginsFeature -import org.wordpress.gutenberg.EditorConfiguration -import org.wordpress.gutenberg.GutenbergView -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -/** - * Helper class to manage GutenbergView warmup for preloading editor assets. - * This improves editor launch speed by caching WebView assets before the editor is opened. - */ -@Singleton -class GutenbergKitWarmupHelper @Inject constructor( - private val appContext: Context, - private val accountStore: AccountStore, - private val userAgent: UserAgent, - private val perAppLocaleManager: PerAppLocaleManager, - private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, - private val gutenbergKitPluginsFeature: GutenbergKitPluginsFeature, - @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher -) { - private var lastWarmedUpSiteId: Long? = null - private var isWarmupInProgress = false - - /** - * Triggers warmup for the given site if not already warmed up. - * - * @param site The site to warm up the editor for - * @param scope The coroutine scope to launch the warmup in - */ - fun warmupIfNeeded(site: SiteModel?, scope: CoroutineScope) { - when { - site == null -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - no site provided") - } - lastWarmedUpSiteId == site.siteId && !isWarmupInProgress -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Already warmed up for site ${site.siteId}") - } - isWarmupInProgress -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup already in progress") - } - !shouldWarmupForSite(site) -> { - // Logging handled within shouldWarmupForSite() - } - else -> { - scope.launch(bgDispatcher) { - performWarmup(site) - } - } - } - } - - /** - * Clears the warmup state when switching sites or logging out. - */ - fun clearWarmupState() { - lastWarmedUpSiteId = null - isWarmupInProgress = false - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup state cleared") - } - - private fun shouldWarmupForSite(site: SiteModel): Boolean { - if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled()) { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - GutenbergKit features disabled") - return false - } - - val shouldWarmup = SiteUtils.isBlockEditorDefaultForNewPost(site) - - if (shouldWarmup) { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warming site ${site.siteId} " + - "(isBlockEditorDefault: true, webEditor: ${site.webEditor})") - } else { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - site ${site.siteId} doesn't " + - "default to the block editor for new posts " + - "(isBlockEditorDefault: false, webEditor: ${site.webEditor})") - } - - return shouldWarmup - } - - private suspend fun performWarmup(site: SiteModel) { - try { - isWarmupInProgress = true - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Starting warmup for site ${site.siteId}") - - val configuration = buildWarmupConfiguration(site) - - // Perform the warmup on the main thread as it involves WebView - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - GutenbergView.warmup(appContext, configuration) - } - - lastWarmedUpSiteId = site.siteId - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup completed for site ${site.siteId}") - } catch (e: IllegalStateException) { - AppLog.e(T.EDITOR, "GutenbergKitWarmupHelper: Warmup failed - illegal state", e) - } finally { - isWarmupInProgress = false - } - } - - private fun buildWarmupConfiguration(site: SiteModel): EditorConfiguration { - // Build the configuration using the same patterns as GutenbergKitSettingsBuilder - val siteConfig = GutenbergKitSettingsBuilder.SiteConfig.fromSiteModel(site) - - // Create minimal post config for warmup (no specific post data) - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = null, - isPage = false, - title = "", - content = "" - ) - - val appConfig = GutenbergKitSettingsBuilder.AppConfig( - accessToken = accountStore.accessToken, - locale = perAppLocaleManager.getCurrentLocaleLanguageCode(), - cookies = null, // No cookies needed for warmup - accountUserId = accountStore.account.userId, - accountUserName = accountStore.account.userName, - userAgent = userAgent, - isJetpackSsoEnabled = false // Default to false for warmup - ) - - val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(), - // Default to true during warmup; actual value will be used when editor launches - isThemeStylesFeatureEnabled = true - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = appConfig, - featureConfig = featureConfig - ) - - return EditorConfigurationBuilder.build(settings, editorSettings = null) - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index eb865c9803ab..25fc7d0ad9a4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -13,6 +13,8 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.webkit.URLUtil +import android.widget.FrameLayout +import androidx.core.os.BundleCompat import androidx.core.util.Pair import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope @@ -24,15 +26,13 @@ import org.wordpress.android.editor.EditorEditMediaListener import org.wordpress.android.editor.EditorFragmentAbstract import org.wordpress.android.editor.EditorImagePreviewListener import org.wordpress.android.editor.LiveTextWatcher -import org.wordpress.android.editor.gutenberg.GutenbergWebViewAuthorizationData -import org.wordpress.android.editor.savedinstance.SavedInstanceDatabase.Companion.getDatabase -import org.wordpress.android.ui.posts.EditorConfigurationBuilder -import org.wordpress.android.ui.posts.GutenbergKitSettingsBuilder +import org.wordpress.android.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 import org.wordpress.android.util.helpers.MediaFile -import org.wordpress.gutenberg.EditorConfiguration +import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView import org.wordpress.gutenberg.GutenbergView.ContentChangeListener import org.wordpress.gutenberg.GutenbergView.FeaturedImageChangeListener @@ -41,10 +41,13 @@ import org.wordpress.gutenberg.GutenbergView.LogJsExceptionListener import org.wordpress.gutenberg.GutenbergView.OpenMediaLibraryListener import org.wordpress.gutenberg.GutenbergView.TitleAndContentCallback import org.wordpress.gutenberg.Media -import java.io.Serializable import java.util.concurrent.CountDownLatch +import javax.inject.Inject class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { + @Inject + lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader + private var gutenbergView: GutenbergView? = null private var isHtmlModeEnabled = false @@ -55,22 +58,19 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { private var onLogJsExceptionListener: LogJsExceptionListener? = null private var modalDialogStateListener: GutenbergView.ModalDialogStateListener? = null private var networkRequestListener: GutenbergView.NetworkRequestListener? = null - - private var editorStarted = false - private var isEditorDidMount = false private var rootView: View? = null private var isXPostsEnabled: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + (requireActivity().application as org.wordpress.android.WordPress) + .component().inject(this) ProfilingUtils.start("Visual Editor Startup") ProfilingUtils.split("EditorFragment.onCreate") if (savedInstanceState != null) { isHtmlModeEnabled = savedInstanceState.getBoolean(KEY_HTML_MODE_ENABLED) - editorStarted = savedInstanceState.getBoolean(KEY_EDITOR_STARTED) - isEditorDidMount = savedInstanceState.getBoolean(KEY_EDITOR_DID_MOUNT) mFeaturedImageId = savedInstanceState.getLong(ARG_FEATURED_IMAGE_ID) } } @@ -145,11 +145,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - if (arguments != null) { - @Suppress("UNCHECKED_CAST", "DEPRECATION") - settings = requireArguments().getSerializable(ARG_GUTENBERG_KIT_SETTINGS) as Map? - } - // Set up fragment's own listeners before initializing the editor initializeFragmentListeners() @@ -158,60 +153,84 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { rootView = inflater.inflate(R.layout.fragment_gutenberg_kit_editor, container, false) val gutenbergViewContainer = rootView!!.findViewById(R.id.gutenberg_view_container) - gutenbergView = GutenbergView.createForEditor(requireContext()).also { gutenbergView -> - gutenbergView.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + val configuration = requireNotNull( + BundleCompat.getParcelable( + requireArguments(), + ARG_GUTENBERG_KIT_SETTINGS, + EditorConfiguration::class.java + ) + ) + + val siteLocalId = requireArguments().getInt(ARG_SITE_LOCAL_ID) + val gutenbergView = GutenbergView( + configuration = configuration, + dependencies = gutenbergEditorPreloader + .getDependencies(siteLocalId), + coroutineScope = this.lifecycleScope, + context = requireContext() + ) + + gutenbergViewContainer.addView( + gutenbergView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT ) - gutenbergViewContainer.addView(gutenbergView) + ) - gutenbergView.setOnFileChooserRequestedListener { intent: Intent?, requestCode: Int? -> - @Suppress("DEPRECATION") startActivityForResult(intent!!, requestCode!!) - null + gutenbergView.setOnFileChooserRequestedListener { intent: Intent?, requestCode: Int? -> + @Suppress("DEPRECATION") startActivityForResult(intent!!, requestCode!!) + null + } + gutenbergView.setContentChangeListener(object : ContentChangeListener { + override fun onContentChanged() { + textWatcher.postTextChanged() } - gutenbergView.setContentChangeListener(object : ContentChangeListener { - override fun onContentChanged() { - textWatcher.postTextChanged() - } - }) - historyChangeListener?.let(gutenbergView::setHistoryChangeListener) - featuredImageChangeListener?.let(gutenbergView::setFeaturedImageChangeListener) - openMediaLibraryListener?.let(gutenbergView::setOpenMediaLibraryListener) - onLogJsExceptionListener?.let(gutenbergView::setLogJsExceptionListener) - modalDialogStateListener?.let(gutenbergView::setModalDialogStateListener) - networkRequestListener?.let(gutenbergView::setNetworkRequestListener) - - // Set up autocomplete listener for user mentions and cross-post suggestions - gutenbergView.setAutocompleterTriggeredListener(object : GutenbergView.AutocompleterTriggeredListener { - override fun onAutocompleterTriggered(type: String) { - when (type) { - "at-symbol" -> mEditorFragmentListener.showUserSuggestions { result -> - result?.let { - // Appended space completes the autocomplete session - gutenbergView.appendTextAtCursor("$it ") - } + }) + + historyChangeListener?.let(gutenbergView::setHistoryChangeListener) + featuredImageChangeListener?.let(gutenbergView::setFeaturedImageChangeListener) + openMediaLibraryListener?.let(gutenbergView::setOpenMediaLibraryListener) + onLogJsExceptionListener?.let(gutenbergView::setLogJsExceptionListener) + modalDialogStateListener?.let(gutenbergView::setModalDialogStateListener) + networkRequestListener?.let(gutenbergView::setNetworkRequestListener) + + // Set up autocomplete listener for user mentions and cross-post suggestions + gutenbergView.setAutocompleterTriggeredListener(object : GutenbergView.AutocompleterTriggeredListener { + override fun onAutocompleterTriggered(type: String) { + when (type) { + "at-symbol" -> mEditorFragmentListener.showUserSuggestions { result -> + result?.let { + // Appended space completes the autocomplete session + gutenbergView.appendTextAtCursor("$it ") } - "plus-symbol" -> { - if (isXPostsEnabled) { - mEditorFragmentListener.showXpostSuggestions { result -> - result?.let { - // Appended space completes the autocomplete session - gutenbergView.appendTextAtCursor("$it ") - } + } + "plus-symbol" -> { + if (isXPostsEnabled) { + mEditorFragmentListener.showXpostSuggestions { result -> + result?.let { + // Appended space completes the autocomplete session + gutenbergView.appendTextAtCursor("$it ") } } } } } - }) + } + }) - gutenbergView.setEditorDidBecomeAvailable { - isEditorDidMount = true - mEditorFragmentListener.onEditorFragmentContentReady(ArrayList(), false) - setEditorProgressBarVisibility(false) + gutenbergView.setLatestContentProvider( + object : GutenbergView.LatestContentProvider { + override fun getLatestContent(): + GutenbergView.LatestContent? { + return null + } } - } + ) - setEditorProgressBarVisibility(true) + gutenbergView.setEditorDidBecomeAvailable { + mEditorFragmentListener.onEditorFragmentContentReady(ArrayList(), false) + } return rootView } @@ -251,17 +270,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { } } - override fun onResume() { - super.onResume() - setEditorProgressBarVisibility(!isEditorDidMount) - } - - private fun setEditorProgressBarVisibility(shown: Boolean) { - if (isAdded) { - rootView?.findViewById(R.id.editor_progress).setVisibleOrGone(shown) - } - } - @Deprecated("Deprecated in Java") @Suppress("DEPRECATION") override fun onRequestPermissionsResult( @@ -292,15 +300,8 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { } } - // View extension functions - private fun View?.setVisibleOrGone(visible: Boolean) { - this?.visibility = if (visible) View.VISIBLE else View.GONE - } - override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(KEY_HTML_MODE_ENABLED, isHtmlModeEnabled) - outState.putBoolean(KEY_EDITOR_STARTED, editorStarted) - outState.putBoolean(KEY_EDITOR_DID_MOUNT, isEditorDidMount) outState.putLong(ARG_FEATURED_IMAGE_ID, mFeaturedImageId) } @@ -445,25 +446,12 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { override fun onDestroy() { gutenbergView?.let { gutenbergView -> - gutenbergView.destroy() historyChangeListener = null featuredImageChangeListener = null } - editorStarted = false - isEditorDidMount = false super.onDestroy() } - fun startWithEditorSettings(editorSettings: String) { - if (gutenbergView == null || editorStarted) { - return - } - - val config = buildEditorConfiguration(editorSettings) - editorStarted = true - gutenbergView?.start(config) - } - fun setXPostsEnabled(enabled: Boolean) { isXPostsEnabled = enabled } @@ -473,11 +461,6 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { gutenbergView?.setNetworkRequestListener(listener) } - private fun buildEditorConfiguration(editorSettings: String): EditorConfiguration { - val settingsMap = settings!! - return EditorConfigurationBuilder.build(settingsMap, editorSettings) - } - override fun onUndoPressed() { gutenbergView?.undo() } @@ -493,67 +476,27 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { companion object { private const val GUTENBERG_EDITOR_NAME = "gutenberg" private const val KEY_HTML_MODE_ENABLED = "KEY_HTML_MODE_ENABLED" - private const val KEY_EDITOR_STARTED = "KEY_EDITOR_STARTED" - private const val KEY_EDITOR_DID_MOUNT = "KEY_EDITOR_DID_MOUNT" - private const val ARG_IS_NEW_POST = "param_is_new_post" - private const val ARG_GUTENBERG_WEB_VIEW_AUTH_DATA = "param_gutenberg_web_view_auth_data" const val ARG_FEATURED_IMAGE_ID: String = "featured_image_id" - const val ARG_JETPACK_FEATURES_ENABLED: String = "jetpack_features_enabled" - const val ARG_GUTENBERG_KIT_SETTINGS: String = "gutenberg_kit_settings" + 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 - private var settings: Map? = null - fun newInstance( - context: Context, - isNewPost: Boolean, - webViewAuthorizationData: GutenbergWebViewAuthorizationData?, - jetpackFeaturesEnabled: Boolean, - settings: Map? + configuration: EditorConfiguration, + site: SiteModel ): GutenbergKitEditorFragment { val fragment = GutenbergKitEditorFragment() val args = Bundle() - args.putBoolean(ARG_IS_NEW_POST, isNewPost) - args.putBoolean(ARG_JETPACK_FEATURES_ENABLED, jetpackFeaturesEnabled) - args.putSerializable(ARG_GUTENBERG_KIT_SETTINGS, settings as Serializable?) - fragment.setArguments(args) - val db = getDatabase(context) - GutenbergKitEditorFragment.settings = settings - db?.addParcel(ARG_GUTENBERG_WEB_VIEW_AUTH_DATA, webViewAuthorizationData) - return fragment - } - - /** - * Simplified factory method that uses GutenbergKitSettingsBuilder for configuration. - * This reduces the activity's responsibility for detailed fragment setup. - */ - fun newInstanceWithBuilder( - context: Context, - isNewPost: Boolean, - jetpackFeaturesEnabled: Boolean, - config: GutenbergKitSettingsBuilder.GutenbergKitConfig - ): GutenbergKitEditorFragment { - val authorizationData = GutenbergKitSettingsBuilder.buildAuthorizationData( - siteConfig = config.siteConfig, - appConfig = config.appConfig - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = config.siteConfig, - postConfig = config.postConfig, - appConfig = config.appConfig, - featureConfig = config.featureConfig - ) - - return newInstance( - context, - isNewPost, - authorizationData, - jetpackFeaturesEnabled, - settings + 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/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 967107aec596..1fe9614c90ea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -16,6 +16,7 @@ import org.wordpress.android.analytics.AnalyticsTracker; import org.wordpress.android.analytics.AnalyticsTracker.Stat; import org.wordpress.android.fluxc.model.JetpackCapability; +import org.wordpress.android.fluxc.model.SiteModel; import org.wordpress.android.models.ReaderTag; import org.wordpress.android.models.ReaderTagType; import org.wordpress.android.ui.ActivityId; @@ -196,6 +197,9 @@ public enum DeletablePrefKey implements PrefKey { READER_READING_PREFERENCES_JSON, SHOULD_SHOW_READER_ANNOUNCEMENT_CARD, STATS_CARDS_CONFIGURATION_JSON, + SITE_SUPPORTS_EDITOR_SETTINGS, + SITE_SUPPORTS_EDITOR_ASSETS, + SITE_THEME_IS_BLOCK_THEME, } /** @@ -1817,4 +1821,43 @@ public static int getTrackNetworkRequestsRetentionPeriod() { public static void setTrackNetworkRequestsRetentionPeriod(int period) { setInt(UndeletablePrefKey.TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, period); } + + public static boolean getSiteSupportsEditorSettings(@NonNull SiteModel site) { + return prefs().getBoolean(getSiteSupportsEditorSettingsKey(site), false); + } + + public static void setSiteSupportsEditorSettings(@NonNull SiteModel site, boolean supports) { + prefs().edit().putBoolean(getSiteSupportsEditorSettingsKey(site), supports).apply(); + } + + @NonNull + private static String getSiteSupportsEditorSettingsKey(@NonNull SiteModel site) { + return DeletablePrefKey.SITE_SUPPORTS_EDITOR_SETTINGS.name() + site.getId(); + } + + public static boolean getSiteSupportsEditorAssets(@NonNull SiteModel site) { + return prefs().getBoolean(getSiteSupportsEditorAssetsKey(site), false); + } + + public static void setSiteSupportsEditorAssets(@NonNull SiteModel site, boolean supports) { + prefs().edit().putBoolean(getSiteSupportsEditorAssetsKey(site), supports).apply(); + } + + @NonNull + private static String getSiteSupportsEditorAssetsKey(@NonNull SiteModel site) { + return DeletablePrefKey.SITE_SUPPORTS_EDITOR_ASSETS.name() + site.getId(); + } + + public static boolean getSiteThemeIsBlockTheme(@NonNull SiteModel site) { + return prefs().getBoolean(getSiteThemeIsBlockThemeKey(site), false); + } + + public static void setSiteThemeIsBlockTheme(@NonNull SiteModel site, boolean isBlockTheme) { + prefs().edit().putBoolean(getSiteThemeIsBlockThemeKey(site), isBlockTheme).apply(); + } + + @NonNull + private static String getSiteThemeIsBlockThemeKey(@NonNull SiteModel site) { + return DeletablePrefKey.SITE_THEME_IS_BLOCK_THEME.name() + site.getId(); + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 2706800222e8..858c1e839503 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -3,6 +3,7 @@ package org.wordpress.android.ui.prefs import androidx.core.content.edit import com.google.gson.Gson import org.wordpress.android.fluxc.model.JetpackCapability +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.models.ReaderTag import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhase import org.wordpress.android.ui.posts.AuthorFilterSelection @@ -505,6 +506,24 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra get() = AppPrefs.getSupportEmail() set(value) = AppPrefs.setSupportEmail(value) + fun getSiteSupportsEditorSettings(site: SiteModel): Boolean = + AppPrefs.getSiteSupportsEditorSettings(site) + + fun setSiteSupportsEditorSettings(site: SiteModel, supports: Boolean) = + AppPrefs.setSiteSupportsEditorSettings(site, supports) + + fun getSiteSupportsEditorAssets(site: SiteModel): Boolean = + AppPrefs.getSiteSupportsEditorAssets(site) + + fun setSiteSupportsEditorAssets(site: SiteModel, supports: Boolean) = + AppPrefs.setSiteSupportsEditorAssets(site, supports) + + fun getSiteThemeIsBlockTheme(site: SiteModel): Boolean = + AppPrefs.getSiteThemeIsBlockTheme(site) + + fun setSiteThemeIsBlockTheme(site: SiteModel, isBlockTheme: Boolean) = + AppPrefs.setSiteThemeIsBlockTheme(site, isBlockTheme) + var isTrackNetworkRequestsEnabled: Boolean get() = AppPrefs.isTrackNetworkRequestsEnabled() set(value) = AppPrefs.setTrackNetworkRequestsEnabled(value) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java index 454c8e008f2c..17285a08c3a2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java @@ -80,6 +80,7 @@ import org.wordpress.android.ui.bloggingreminders.BloggingRemindersViewModel; import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper; import org.wordpress.android.util.PlansConstants; +import org.wordpress.android.repositories.EditorSettingsRepository; import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker; import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation.ValidationType; import org.wordpress.android.ui.prefs.SiteSettingsFormatDialog.FormatType; @@ -194,6 +195,7 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; + @Inject EditorSettingsRepository mEditorSettingsRepository; private BloggingRemindersViewModel mBloggingRemindersViewModel; @@ -228,6 +230,7 @@ public class SiteSettingsFragment extends PreferenceFragment // Writing settings private WPSwitchPreference mGutenbergDefaultForNewPosts; private WPSwitchPreference mUseThemeStylesPref; + private WPSwitchPreference mUseThirdPartyBlocksPref; private DetailListPreference mCategoryPref; private DetailListPreference mFormatPref; private WPPreference mDateFormatPref; @@ -848,6 +851,8 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { AnalyticsUtils.refreshMetadata(mAccountStore, mSiteStore); } else if (preference == mUseThemeStylesPref) { mSiteSettings.setUseThemeStyles((Boolean) newValue); + } else if (preference == mUseThirdPartyBlocksPref) { + mSiteSettings.setUseThirdPartyBlocks((Boolean) newValue); } else if (preference == mBloggingPromptsPref) { final boolean isEnabled = (boolean) newValue; mPromptsSettingsHelper.updatePromptsCardEnabledBlocking(mSite.getId(), isEnabled); @@ -1037,6 +1042,10 @@ public void initPreferences() { (WPSwitchPreference) getChangePref(R.string.pref_key_use_theme_styles); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); + mUseThirdPartyBlocksPref = + (WPSwitchPreference) getChangePref(R.string.pref_key_use_third_party_blocks); + mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); + mSiteAcceleratorSettings = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings); mSiteAcceleratorSettingsNested = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings_nested); @@ -1081,6 +1090,35 @@ public void initPreferences() { // hide theme styles preference if GutenbergKit is not enabled if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled()) { WPPrefUtils.removePreference(this, R.string.pref_key_site_editor, R.string.pref_key_use_theme_styles); + } else if (!mEditorSettingsRepository.getSupportsEditorSettingsForSite(mSite)) { + mUseThemeStylesPref.setEnabled(false); + mUseThemeStylesPref.setSummary( + getString(R.string.site_settings_use_theme_styles_summary) + + "\n\n" + + getString(R.string.site_settings_use_theme_styles_unsupported) + ); + } else if (!mEditorSettingsRepository.getThemeSupportsBlockStyles(mSite)) { + mUseThemeStylesPref.setSummary( + getString(R.string.site_settings_use_theme_styles_summary) + + "\n\n" + + getString( + R.string.site_settings_use_theme_styles_not_block_theme + ) + ); + } + + // hide third-party blocks preference if GutenbergKit is not enabled + if (!mGutenbergKitFeatureChecker.isGutenbergKitEnabled()) { + WPPrefUtils.removePreference( + this, R.string.pref_key_site_editor, R.string.pref_key_use_third_party_blocks + ); + } else if (!mEditorSettingsRepository.getSupportsEditorAssetsForSite(mSite)) { + mUseThirdPartyBlocksPref.setEnabled(false); + mUseThirdPartyBlocksPref.setSummary( + getString(R.string.site_settings_use_third_party_blocks_summary) + + "\n\n" + + getString(R.string.site_settings_use_third_party_blocks_unsupported) + ); } // hide Admin options depending of capabilities on this site @@ -1207,7 +1245,8 @@ public void setEditingEnabled(boolean enabled) { mDateFormatPref, mTimeFormatPref, mTimezonePref, mBloggingRemindersPref, mPostsPerPagePref, mAmpPref, mDeleteSitePref, mJpMonitorActivePref, mJpMonitorEmailNotesPref, mJpSsoPref, mJpMonitorWpNotesPref, mJpBruteForcePref, mJpAllowlistPref, mJpMatchEmailPref, mJpUseTwoFactorPref, - mGutenbergDefaultForNewPosts, mUseThemeStylesPref, mHomepagePref, mBloggingPromptsPref + mGutenbergDefaultForNewPosts, mUseThemeStylesPref, mUseThirdPartyBlocksPref, + mHomepagePref, mBloggingPromptsPref }; for (Preference preference : editablePreference) { @@ -1552,6 +1591,7 @@ public void setPreferencesFromSiteSettings() { mWeekStartPref.setSummary(mWeekStartPref.getEntry()); mGutenbergDefaultForNewPosts.setChecked(SiteUtils.isBlockEditorDefaultForNewPost(mSite)); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); + mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); setAdFreeHostingChecked(mSiteSettings.isAdFreeHostingEnabled()); boolean checked = mSiteSettings.isImprovedSearchEnabled() || mSiteSettings.getJetpackSearchEnabled(); mImprovedSearch.setChecked(checked); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java index adf1a19c456c..f68f87774683 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsInterface.java @@ -628,6 +628,14 @@ public void setUseThemeStyles(boolean enabled) { mSettings.useThemeStyles = enabled; } + public boolean getUseThirdPartyBlocks() { + return mSettings.useThirdPartyBlocks; + } + + public void setUseThirdPartyBlocks(boolean enabled) { + mSettings.useThirdPartyBlocks = enabled; + } + public boolean isJetpackMonitorEnabled() { return mJpSettings.monitorActive; } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java index beba14352ea5..85d8d0fa213a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/WPComSiteSettings.java @@ -182,10 +182,12 @@ public void onResponse(JSONObject response) { // Local settings boolean location = mSettings.location; boolean useThemeStyles = mSettings.useThemeStyles; + boolean useThirdPartyBlocks = mSettings.useThirdPartyBlocks; mSettings.copyFrom(mRemoteSettings); mSettings.postFormats = currentPostFormats; mSettings.location = location; mSettings.useThemeStyles = useThemeStyles; + mSettings.useThirdPartyBlocks = useThirdPartyBlocks; SiteSettingsTable.saveSettings(mSettings); } diff --git a/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java b/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java index c94fe407feab..9bcbee81cd32 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java +++ b/WordPress/src/main/java/org/wordpress/android/util/SiteUtils.java @@ -196,6 +196,10 @@ public static void disableBlockEditor(Dispatcher dispatcher, SiteModel siteModel new DesignateMobileEditorPayload(siteModel, AZTEC_EDITOR_NAME))); } + /** + * @deprecated Use {@code SiteSettingsProvider.isBlockEditorDefault()} instead. + */ + @Deprecated public static boolean isBlockEditorDefaultForNewPost(@Nullable SiteModel site) { if (site == null) { return true; @@ -208,6 +212,10 @@ public static boolean isBlockEditorDefaultForNewPost(@Nullable SiteModel site) { } } + /** + * @deprecated Use {@code SiteSettingsProvider.isBlockEditorDefault()} instead. + */ + @Deprecated public static boolean alwaysDefaultToGutenberg(SiteModel site) { return site.isWPCom() && !site.isWPComAtomic(); } diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt index e4e475f05276..ce3931a01adc 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/mlp/ModalLayoutPickerViewModel.kt @@ -144,6 +144,7 @@ class ModalLayoutPickerViewModel @Inject constructor( * at this point the only requirement is to have the block editor enabled * @return true if the Modal Layout Picker can be shown */ + @Suppress("DEPRECATION") fun canShowModalLayoutPicker() = SiteUtils.isBlockEditorDefaultForNewPost(site) /** diff --git a/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml b/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml index d20f0298540a..2e013f222801 100644 --- a/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml +++ b/WordPress/src/main/res/layout/fragment_gutenberg_kit_editor.xml @@ -9,10 +9,4 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - diff --git a/WordPress/src/main/res/values/key_strings.xml b/WordPress/src/main/res/values/key_strings.xml index efee36015b10..8d84013f5305 100644 --- a/WordPress/src/main/res/values/key_strings.xml +++ b/WordPress/src/main/res/values/key_strings.xml @@ -56,6 +56,7 @@ wp_pref_key_optimize_video wp_pref_key_gutenberg_default_for_new_posts wp_pref_key_use_theme_styles + wp_pref_key_use_third_party_blocks wp_pref_site_default_video_width wp_pref_site_default_encoder_bitrate wp_pref_site_discussion diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index fb0fdc2e0cbd..afac7090902b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -698,7 +698,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..467b91d41808 100644 --- a/WordPress/src/main/res/xml/site_settings.xml +++ b/WordPress/src/main/res/xml/site_settings.xml @@ -138,6 +138,13 @@ android:summary="@string/site_settings_use_theme_styles_summary" android:title="@string/site_settings_use_theme_styles" /> + + 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() + } +} 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..542b8bd7123e --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/repositories/ThemeRepositoryTest.kt @@ -0,0 +1,143 @@ +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" + ) + 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 + ) +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt index f22245be043c..f4a2c9a22bca 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt @@ -40,7 +40,7 @@ import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewMode import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker import org.wordpress.android.ui.pages.SnackbarMessageHolder -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -98,10 +98,10 @@ class MySiteViewModelTest : BaseUnitTest() { lateinit var applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice @Mock - lateinit var gutenbergKitWarmupHelper: GutenbergKitWarmupHelper + lateinit var siteCapabilityChecker: SiteCapabilityChecker @Mock - lateinit var siteCapabilityChecker: SiteCapabilityChecker + lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader private lateinit var viewModel: MySiteViewModel private lateinit var uiModels: MutableList @@ -156,8 +156,8 @@ class MySiteViewModelTest : BaseUnitTest() { dashboardCardsViewModelSlice, dashboardItemsViewModelSlice, applicationPasswordViewModelSlice, - gutenbergKitWarmupHelper, siteCapabilityChecker, + gutenbergEditorPreloader, ) uiModels = mutableListOf() snackbars = mutableListOf() @@ -399,6 +399,19 @@ class MySiteViewModelTest : BaseUnitTest() { verify(accountDataViewModelSlice).onCleared() verify(dashboardCardsViewModelSlice).onCleared() verify(dashboardItemsViewModelSlice).onCleared() + verify(gutenbergEditorPreloader).clear() + } + + @Test + fun `when dashboard is built, then editor preload is triggered`() { + initSelectedSite() + + viewModel.refresh() + + verify(gutenbergEditorPreloader).preloadIfNeeded( + org.mockito.kotlin.eq(siteTest), + org.mockito.kotlin.any() + ) } @Suppress("LongParameterList") 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) +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt index 3f63e25fcfa3..73a978d89d8e 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilderTest.kt @@ -1,661 +1,738 @@ package org.wordpress.android.ui.posts -import android.content.Context import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner -import org.wordpress.android.fluxc.network.UserAgent +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.repositories.EditorSettingsRepository @RunWith(MockitoJUnitRunner::class) -@Suppress("LargeClass") class GutenbergKitSettingsBuilderTest { - // ===== Plugin Logic Tests ===== @Mock - lateinit var appContext: Context + lateinit var editorSettingsRepository: EditorSettingsRepository + + @Mock + lateinit var siteSettingsProvider: SiteSettingsProvider + + private val builder by lazy { + GutenbergKitSettingsBuilder( + editorSettingsRepository, + siteSettingsProvider + ) + } + // ===== Auth Header Tests ===== @Test - fun `plugins disabled when feature flag is off regardless of site configuration`() { - val testCases = listOf( - // isWPCom, isJetpackConnected, applicationPassword - Triple(true, false, null), // WPCom site - Triple(false, true, "password"), // Jetpack with password - Triple(false, false, null), // Self-hosted + fun `WPCom site returns Bearer token header`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = "my_token", + username = null, + password = null ) - testCases.forEach { (isWPCom, isJetpack, password) -> - val siteConfig = createSiteConfig( - isWPCom = isWPCom, - isJetpackConnected = isJetpack, - apiRestPasswordPlain = password - ) + assertThat(header).isEqualTo("Bearer my_token") + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + @Test + fun `WPCom site with null token returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = null, + username = null, + password = null + ) - featureConfig = createFeatureConfig(), // Both features disabled - ) + assertThat(header).isNull() + } - assertThat(settings["plugins"]) - .withFailMessage("Expected plugins=false for WPCom=$isWPCom, Jetpack=$isJetpack, password=$password") - .isEqualTo(false) - } + @Test + fun `WPCom site with empty token returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = true, + accessToken = "", + username = null, + password = null + ) + + assertThat(header).isNull() } @Test - fun `plugins enabled for WPCom sites when feature flag is on`() { - val siteConfig = createSiteConfig(isWPCom = true) + 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(), + assertThat(header).isNotNull() + assertThat(header).startsWith("Basic ") + } - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + @Test + fun `Basic auth with null username returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = null, + password = "password123" + ) + assertThat(header).isNull() + } + + @Test + fun `Basic auth with empty username returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "", + password = "password123" ) - 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 `Basic auth with null password returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "username", + password = null ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + assertThat(header).isNull() + } - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + @Test + fun `Basic auth with empty password returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "username", + password = "" + ) + + assertThat(header).isNull() + } + @Test + fun `Basic auth with both empty returns null`() { + val header = builder.buildAuthHeader( + shouldUseWPComRestApi = false, + accessToken = null, + username = "", + password = "" ) - assertThat(settings["plugins"]).isEqualTo(true) + assertThat(header).isNull() } @Test - fun `plugins disabled for Jetpack sites without application password`() { - val passwordVariants = listOf(null, "") + 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" + ) - passwordVariants.forEach { password -> - val siteConfig = createSiteConfig( - isWPCom = false, - isJetpackConnected = true, - apiRestPasswordPlain = password - ) + assertThat(header).isNotNull() + assertThat(header).startsWith("Basic ") + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + // ===== Plugin Logic Tests ===== - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + @Test + fun `plugins disabled when feature flag is off`() { + val testCases = listOf( + Triple(true, false, null), + Triple(false, true, "password"), + Triple(false, false, null), + ) + testCases.forEach { (isWPCom, isJetpack, password) -> + val result = builder.shouldUsePlugins( + isFeatureEnabled = false, + isWPComSite = isWPCom, + isJetpackConnected = isJetpack, + applicationPassword = password ) - assertThat(settings["plugins"]) - .withFailMessage("Expected plugins=false for password=$password") - .isEqualTo(false) + assertThat(result) + .withFailMessage( + "Expected false for WPCom=$isWPCom, " + + "Jetpack=$isJetpack, password=$password" + ) + .isFalse() } } @Test - fun `plugins disabled for self-hosted sites without Jetpack`() { - val siteConfig = createSiteConfig( - isWPCom = false, + fun `plugins enabled for WPCom sites when feature is on`() { + val result = builder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = true, isJetpackConnected = false, - apiRestPasswordPlain = "password" // Has password but no Jetpack + applicationPassword = null ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true), + assertThat(result).isTrue() + } + @Test + fun `plugins enabled for Jetpack with app password when feature is on`() { + val result = builder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = false, + isJetpackConnected = true, + applicationPassword = "validPassword" ) - assertThat(settings["plugins"]).isEqualTo(false) + assertThat(result).isTrue() } - // ===== Authentication Flow Tests ===== + @Test + fun `plugins disabled for Jetpack without app password`() { + listOf(null, "").forEach { password -> + val result = builder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = false, + isJetpackConnected = true, + applicationPassword = password + ) + + assertThat(result) + .withFailMessage( + "Expected false for password=$password" + ) + .isFalse() + } + } @Test - fun `WPCom site uses Bearer token and public API`() { - val siteConfig = createSiteConfig( - url = "https://example.wordpress.com", - siteId = 123, - isWPCom = true, - isUsingWpComRestApi = true + fun `plugins disabled for self-hosted without Jetpack`() { + val result = builder.shouldUsePlugins( + isFeatureEnabled = true, + isWPComSite = false, + isJetpackConnected = false, + applicationPassword = "password" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "test_bearer_token"), + assertThat(result).isFalse() + } - featureConfig = createFeatureConfig(), + // ===== Site API Namespace Tests ===== + @Test + fun `namespace is empty for non-WPCom sites`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = false, + siteId = 123L, + siteUrl = "https://example.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).isEmpty() } @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 site ID and host for WPCom sites`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 456L, + siteUrl = "https://example.wordpress.com" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "unused_token"), - - featureConfig = createFeatureConfig(), + assertThat(result).containsExactly( + "sites/456/", + "sites/example.wordpress.com/" + ) + } + @Test + fun `namespace includes only site ID when host extraction fails`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 789L, + siteUrl = "not-a-valid-url" ) - assertThat(settings["authHeader"] as String).startsWith("Basic ") - assertThat(settings["siteApiRoot"]).isEqualTo("https://mysite.com/wp-json/") - assertThat(settings["siteApiNamespace"] as Array<*>).isEmpty() + assertThat(result).containsExactly("sites/789/") } + // ===== Extract Host Tests ===== + @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 returns host from valid URL`() { + assertThat( + builder.extractHost( + "https://example.wordpress.com" + ) + ).isEqualTo("example.wordpress.com") + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(accessToken = "fallback_token"), + @Test + fun `extractHost returns null for invalid URL`() { + assertThat( + builder.extractHost("not-a-url") + ).isNull() + } - featureConfig = createFeatureConfig(), + @Test + fun `extractHost strips path from URL`() { + assertThat( + builder.extractHost( + "https://example.com/blog/page" + ) + ).isEqualTo("example.com") + } - ) + // ===== buildPostConfiguration Tests ===== + + // --- WPCom site configuration --- - assertThat(settings["authHeader"]).isEqualTo("Bearer fallback_token") - assertThat(settings["siteApiRoot"]).isEqualTo("https://public-api.wordpress.com/") + @Test + fun `WPCom site uses WPCom API root`() { + val config = buildWPComConfig() + + 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" ) + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + // --- Application password overrides WPCom REST API --- + + @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("post") + } + + @Test + fun `null post title becomes empty string`() { + val config = buildWPComConfig() - assertThat(settings["authHeader"]).isNull() + 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() + } - assertThat(settings["authHeader"]).isEqualTo("Bearer valid_token_123") + @Test + fun `null post status defaults to draft`() { + val config = buildWPComConfig() + + assertThat(config.postStatus).isEqualTo("draft") } + // --- Asset caching --- + @Test - fun `Valid Basic auth returns proper Basic header`() { - val siteConfig = createSiteConfig( - isJetpackConnected = true, - apiRestUsernamePlain = "testuser", - apiRestPasswordPlain = "testpass" + fun `asset caching is always enabled`() { + val config = buildWPComConfig() + + assertThat(config.enableAssetCaching).isTrue() + } + + @Test + 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() + + assertThat(config.namespaceExcludedPaths).containsExactly( + "/wpcom/v2/following/recommendations", + "/wpcom/v2/following/mine" ) + } - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), - featureConfig = createFeatureConfig() + // --- Site URL passthrough --- + + @Test + fun `site URL is passed through to configuration`() { + val config = buildWPComConfig( + siteUrl = "https://example.wordpress.com" ) - val authHeader = settings["authHeader"] as String? - assertThat(authHeader).isNotNull() - assertThat(authHeader).startsWith("Basic ") + assertThat(config.siteURL) + .isEqualTo("https://example.wordpress.com") } - // ===== Complete Scenario Tests ===== + // ===== buildCachedHosts (via buildPostConfiguration) ===== @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 - ) - - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = 456L, - isPage = false, - title = "Test Post", - content = "Test Content" - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = createAppConfig( - accessToken = "test_token", - cookies = "test_cookies" - ), - featureConfig = createFeatureConfig( - isPluginsFeatureEnabled = true, - isThemeStylesFeatureEnabled = true - ) + fun `cached hosts includes site host for subdirectory URL`() { + val config = buildWPComConfig( + siteUrl = "https://example.com/blog" ) - // Verify all settings are correctly configured - assertThat(settings["postId"]).isEqualTo(456) - assertThat(settings["postType"]).isEqualTo("post") - assertThat(settings["postTitle"]).isEqualTo("Test Post") - assertThat(settings["postContent"]).isEqualTo("Test Content") - assertThat(settings["siteURL"]).isEqualTo("https://example.wordpress.com") - assertThat(settings["authHeader"]).isEqualTo("Bearer test_token") - assertThat(settings["siteApiRoot"]).isEqualTo("https://public-api.wordpress.com/") - assertThat(settings["plugins"]).isEqualTo(true) // WPCom with feature enabled - assertThat(settings["themeStyles"]).isEqualTo(true) - assertThat(settings["locale"]).isEqualTo("en-us") - assertThat(settings["cookies"]).isEqualTo("test_cookies") + assertThat(config.cachedAssetHosts).containsExactlyInAnyOrder( + "s0.wp.com", + "example.com" + ) } @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" - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = postConfig, - appConfig = createAppConfig( - accessToken = "unused", - locale = "fr_FR" - ), - featureConfig = createFeatureConfig(isPluginsFeatureEnabled = true) - ) - - assertThat(settings["postType"]).isEqualTo("page") - assertThat(settings["authHeader"] as String).startsWith("Basic ") - assertThat(settings["siteApiRoot"]).isEqualTo("https://jetpack-site.com/wp-json/") - assertThat(settings["siteApiNamespace"] as Array<*>).isEmpty() - assertThat(settings["plugins"]).isEqualTo(true) // Jetpack with password and feature enabled - assertThat(settings["locale"]).isEqualTo("fr-fr") - } - - @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() - ) + 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(), + @Test + fun `editor assets endpoint for non-WPCom site uses API root`() { + val config = buildSelfHostedConfig() - featureConfig = createFeatureConfig( - isPluginsFeatureEnabled = plugins, - isThemeStylesFeatureEnabled = themes - ), - ) + assertThat(config.editorAssetsEndpoint).isEqualTo( + "https://mysite.com/wp-json/wpcom/v2/editor-assets" + ) + } + + // ===== buildSiteApiNamespace edge cases ===== + + @Test + fun `namespace with empty URL returns only site ID`() { + val result = builder.buildSiteApiNamespace( + shouldUseWPComRestApi = true, + siteId = 321L, + siteUrl = "" + ) - assertThat(settings["plugins"]).isEqualTo(expected.first) - assertThat(settings["themeStyles"]).isEqualTo(expected.second) + assertThat(result).containsExactly("sites/321/") + } + + // ===== Post type and ID edge cases ===== + + @Test + fun `page post results in page post type`() { + val site = SiteModel().apply { + url = "https://example.wordpress.com" + siteId = 123L + setIsWPCom(true) + setIsJetpackConnected(false) + origin = SiteModel.ORIGIN_WPCOM_REST } + val post = PostModel().apply { + setIsPage(true) + } + val config = builder.buildPostConfiguration( + site = site, + post = post, + accessToken = "test_token" + ) + + assertThat(config.postType).isEqualTo("page") } @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 `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" ) - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = siteConfig, - postConfig = createPostConfig(), - appConfig = createAppConfig(), + assertThat(config.postId).isEqualTo(42) + } - featureConfig = createFeatureConfig(), + // ===== SiteSettingsProvider Tests ===== - ) + @Test + fun `theme styles defaults to true when settings are null`() { + whenever( + editorSettingsRepository + .getSupportsEditorSettingsForSite(any()) + ).thenReturn(true) + whenever( + siteSettingsProvider.getSettings(any()) + ).thenReturn(null) + + val config = buildWPComConfig() + + assertThat(config.themeStyles).isTrue() + } - assertThat(settings["siteApiRoot"]).isEqualTo("https://selfhosted.org/wp-json/") + @Test + fun `third party blocks defaults to false when settings are null`() { + whenever( + editorSettingsRepository + .getSupportsEditorAssetsForSite(any()) + ).thenReturn(true) + whenever( + siteSettingsProvider.getSettings(any()) + ).thenReturn(null) + + val config = buildWPComConfig() + + assertThat(config.plugins).isFalse() } @Test - fun `namespaceExcludedPaths is always included`() { - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = createPostConfig(), - appConfig = createAppConfig(), + fun `feature unsupported skips settings provider for theme styles`() { + whenever( + editorSettingsRepository + .getSupportsEditorSettingsForSite(any()) + ).thenReturn(false) - featureConfig = createFeatureConfig(), + val config = buildWPComConfig() - ) + assertThat(config.themeStyles).isFalse() + verify(siteSettingsProvider, never()) + .getSettings(any()) + } - val excludedPaths = settings["namespaceExcludedPaths"] as Array<*> - assertThat(excludedPaths).containsExactly( - "/wpcom/v2/following/recommendations", - "/wpcom/v2/following/mine" + @Test + fun `feature unsupported skips settings provider for plugins`() { + whenever( + editorSettingsRepository + .getSupportsEditorAssetsForSite(any()) + ).thenReturn(false) + + val config = buildWPComConfig() + + assertThat(config.plugins).isFalse() + verify(siteSettingsProvider, never()) + .getSettings(any()) + } + + // ===== Helpers ===== + + private fun buildWPComConfig( + 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 ) } - @Test - fun `null post data is handled correctly`() { - val postConfig = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = null, - isPage = false, - title = null, - content = null - ) - - val settings = GutenbergKitSettingsBuilder.buildSettings( - siteConfig = createSiteConfig(), - postConfig = postConfig, - appConfig = createAppConfig(), - - featureConfig = createFeatureConfig(), - - ) - - assertThat(settings["postId"]).isNull() - assertThat(settings["postTitle"]).isNull() - assertThat(settings["postContent"]).isNull() - assertThat(settings["postType"]).isEqualTo("post") // Still defaults to post - } - - // ===== Helper Methods ===== - - private fun createFeatureConfig( - isPluginsFeatureEnabled: Boolean = false, - isThemeStylesFeatureEnabled: Boolean = false - ) = GutenbergKitSettingsBuilder.FeatureConfig( - isPluginsFeatureEnabled = isPluginsFeatureEnabled, - isThemeStylesFeatureEnabled = isThemeStylesFeatureEnabled - ) - - private fun createAppConfig( - accessToken: String? = "token", - locale: String = "en_US", - cookies: Any? = null - ) = GutenbergKitSettingsBuilder.AppConfig( - accessToken = accessToken, - locale = locale, - cookies = cookies, - accountUserId = 123L, - accountUserName = "testuser", - userAgent = UserAgent(appContext = appContext, appName = "foo"), - isJetpackSsoEnabled = false - ) - - private fun createSiteConfig( - url: String = "https://test.com", - siteId: Long = 1, - isWPCom: Boolean = false, - isWPComAtomic: Boolean = false, - isJetpackConnected: Boolean = false, - isUsingWpComRestApi: Boolean = false, - wpApiRestUrl: String? = null, - apiRestUsernamePlain: String? = null, - apiRestPasswordPlain: String? = null - ) = GutenbergKitSettingsBuilder.SiteConfig( - url = url, - siteId = siteId, - isWPCom = isWPCom, - isWPComAtomic = isWPComAtomic, - isJetpackConnected = isJetpackConnected, - isUsingWpComRestApi = isUsingWpComRestApi, - wpApiRestUrl = wpApiRestUrl, - apiRestUsernamePlain = apiRestUsernamePlain, - apiRestPasswordPlain = apiRestPasswordPlain, - selfHostedSiteId = siteId, - webEditor = "gutenberg", - apiRestUsernameProcessed = apiRestUsernamePlain, - apiRestPasswordProcessed = apiRestPasswordPlain - ) - - private fun createPostConfig( - remotePostId: Long? = 1L, - isPage: Boolean = false, - title: String? = "Test", - content: String? = "Content" - ) = GutenbergKitSettingsBuilder.PostConfig( - remotePostId = remotePostId, - isPage = isPage, - title = title, - content = content - ) + private fun buildSelfHostedConfig( + siteUrl: String = "https://mysite.com", + wpApiRestUrl: String? = "https://mysite.com/wp-json/", + applicationPassword: String? = "app_pass", + apiRestUsername: String? = "admin" + ): org.wordpress.gutenberg.model.EditorConfiguration { + val site = SiteModel().apply { + url = siteUrl + siteId = 999L + setIsWPCom(false) + setIsJetpackConnected(false) + this.wpApiRestUrl = wpApiRestUrl + apiRestPasswordPlain = applicationPassword + apiRestUsernamePlain = apiRestUsername + } + return builder.buildPostConfiguration( + site = site, + accessToken = null + ) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 279d1759b988..eb14683b1a8f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,7 +74,7 @@ google-play-services-auth = '20.4.1' google-services = '4.4.4' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = 'v0.11.1' +gutenberg-kit = '316-34bc052ce3435b4189a4cbcec18d5d7b63ee34b3' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java index 0635d78e55a2..cbf55451786a 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java @@ -438,6 +438,10 @@ public void setXmlRpcUrl(String xmlRpcUrl) { } public String getWpApiRestUrl() { + if (isWPComSimpleSite()) { + return "https://public-api.wordpress.com/wp/v2/sites/" + + mSiteId; + } return mWpApiRestUrl; } @@ -926,6 +930,10 @@ public boolean hasDiskSpaceQuotaInformation() { return mSpaceAllowed > 0; } + public boolean isWPComSimpleSite() { + return isWPCom() && !isWPComAtomic(); + } + public boolean isWPComAtomic() { return mIsWPComAtomic; } diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt index 9b3f0982ef07..79da89ea6cb1 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 @@ -7,40 +7,69 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.module.OkHttpClientQualifiers +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler import rs.wordpress.api.kotlin.WpApiClient import rs.wordpress.api.kotlin.WpHttpClient import rs.wordpress.api.kotlin.WpRequestExecutor import uniffi.wp_api.CookiesNonceAuthenticationProvider import uniffi.wp_api.WpAppNotifier +import uniffi.wp_api.WpAuthentication import uniffi.wp_api.WpAuthenticationProvider +import uniffi.wp_api.WpOrgSiteApiUrlResolver +import uniffi.wp_api.ParsedUrl +import uniffi.wp_api.WpComBaseUrl +import uniffi.wp_api.WpComDotOrgApiUrlResolver // CHECKSTYLE IGNORE import java.net.URL import javax.inject.Inject import javax.inject.Named class WpApiClientProvider @Inject constructor( private val wpAppNotifierHandler: WpAppNotifierHandler, + private val accessToken: AccessToken, @Named(OkHttpClientQualifiers.INTERCEPTORS) private val interceptors: Set<@JvmSuppressWildcards Interceptor>, ) { fun getWpApiClient( site: SiteModel, uploadListener: WpRequestExecutor.UploadListener? = null ): WpApiClient { - val authProvider = WpAuthenticationProvider.staticWithUsernameAndPassword( - username = site.apiRestUsernamePlain, password = site.apiRestPasswordPlain - ) - val apiRootUrl = URL(site.buildUrl()) - val client = WpApiClient( - wpOrgSiteApiRootUrl = apiRootUrl, + val authProvider = if (site.isWPComSimpleSite) { + // A WP.com simple site auth provider + WpAuthenticationProvider.staticWithAuth( + WpAuthentication.Bearer(accessToken.get()) + ) + } else { + WpAuthenticationProvider.staticWithUsernameAndPassword( + username = site.apiRestUsernamePlain, + password = site.apiRestPasswordPlain + ) + } + + val urlResolver = if (site.isWPComSimpleSite) { + WpComDotOrgApiUrlResolver( // CHECKSTYLE IGNORE + siteId = site.siteId.toString(), + baseUrl = WpComBaseUrl.Production + ) + } else { + WpOrgSiteApiUrlResolver(ParsedUrl.parse(site.buildUrl())) + } + + return WpApiClient( + apiUrlResolver = urlResolver, authProvider = authProvider, - requestExecutor = WpRequestExecutor(interceptors = interceptors.toList(), uploadListener = uploadListener), + requestExecutor = WpRequestExecutor( + interceptors = interceptors.toList(), + uploadListener = uploadListener + ), appNotifier = object : WpAppNotifier { - override suspend fun requestedWithInvalidAuthentication(requestUrl: String) { - wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(site) + override suspend fun requestedWithInvalidAuthentication( + requestUrl: String + ) { + wpAppNotifierHandler + .notifyRequestedWithInvalidAuthentication(site) } } ) - return client } fun getWpApiClientCookiesNonceAuthentication(site: SiteModel): WpApiClient {