Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -255,6 +256,8 @@ public interface AppComponent {

void inject(GutenbergKitActivity object);

void inject(GutenbergKitEditorFragment object);

void inject(EditPostSettingsFragment object);

void inject(PostSettingsListDialogFragment object);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.wordpress.android.datasets.SiteSettingsProvider
import org.wordpress.android.datasets.SiteSettingsProviderImpl
import org.wordpress.android.ui.posts.EditorServiceProvider
import org.wordpress.android.ui.posts.EditorServiceProviderImpl
import org.wordpress.android.ui.posts.IPostFreshnessChecker
import org.wordpress.android.ui.posts.PostFreshnessCheckerImpl
import javax.inject.Singleton
Expand All @@ -23,4 +25,10 @@ class PostModule {
fun provideSiteSettingsProvider(
impl: SiteSettingsProviderImpl
): SiteSettingsProvider = impl

@Singleton
@Provides
fun provideEditorServiceProvider(
impl: EditorServiceProviderImpl
): EditorServiceProvider = impl
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
import org.wordpress.android.repositories.EditorSettingsRepository

Expand All @@ -65,9 +65,9 @@ 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 editorSettingsRepository: EditorSettingsRepository,
private val gutenbergEditorPreloader: GutenbergEditorPreloader,
) : ScopedViewModel(mainDispatcher) {
private val _onSnackbarMessage = MutableLiveData<Event<SnackbarMessageHolder>>()
private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
Expand Down Expand Up @@ -175,7 +175,7 @@ class MySiteViewModel @Inject constructor(
if (isPullToRefresh) {
siteCapabilityChecker.clearCacheForSite(site.siteId)
}
buildDashboardOrSiteItems(site)
buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh)
launch {
fetchEditorCapabilitiesWithSnackbar(
site,
Expand Down Expand Up @@ -296,7 +296,7 @@ class MySiteViewModel @Inject constructor(
dashboardCardsViewModelSlice.onCleared()
dashboardItemsViewModelSlice.onCleared()
accountDataViewModelSlice.onCleared()
gutenbergKitWarmupHelper.clearWarmupState()
gutenbergEditorPreloader.clear()
super.onCleared()
}

Expand Down Expand Up @@ -329,7 +329,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)) {
Expand All @@ -339,8 +342,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ object EditPostCustomerSupportHelper {

private fun getTagsList(site: SiteModel): List<String>? =
// 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
fun interface EditorServiceProvider {
suspend fun prepare(
context: Context,
configuration: EditorConfiguration,
coroutineScope: CoroutineScope
): EditorDependencies
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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()
}

// Keyed by SiteModel.id (the local DB row ID — stable across the
// process lifetime, unlike the remote siteId which is 0 for
// unauthenticated self-hosted sites until discovery completes).
private val state = ConcurrentHashMap<Int, PreloadState>()

/**
* 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)
// Preloading produces EditorDependencies, which the editor
// consumes alongside its own per-launch EditorConfiguration.
// Locale, cookies, and network-logging are per-launch
// concerns the preloaded dependencies don't depend on, so
// pass safe defaults here.
val config = gutenbergKitSettingsBuilder
.buildPostConfiguration(
site = site,
accessToken = accountStore.accessToken,
locale = "en",
cookies = emptyMap(),
isNetworkLoggingEnabled = false,
)
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading