From 12084a6b231957636f32ec6042b9d1dfe580a97e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:07:43 -0600 Subject: [PATCH] Integrate GutenbergKit editor preloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `GutenbergEditorPreloader`, a `@Singleton` that prepares `EditorDependencies` per site on dashboard refresh so the editor can open with warm dependencies instead of loading them at launch. - Wires preloading into `MySiteViewModel.buildDashboardOrSiteItems`, replacing the previous `GutenbergKitWarmupHelper` warmup flow. The separate `fetchEditorCapabilitiesWithSnackbar` path is unchanged — the preloader and the snackbar flow share the same repository call, deduplicated at the cache layer. - `GutenbergKitActivity` passes the site to `GutenbergKitEditorFragment`; the fragment resolves preloaded `EditorDependencies` from the preloader by site local ID instead of building them inline. - Removes `GutenbergKitWarmupHelper`. --- .../android/modules/AppComponent.java | 3 + .../android/ui/mysite/MySiteViewModel.kt | 25 +- .../android/ui/posts/EditorServiceProvider.kt | 30 ++ .../ui/posts/EditorServiceProviderImpl.kt | 26 + .../ui/posts/GutenbergEditorPreloader.kt | 177 +++++++ .../android/ui/posts/GutenbergKitActivity.kt | 5 +- .../ui/posts/GutenbergKitWarmupHelper.kt | 93 ---- .../editor/GutenbergKitEditorFragment.kt | 16 +- .../android/ui/mysite/MySiteViewModelTest.kt | 23 +- .../ui/posts/GutenbergEditorPreloaderTest.kt | 467 ++++++++++++++++++ 10 files changed, 754 insertions(+), 111 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt delete mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java index b25e94a5b64c..70dc7be2a0f5 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/AppComponent.java @@ -82,6 +82,7 @@ import org.wordpress.android.ui.posts.AddCategoryFragment; import org.wordpress.android.ui.posts.EditPostActivity; import org.wordpress.android.ui.posts.GutenbergKitActivity; +import org.wordpress.android.ui.posts.editor.GutenbergKitEditorFragment; import org.wordpress.android.ui.posts.EditPostPublishSettingsFragment; import org.wordpress.android.ui.posts.EditPostSettingsFragment; import org.wordpress.android.ui.posts.HistoryListFragment; @@ -255,6 +256,8 @@ public interface AppComponent { void inject(GutenbergKitActivity object); + void inject(GutenbergKitEditorFragment object); + void inject(EditPostSettingsFragment object); void inject(PostSettingsListDialogFragment object); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt index 479884efdfcf..2d4695f7cb4a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt @@ -31,6 +31,8 @@ import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.mediapicker.MediaPickerActivity import org.wordpress.android.ui.posts.BasicDialogViewModel +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -43,9 +45,7 @@ import javax.inject.Inject import javax.inject.Named import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper import org.wordpress.android.ui.utils.UiString -import org.wordpress.android.repositories.EditorSettingsRepository @Suppress("LargeClass", "LongMethod", "LongParameterList") class MySiteViewModel @Inject constructor( @@ -65,8 +65,8 @@ class MySiteViewModel @Inject constructor( private val dashboardCardsViewModelSlice: DashboardCardsViewModelSlice, private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice, private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice, - private val gutenbergKitWarmupHelper: GutenbergKitWarmupHelper, private val siteCapabilityChecker: SiteCapabilityChecker, + private val gutenbergEditorPreloader: GutenbergEditorPreloader, private val editorSettingsRepository: EditorSettingsRepository, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() @@ -169,7 +169,7 @@ class MySiteViewModel @Inject constructor( if (isPullToRefresh) { siteCapabilityChecker.clearCacheForSite(site.siteId) } - buildDashboardOrSiteItems(site) + buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh) launch { fetchEditorCapabilitiesWithSnackbar( site, @@ -211,8 +211,7 @@ class MySiteViewModel @Inject constructor( Event( SnackbarMessageHolder( UiString.UiStringRes( - R.string - .site_settings_fetch_failed + R.string.site_settings_fetch_failed ) ) ) @@ -284,7 +283,7 @@ class MySiteViewModel @Inject constructor( dashboardCardsViewModelSlice.onCleared() dashboardItemsViewModelSlice.onCleared() accountDataViewModelSlice.onCleared() - gutenbergKitWarmupHelper.clearWarmupState() + gutenbergEditorPreloader.clear() super.onCleared() } @@ -317,7 +316,10 @@ class MySiteViewModel @Inject constructor( } } - private fun buildDashboardOrSiteItems(site: SiteModel) { + private fun buildDashboardOrSiteItems( + site: SiteModel, + forceRefresh: Boolean = false + ) { siteInfoHeaderCardViewModelSlice.buildCard(site) applicationPasswordViewModelSlice.buildCard(site) if (shouldShowDashboard(site)) { @@ -327,8 +329,11 @@ class MySiteViewModel @Inject constructor( dashboardItemsViewModelSlice.buildItems(site) dashboardCardsViewModelSlice.clearValue() } - // Trigger GutenbergView warmup for the selected site - gutenbergKitWarmupHelper.warmupIfNeeded(site, viewModelScope) + if (forceRefresh) { + gutenbergEditorPreloader.refreshPreloading(site, viewModelScope) + } else { + gutenbergEditorPreloader.preloadIfNeeded(site, viewModelScope) + } } private fun onSitePicked(site: SiteModel) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt new file mode 100644 index 000000000000..9a37011c71ae --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProvider.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies + +/** + * Abstracts the creation and preparation of the GutenbergKit + * [EditorService] so callers can be tested without the real + * service. + */ +interface EditorServiceProvider { + suspend fun prepare( + context: Context, + configuration: EditorConfiguration, + coroutineScope: CoroutineScope + ): EditorDependencies +} + +@InstallIn(SingletonComponent::class) +@Module +interface EditorServiceProviderModule { + @Binds + fun bindEditorServiceProvider(impl: EditorServiceProviderImpl): EditorServiceProvider +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt new file mode 100644 index 000000000000..5f5b744e8b70 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorServiceProviderImpl.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.services.EditorService +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EditorServiceProviderImpl @Inject constructor() : + EditorServiceProvider { + override suspend fun prepare( + context: Context, + configuration: EditorConfiguration, + coroutineScope: CoroutineScope + ): EditorDependencies { + val service = EditorService.create( + context = context, + configuration = configuration, + coroutineScope = coroutineScope + ) + return service.prepare(null) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt new file mode 100644 index 000000000000..00cba88ced3e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -0,0 +1,177 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import androidx.annotation.MainThread +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.util.AppLog +import org.wordpress.gutenberg.model.EditorDependencies +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Opportunistically preloads GutenbergKit editor dependencies in the + * background so the editor opens faster. + * + * Cached dependencies are keyed by site local ID, so switching + * between sites does not discard previously preloaded results. + * + * ## Usage + * + * - [preloadIfNeeded] — idempotent; call whenever a site becomes + * visible. Skips work if the site was already preloaded or a job + * is in flight. + * - [refreshPreloading] — discards the cached result for a site + * and re-preloads from scratch (e.g. on pull-to-refresh). + * - [getDependencies] — returns the cached result for a site, or + * `null` if preloading has not completed. Callers must handle + * `null` gracefully by loading dependencies themselves. + * - [clear] — cancels all in-flight work and releases all cached + * data. Call when the driving scope is being destroyed. + * + * ## Threading + * + * Public methods are annotated [@MainThread] and must only be + * called from the main thread. [state] is a [ConcurrentHashMap], + * so the background coroutine can safely write [Ready] or remove + * entries without thread-hopping. + * + * ## Deduplication + * + * Preloading is skipped when the site already has a cached result + * or an in-flight job. On failure the entry is removed so the + * next visit retries automatically. If a caller's coroutine scope + * is cancelled externally, [shouldPreload] detects the dead + * [Loading] entry and allows a fresh attempt. + */ +@Singleton +class GutenbergEditorPreloader @Inject constructor( + @ApplicationContext private val appContext: Context, + private val accountStore: AccountStore, + private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, + private val gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder, + private val siteSettingsProvider: SiteSettingsProvider, + private val editorServiceProvider: EditorServiceProvider, + private val editorSettingsRepository: EditorSettingsRepository, + @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher +) { + private sealed class PreloadState { + data class Loading(val job: Job) : PreloadState() + data class Ready( + val dependencies: EditorDependencies + ) : PreloadState() + } + + private val state = ConcurrentHashMap() + + /** + * Starts a background preload for [site] if one hasn't already + * been performed for this site and no job is currently in + * flight for it. + * + * [scope] is the caller's [CoroutineScope] (typically + * `viewModelScope`); the launched coroutine is cancelled when + * that scope is cancelled. + */ + @MainThread + fun preloadIfNeeded(site: SiteModel, scope: CoroutineScope) { + if (!shouldPreload(site)) return + + val siteId = site.id + val job = scope.launch(bgDispatcher) { + try { + editorSettingsRepository + .fetchEditorCapabilitiesForSite(site) + val config = gutenbergKitSettingsBuilder + .buildPostConfiguration( + site = site, + accessToken = accountStore.accessToken + ) + val result = editorServiceProvider.prepare( + context = appContext, + configuration = config, + coroutineScope = scope + ) + state[siteId] = PreloadState.Ready(result) + AppLog.d( + AppLog.T.EDITOR, + "Editor dependencies preloaded for" + + " site ${site.name}" + ) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception + ) { + AppLog.e( + AppLog.T.EDITOR, + "Failed to preload editor dependencies", + e + ) + state.remove(siteId) + } + } + state[siteId] = PreloadState.Loading(job) + } + + /** + * Discards any cached result for [site] and re-preloads from + * scratch. Use for pull-to-refresh or any scenario where the + * caller wants to force a fresh fetch. + */ + @MainThread + fun refreshPreloading(site: SiteModel, scope: CoroutineScope) { + clearSite(site) + preloadIfNeeded(site, scope) + } + + /** + * Returns the preloaded dependencies for [site], or `null` if + * preloading has not completed (or failed). Callers must handle + * `null` gracefully by loading dependencies themselves. + */ + @MainThread + fun getDependencies(site: SiteModel): EditorDependencies? = + getDependencies(site.id) + + @MainThread + fun getDependencies(siteLocalId: Int): EditorDependencies? = + (state[siteLocalId] as? PreloadState.Ready)?.dependencies + + /** + * Cancels all in-flight preloads and discards all cached + * results. Call when the driving scope is being destroyed. + */ + @MainThread + fun clear() { + state.values.forEach { entry -> + if (entry is PreloadState.Loading) entry.job.cancel() + } + state.clear() + } + + private fun clearSite(site: SiteModel) { + val entry = state.remove(site.id) + if (entry is PreloadState.Loading) entry.job.cancel() + } + + private fun shouldPreload(site: SiteModel): Boolean { + val isEnabled = + gutenbergKitFeatureChecker.isGutenbergKitEnabled() && + siteSettingsProvider.isBlockEditorDefault(site) + val isAlreadyHandled = when (val entry = state[site.id]) { + is PreloadState.Loading -> entry.job.isActive + is PreloadState.Ready -> true + null -> false + } + return isEnabled && !isAlreadyHandled + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 7d9e3bc09b69..e884f07e0a0a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -2210,7 +2210,10 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene val post = editPostRepository.getPost() val configuration = buildEditorConfiguration(siteModel, post) - return GutenbergKitEditorFragment.newInstance(configuration) + return GutenbergKitEditorFragment.newInstance( + configuration, + siteModel + ) } private fun buildEditorConfiguration( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt deleted file mode 100644 index 1595c640a965..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitWarmupHelper.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.wordpress.android.ui.posts - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.modules.BG_THREAD -import org.wordpress.android.util.AppLog -import org.wordpress.android.util.AppLog.T -import org.wordpress.android.util.SiteUtils -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -/** - * Helper class to manage GutenbergView warmup for preloading editor assets. - * This improves editor launch speed by caching WebView assets before the editor is opened. - */ -@Singleton -class GutenbergKitWarmupHelper @Inject constructor( - private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, - @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher -) { - private var lastWarmedUpSiteId: Long? = null - private var isWarmupInProgress = false - - /** - * Triggers warmup for the given site if not already warmed up. - * - * @param site The site to warm up the editor for - * @param scope The coroutine scope to launch the warmup in - */ - fun warmupIfNeeded(site: SiteModel?, scope: CoroutineScope) { - when { - site == null -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - no site provided") - } - lastWarmedUpSiteId == site.siteId && !isWarmupInProgress -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Already warmed up for site ${site.siteId}") - } - isWarmupInProgress -> { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup already in progress") - } - !shouldWarmupForSite(site) -> { - // Logging handled within shouldWarmupForSite() - } - else -> { - scope.launch(bgDispatcher) { - performWarmup(site) - } - } - } - } - - /** - * Clears the warmup state when switching sites or logging out. - */ - fun clearWarmupState() { - lastWarmedUpSiteId = null - isWarmupInProgress = false - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup state cleared") - } - - private fun shouldWarmupForSite(site: SiteModel): Boolean { - if (!gutenbergKitFeatureChecker.isGutenbergKitEnabled()) { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - GutenbergKit features disabled") - return false - } - - val shouldWarmup = SiteUtils.isBlockEditorDefaultForNewPost(site) - - if (shouldWarmup) { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warming site ${site.siteId} " + - "(isBlockEditorDefault: true, webEditor: ${site.webEditor})") - } else { - AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - site ${site.siteId} doesn't " + - "default to the block editor for new posts " + - "(isBlockEditorDefault: false, webEditor: ${site.webEditor})") - } - - return shouldWarmup - } - - @Suppress("UnusedParameter") - private suspend fun performWarmup(site: SiteModel) { - // GutenbergView.warmup() was removed in GutenbergKit v0.15.0. - // Warmup/preloading needs to be reimplemented using the new API. - AppLog.d( - T.EDITOR, - "GutenbergKitWarmupHelper: Warmup not yet supported in v0.15.0" - ) - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 9c56674e6b5f..e1ba35ffae81 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -26,6 +26,8 @@ import org.wordpress.android.editor.EditorEditMediaListener import org.wordpress.android.editor.EditorFragmentAbstract import org.wordpress.android.editor.EditorImagePreviewListener import org.wordpress.android.editor.LiveTextWatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.util.AppLog import org.wordpress.android.util.PermissionUtils import org.wordpress.android.util.ProfilingUtils @@ -40,8 +42,12 @@ import org.wordpress.gutenberg.GutenbergView.TitleAndContentCallback import org.wordpress.gutenberg.Media import org.wordpress.gutenberg.model.EditorConfiguration import java.util.concurrent.CountDownLatch +import javax.inject.Inject class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { + @Inject + lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader + private var gutenbergView: GutenbergView? = null private var isHtmlModeEnabled = false @@ -57,6 +63,8 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + (requireActivity().application as org.wordpress.android.WordPress) + .component().inject(this) ProfilingUtils.start("Visual Editor Startup") ProfilingUtils.split("EditorFragment.onCreate") @@ -164,9 +172,10 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { ) ) + val siteLocalId = requireArguments().getInt(ARG_SITE_LOCAL_ID) val gutenbergView = GutenbergView( configuration = configuration, - dependencies = null, + dependencies = gutenbergEditorPreloader.getDependencies(siteLocalId), coroutineScope = this.lifecycleScope, context = requireContext() ) @@ -537,16 +546,19 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { const val ARG_FEATURED_IMAGE_ID: String = "featured_image_id" const val ARG_GUTENBERG_KIT_SETTINGS: String = "gutenberg_kit_settings" + private const val ARG_SITE_LOCAL_ID = "site_local_id" private const val CAPTURE_PHOTO_PERMISSION_REQUEST_CODE = 101 private const val CAPTURE_VIDEO_PERMISSION_REQUEST_CODE = 102 fun newInstance( - configuration: EditorConfiguration + configuration: EditorConfiguration, + site: SiteModel ): GutenbergKitEditorFragment { val fragment = GutenbergKitEditorFragment() val args = Bundle() args.putParcelable(ARG_GUTENBERG_KIT_SETTINGS, configuration) + args.putInt(ARG_SITE_LOCAL_ID, site.id) fragment.arguments = args return fragment } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt index c6c0ffc6c628..2fa571b853ad 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt @@ -40,9 +40,9 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewModelSlice import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker -import org.wordpress.android.repositories.EditorSettingsRepository import org.wordpress.android.ui.pages.SnackbarMessageHolder -import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.ui.posts.GutenbergEditorPreloader import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -100,10 +100,10 @@ class MySiteViewModelTest : BaseUnitTest() { lateinit var applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice @Mock - lateinit var gutenbergKitWarmupHelper: GutenbergKitWarmupHelper + lateinit var siteCapabilityChecker: SiteCapabilityChecker @Mock - lateinit var siteCapabilityChecker: SiteCapabilityChecker + lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader @Mock lateinit var editorSettingsRepository: EditorSettingsRepository @@ -162,8 +162,8 @@ class MySiteViewModelTest : BaseUnitTest() { dashboardCardsViewModelSlice, dashboardItemsViewModelSlice, applicationPasswordViewModelSlice, - gutenbergKitWarmupHelper, siteCapabilityChecker, + gutenbergEditorPreloader, editorSettingsRepository, ) uiModels = mutableListOf() @@ -406,6 +406,19 @@ class MySiteViewModelTest : BaseUnitTest() { verify(accountDataViewModelSlice).onCleared() verify(dashboardCardsViewModelSlice).onCleared() verify(dashboardItemsViewModelSlice).onCleared() + verify(gutenbergEditorPreloader).clear() + } + + @Test + fun `when dashboard is built, then editor preload is triggered`() { + initSelectedSite() + + viewModel.refresh() + + verify(gutenbergEditorPreloader).preloadIfNeeded( + org.mockito.kotlin.eq(siteTest), + org.mockito.kotlin.any() + ) } @Suppress("LongParameterList") diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt new file mode 100644 index 000000000000..5072ba52a430 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -0,0 +1,467 @@ +package org.wordpress.android.ui.posts + +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.gutenberg.model.EditorAssetBundle +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorSettings + +@ExperimentalCoroutinesApi +class GutenbergEditorPreloaderTest : + BaseUnitTest(StandardTestDispatcher()) { + @Mock + lateinit var appContext: Context + + @Mock + lateinit var accountStore: AccountStore + + @Mock + lateinit var gutenbergKitFeatureChecker: GutenbergKitFeatureChecker + + @Mock + lateinit var gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder + + @Mock + lateinit var siteSettingsProvider: SiteSettingsProvider + + @Mock + lateinit var editorServiceProvider: EditorServiceProvider + + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + + private val editorDependencies = EditorDependencies.empty + + private lateinit var preloader: GutenbergEditorPreloader + + private fun createSite(id: Int = 1): SiteModel { + val site = SiteModel() + site.id = id + site.name = "Site $id" + return site + } + + @Before + fun setUp() { + preloader = GutenbergEditorPreloader( + appContext = appContext, + accountStore = accountStore, + gutenbergKitFeatureChecker = gutenbergKitFeatureChecker, + gutenbergKitSettingsBuilder = gutenbergKitSettingsBuilder, + siteSettingsProvider = siteSettingsProvider, + editorServiceProvider = editorServiceProvider, + editorSettingsRepository = editorSettingsRepository, + bgDispatcher = testDispatcher() + ) + } + + private fun enablePreloading(site: SiteModel) { + whenever( + gutenbergKitFeatureChecker.isGutenbergKitEnabled() + ).thenReturn(true) + whenever( + siteSettingsProvider.isBlockEditorDefault(site) + ).thenReturn(true) + } + + private fun stubSuccessfulPreload() { + whenever( + gutenbergKitSettingsBuilder.buildPostConfiguration( + site = any(), + post = anyOrNull(), + accessToken = anyOrNull() + ) + ).thenReturn(mock()) + } + + private suspend fun stubEditorService() { + whenever( + editorServiceProvider.prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + ).thenReturn(editorDependencies) + } + + // region getDependencies + + @Test + fun `getDependencies returns null when nothing preloaded`() { + val site = createSite() + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `getDependencies by ID returns null when nothing preloaded`() { + assertThat(preloader.getDependencies(99)).isNull() + } + + // endregion + + // region preloadIfNeeded — gating + + @Test + fun `skips preload when feature is disabled`() = test { + val site = createSite() + whenever( + gutenbergKitFeatureChecker.isGutenbergKitEnabled() + ).thenReturn(false) + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + verify(editorServiceProvider, never()).prepare( + context = any(), + configuration = any(), + coroutineScope = any() + ) + } + + @Test + fun `skips preload when block editor is not default`() = test { + val site = createSite() + whenever( + gutenbergKitFeatureChecker.isGutenbergKitEnabled() + ).thenReturn(true) + whenever( + siteSettingsProvider.isBlockEditorDefault(site) + ).thenReturn(false) + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + verify(editorServiceProvider, never()).prepare( + context = any(), + configuration = any(), + coroutineScope = any() + ) + } + + // endregion + + // region preloadIfNeeded — success + + @Test + fun `successful preload caches dependencies`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(editorDependencies) + } + + @Test + fun `successful preload fetches editor capabilities`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + verify(editorSettingsRepository) + .fetchEditorCapabilitiesForSite(site) + } + + @Test + fun `getDependencies by ID returns cached result`() = test { + val site = createSite(id = 42) + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(42)) + .isSameAs(editorDependencies) + } + + // endregion + + // region preloadIfNeeded — failure + + @Test + fun `failed preload removes entry`() = test { + val site = createSite() + enablePreloading(site) + whenever( + gutenbergKitSettingsBuilder.buildPostConfiguration( + site = any(), + post = anyOrNull(), + accessToken = anyOrNull() + ) + ).thenThrow(RuntimeException("network error")) + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + } + + // endregion + + // region deduplication + + @Test + fun `second preload for same site is skipped`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + verify(editorServiceProvider).prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + } + + @Test + fun `in-flight preload blocks duplicate request`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + // Job is in-flight — don't advance + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + verify(editorServiceProvider).prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + } + + @Test + fun `getDependencies returns null while preload is in-flight`() = + test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + // Job is in-flight — don't advance + + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `cancelled scope allows fresh preload attempt`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + // Launch in a separate scope and cancel it + val expendableScope = TestScope(testDispatcher()) + preloader.preloadIfNeeded(site, expendableScope) + expendableScope.cancel() + + // The dead Loading entry should not block a retry + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(editorDependencies) + } + + // endregion + + // region multi-site caching + + @Test + fun `preloading site B does not discard site A`() = test { + val siteA = createSite(id = 1) + val siteB = createSite(id = 2) + enablePreloading(siteA) + enablePreloading(siteB) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(siteA, this) + advanceUntilIdle() + preloader.preloadIfNeeded(siteB, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(siteA)) + .isSameAs(editorDependencies) + assertThat(preloader.getDependencies(siteB)) + .isSameAs(editorDependencies) + } + + @Test + fun `concurrent in-flight preloads for different sites`() = + test { + val siteA = createSite(id = 1) + val siteB = createSite(id = 2) + enablePreloading(siteA) + enablePreloading(siteB) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(siteA, this) + preloader.preloadIfNeeded(siteB, this) + // Both in-flight — now advance + advanceUntilIdle() + + assertThat(preloader.getDependencies(siteA)) + .isSameAs(editorDependencies) + assertThat(preloader.getDependencies(siteB)) + .isSameAs(editorDependencies) + } + + // endregion + + // region refreshPreloading + + @Test + fun `refresh discards cached result and re-preloads`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + + // Now make the service return a different result + val freshDependencies = EditorDependencies( + editorSettings = EditorSettings.undefined, + assetBundle = EditorAssetBundle.empty, + preloadList = null + ) + whenever( + editorServiceProvider.prepare( + context = any(), + configuration = anyOrNull(), + coroutineScope = any() + ) + ).thenReturn(freshDependencies) + + preloader.refreshPreloading(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(freshDependencies) + } + + @Test + fun `failed refresh removes previously cached result`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + advanceUntilIdle() + assertThat(preloader.getDependencies(site)).isNotNull + + // Make the refresh fail + whenever( + gutenbergKitSettingsBuilder.buildPostConfiguration( + site = any(), + post = anyOrNull(), + accessToken = anyOrNull() + ) + ).thenThrow(RuntimeException("refresh failed")) + + preloader.refreshPreloading(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `refresh on never-preloaded site works`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.refreshPreloading(site, this) + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)) + .isSameAs(editorDependencies) + } + + // endregion + + // region clear + + @Test + fun `clear during in-flight preload discards result`() = test { + val site = createSite() + enablePreloading(site) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(site, this) + // Job is in-flight — clear before it completes + preloader.clear() + advanceUntilIdle() + + assertThat(preloader.getDependencies(site)).isNull() + } + + @Test + fun `clear removes all cached dependencies`() = test { + val siteA = createSite(id = 1) + val siteB = createSite(id = 2) + enablePreloading(siteA) + enablePreloading(siteB) + stubSuccessfulPreload() + stubEditorService() + + preloader.preloadIfNeeded(siteA, this) + preloader.preloadIfNeeded(siteB, this) + advanceUntilIdle() + + preloader.clear() + + assertThat(preloader.getDependencies(siteA)).isNull() + assertThat(preloader.getDependencies(siteB)).isNull() + } + + // endregion + + private inline fun mock(): T = + org.mockito.Mockito.mock(T::class.java) +}