From 876301a2d67cfb257d73391155db1868f5be65c9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 25 May 2026 13:46:56 -0600 Subject: [PATCH 1/2] Add GutenbergKit opt-in announcement and per-site override Introduces a one-time announcement bottom sheet, shown on the next WPMainActivity.onResume when the GutenbergKit remote feature is on, the current site already defaults to the block editor, and the announcement has not been shown before. The dialog's primary CTA sets an app-wide opt-in flag (UndeletablePrefKey, persists across logout); "Maybe later" dismisses without flipping it. Site Settings gains a per-site GutenbergKit toggle, gated on the same remote flag. The toggle is a tri-state override stored as two StringSets (opt-in / opt-out / follow global), letting a user pin a specific site on or off independent of the global opt-in. Resolution order in GutenbergKitFeatureChecker: kill switch beats everything; then siteOverride ?? globalOptIn ?? experimental ?? remote-feature decides. Threaded through EditorCapabilityResolver so Theme Styles and Third-Party Blocks visibility / application stays in agreement with whether the editor actually opens for the site. --- RELEASE-NOTES.txt | 1 + .../android/ui/mysite/MySiteFragment.kt | 10 ++ .../android/ui/mysite/MySiteViewModel.kt | 13 ++ .../ui/posts/EditorCapabilityResolver.kt | 4 +- .../android/ui/posts/EditorLauncher.kt | 13 +- ...nbergKitAnnouncementBottomSheetFragment.kt | 99 ++++++++++++ .../GutenbergKitAnnouncementController.kt | 60 +++++++ .../posts/GutenbergKitAnnouncementScreen.kt | 149 ++++++++++++++++++ .../ui/posts/GutenbergKitFeatureChecker.kt | 54 ++++--- .../wordpress/android/ui/prefs/AppPrefs.java | 115 ++++++++++++++ .../android/ui/prefs/AppPrefsWrapper.kt | 12 ++ .../ui/prefs/SiteSettingsFragment.java | 46 +++++- WordPress/src/main/res/values/key_strings.xml | 1 + WordPress/src/main/res/values/strings.xml | 11 ++ WordPress/src/main/res/xml/site_settings.xml | 6 + .../android/ui/mysite/MySiteViewModelTest.kt | 35 ++++ .../ui/posts/EditorCapabilityResolverTest.kt | 30 +++- .../GutenbergKitAnnouncementControllerTest.kt | 132 ++++++++++++++++ .../posts/GutenbergKitFeatureCheckerTest.kt | 139 +++++++++++++--- 19 files changed, 878 insertions(+), 52 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementController.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementScreen.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementControllerTest.kt diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 1c4710713d9a..8900c7106884 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -4,6 +4,7 @@ ----- * [**] Resolved an issue where the editor could become impossible to exit when it failed to load. * [*] Atomic sites can now create application passwords without leaving the app. +* [*] Try out the next-generation block editor on a per-site basis from Site Settings. 26.7 ----- diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteFragment.kt index 65ffc6a6a518..8be71e0f32f1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteFragment.kt @@ -49,6 +49,7 @@ import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.photopicker.MediaPickerConstants import org.wordpress.android.ui.photopicker.MediaPickerLauncher import org.wordpress.android.ui.posts.BasicDialogViewModel +import org.wordpress.android.ui.posts.GutenbergKitAnnouncementBottomSheetFragment import org.wordpress.android.ui.posts.PostListType import org.wordpress.android.ui.posts.PostUtils import org.wordpress.android.ui.reader.ReaderActivityLauncher @@ -402,6 +403,15 @@ class MySiteFragment : Fragment(R.layout.my_site_fragment), ) } + viewModel.onShowGutenbergKitAnnouncement.observeEvent(viewLifecycleOwner) { site -> + if (parentFragmentManager.isStateSaved) return@observeEvent + if (parentFragmentManager.findFragmentByTag( + GutenbergKitAnnouncementBottomSheetFragment.TAG + ) != null) return@observeEvent + GutenbergKitAnnouncementBottomSheetFragment.newInstance(site) + .show(parentFragmentManager, GutenbergKitAnnouncementBottomSheetFragment.TAG) + } + viewModel.refresh.observe(viewLifecycleOwner) { viewModel.refresh() } 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 5872166e0c81..52f6af7a8bd5 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 @@ -32,6 +32,7 @@ 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.posts.GutenbergKitAnnouncementController import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper @@ -68,11 +69,14 @@ class MySiteViewModel @Inject constructor( private val siteCapabilityChecker: SiteCapabilityChecker, private val gutenbergEditorPreloader: GutenbergEditorPreloader, private val siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice, + private val gutenbergKitAnnouncementController: GutenbergKitAnnouncementController, ) : ScopedViewModel(mainDispatcher) { private val _onSnackbarMessage = MutableLiveData>() private val _onNavigation = MutableLiveData>() private val _onOpenJetpackInstallFullPluginOnboarding = SingleLiveEvent>() private val _onShowJetpackIndividualPluginOverlay = SingleLiveEvent>() + private val _onShowGutenbergKitAnnouncement = SingleLiveEvent>() + val onShowGutenbergKitAnnouncement: LiveData> = _onShowGutenbergKitAnnouncement /* Capture and track the site selected event so we can circumvent refreshing sources on resume as they're already built on site select. */ @@ -185,6 +189,7 @@ class MySiteViewModel @Inject constructor( fun onResume() { isSiteSelected = false checkAndShowJetpackFullPluginInstallOnboarding() + checkAndShowGutenbergKitAnnouncement() selectedSiteRepository.updateSiteSettingsIfNecessary() selectedSiteRepository.getSelectedSite()?.let { buildDashboardOrSiteItems(it) @@ -205,6 +210,14 @@ class MySiteViewModel @Inject constructor( } } + private fun checkAndShowGutenbergKitAnnouncement() { + selectedSiteRepository.getSelectedSite()?.let { selectedSite -> + if (gutenbergKitAnnouncementController.shouldShowAnnouncement(selectedSite)) { + _onShowGutenbergKitAnnouncement.postValue(Event(selectedSite)) + } + } + } + fun onSiteNameChosen(input: String) { siteInfoHeaderCardViewModelSlice.onSiteNameChosen(input) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt index cf4dd5091f9c..a9da2926bf52 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt @@ -31,7 +31,7 @@ class EditorCapabilityResolver @Inject constructor( private val siteSettingsProvider: SiteSettingsProvider, ) { fun resolveThirdPartyBlocks(site: SiteModel): EditorCapabilityState = when { - !gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> EditorCapabilityState.Hidden + !gutenbergKitFeatureChecker.isGutenbergKitEnabled(site) -> EditorCapabilityState.Hidden !gutenbergKitPluginsFeature.isEnabled() -> EditorCapabilityState.Hidden !editorSettingsRepository.getSupportsEditorAssetsForSite(site) -> EditorCapabilityState.Unsupported(EditorCapabilityState.UnsupportedReason.CapabilityMissing) @@ -45,7 +45,7 @@ class EditorCapabilityResolver @Inject constructor( } fun resolveThemeStyles(site: SiteModel): EditorCapabilityState = when { - !gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> EditorCapabilityState.Hidden + !gutenbergKitFeatureChecker.isGutenbergKitEnabled(site) -> EditorCapabilityState.Hidden !editorSettingsRepository.getSupportsEditorSettingsForSite(site) -> EditorCapabilityState.Unsupported(EditorCapabilityState.UnsupportedReason.CapabilityMissing) else -> { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt index c1e024664d42..47c5ce1e3029 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 @@ -89,10 +89,10 @@ class EditorLauncher @Inject constructor( * Determines if GutenbergKit editor should be used based on feature flags and post content. */ private fun shouldUseGutenbergKitEditor(params: EditorLauncherParams): Boolean { - val featureState = gutenbergKitFeatureChecker.getFeatureState() + val site = params.siteSource.getSite(siteStore) + val featureState = gutenbergKitFeatureChecker.getFeatureState(site) val isGutenbergFeatureEnabled = featureState.isGutenbergKitEnabled - val site = params.siteSource.getSite(siteStore) return when { !isGutenbergFeatureEnabled -> { logFeatureDisabledReason(featureState) @@ -124,8 +124,10 @@ class EditorLauncher @Inject constructor( val reason = when { featureState.isDisableExperimentalBlockEditorEnabled -> "the experimental block editor is explicitly disabled" - !featureState.isExperimentalBlockEditorEnabled && !featureState.isGutenbergKitFeatureEnabled -> - "neither the experimental block editor feature nor GutenbergKit feature is enabled" + featureState.siteOverride == false -> + "this site has an explicit GutenbergKit opt-out" + featureState.siteOverride == null && !featureState.isExperimentalBlockEditorEnabled -> + "no per-site opt-in and the experimental block editor flag is off" else -> "GutenbergKit feature checks failed" } val featureFlags = getFeatureFlagsString(featureState) @@ -144,7 +146,8 @@ class EditorLauncher @Inject constructor( featureState: GutenbergKitFeatureChecker.FeatureState = gutenbergKitFeatureChecker.getFeatureState() ): String { return "(experimental_block_editor: ${featureState.isExperimentalBlockEditorEnabled}, " + - "gutenberg_kit_feature: ${featureState.isGutenbergKitFeatureEnabled}, " + + "gutenberg_kit_remote_flag: ${featureState.isGutenbergKitFeatureEnabled}, " + + "site_override: ${featureState.siteOverride}, " + "disable_experimental_block_editor: ${featureState.isDisableExperimentalBlockEditorEnabled})" } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt new file mode 100644 index 000000000000..ffe1f63527eb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.ui.posts + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.WordPress +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.WPWebViewActivity +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.util.extensions.getSerializableCompat +import javax.inject.Inject + +/** + * One-time announcement bottom sheet for the upcoming GutenbergKit editor. Show/defer/activate + * logic lives in [GutenbergKitAnnouncementController]; this fragment hosts a Compose layout and + * forwards button taps. + */ +@AndroidEntryPoint +class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() { + @Inject lateinit var controller: GutenbergKitAnnouncementController + + private var decisionRecorded = false + + // The default `WordPress.BottomSheetDialogTheme` sets `fitsSystemWindows=true`, which adds + // the status-bar inset as top padding to the sheet container. The `NonTranslucent` variant + // turns that off — matches what other Compose bottom sheets (e.g. ReaderSubscriptionSettings) + // already do. + override fun getTheme(): Int = R.style.WordPress_BottomSheetDialogTheme_NonTranslucent + + private val site: SiteModel + get() = requireNotNull( + requireArguments().getSerializableCompat(WordPress.SITE) + ) { "GutenbergKitAnnouncementBottomSheetFragment requires a SiteModel argument" } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = ComposeView(requireContext()).apply { + setContent { + AppThemeM3 { + GutenbergKitAnnouncementScreen( + onActivate = { + controller.onActivate(site) + decisionRecorded = true + dismiss() + }, + onMaybeLater = { + controller.onMaybeLater(site) + decisionRecorded = true + dismiss() + }, + onLearnMore = { + WPWebViewActivity.openURL( + requireContext(), + getString(R.string.gutenberg_kit_learn_more_url), + ) + }, + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // Force STATE_EXPANDED so the screen has a defined height — the flex spacer between body + // and buttons in the Compose layout relies on the parent being taller than its content. + (dialog as? BottomSheetDialog)?.apply { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.skipCollapsed = true + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + // Swipe / back / tap-outside without a button tap is treated as an implicit "Maybe later" + // so the sheet doesn't re-prompt on the next My Site resume. Config changes don't count. + if (decisionRecorded) return + if (activity?.isChangingConfigurations == true) return + controller.onMaybeLater(site) + } + + companion object { + const val TAG = "GutenbergKitAnnouncementBottomSheetFragment" + + fun newInstance(site: SiteModel): GutenbergKitAnnouncementBottomSheetFragment = + GutenbergKitAnnouncementBottomSheetFragment().apply { + arguments = Bundle().apply { putSerializable(WordPress.SITE, site) } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementController.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementController.kt new file mode 100644 index 000000000000..ead07162acd7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementController.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.ui.posts + +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import java.time.Clock +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns the decisions for the GutenbergKit announcement bottom sheet and the per-site override + * it writes. Pure logic so it is unit-testable; the fragment and Site Settings only call into it. + * + * The per-site override is the single source of truth — its presence means the user has decided + * for that site (either direction), its absence means "not yet decided." "Maybe later" defers the + * announcement for one week per-site rather than writing an override, so we don't mis-read the + * user's intent. + */ +@Singleton +class GutenbergKitAnnouncementController @Inject constructor( + private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, + private val siteSettingsProvider: SiteSettingsProvider, + private val appPrefsWrapper: AppPrefsWrapper, + private val clock: Clock, +) { + @Suppress("ReturnCount") + fun shouldShowAnnouncement(site: SiteModel): Boolean { + // The per-site override/deferral prefs are keyed by URL, so a site without one would + // loop the announcement on every resume (writes would no-op via TextUtils.isEmpty). + if (site.url.isNullOrEmpty()) return false + if (!gutenbergKitFeatureChecker.isGutenbergKitRemoteFeatureEnabled()) return false + if (!siteSettingsProvider.isBlockEditorDefault(site)) return false + if (appPrefsWrapper.getGutenbergKitSiteOverride(site.url) != null) return false + return clock.millis() >= appPrefsWrapper.getGutenbergKitAnnouncementDeferredUntil(site.url) + } + + fun onActivate(site: SiteModel) = setOverride(site, true) + + /** + * Records an explicit per-site decision (from the announcement sheet or Site Settings). An + * explicit decision supersedes any pending "Maybe later" deferral on the same site, so this + * clears the deferral timestamp as well. + */ + fun setOverride(site: SiteModel, enabled: Boolean) { + appPrefsWrapper.setGutenbergKitSiteOverride(site.url, enabled) + appPrefsWrapper.setGutenbergKitAnnouncementDeferredUntil(site.url, 0L) + } + + fun onMaybeLater(site: SiteModel) { + appPrefsWrapper.setGutenbergKitAnnouncementDeferredUntil( + site.url, + clock.millis() + DEFER_DURATION_MILLIS + ) + } + + companion object { + val DEFER_DURATION_MILLIS: Long = TimeUnit.DAYS.toMillis(7) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementScreen.kt new file mode 100644 index 000000000000..8f3083f7ebd4 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementScreen.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.ui.posts + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +private val HORIZONTAL_PADDING = 24.dp +private val HANDLE_WIDTH = 32.dp +private val HANDLE_HEIGHT = 4.dp +private val HANDLE_TOP_MARGIN = 4.dp +private val TITLE_TOP_SPACE = 24.dp +private val TITLE_BODY_SPACE = 8.dp +private val BUTTONS_TOP_SPACE = 24.dp +private val BOTTOM_SPACE = 16.dp +private val BUTTON_GAP = 8.dp +private const val HANDLE_ALPHA = 0.38f + +@Composable +fun GutenbergKitAnnouncementScreen( + onActivate: () -> Unit, + onMaybeLater: () -> Unit, + onLearnMore: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = HORIZONTAL_PADDING) + ) { + BottomSheetHandle(modifier = Modifier.padding(top = HANDLE_TOP_MARGIN)) + + Spacer(modifier = Modifier.height(TITLE_TOP_SPACE)) + Text( + text = stringResource(R.string.gutenberg_kit_announcement_title), + style = MaterialTheme.typography.headlineSmall, + fontFamily = FontFamily.Serif, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.height(TITLE_BODY_SPACE)) + Text( + text = buildBodyWithLearnMore(onLearnMore), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + ) + + Spacer(modifier = Modifier.height(BUTTONS_TOP_SPACE)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(BUTTON_GAP), + ) { + TextButton( + onClick = onMaybeLater, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.gutenberg_kit_announcement_maybe_later)) + } + Button( + onClick = onActivate, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.gutenberg_kit_announcement_activate)) + } + } + + Spacer(modifier = Modifier.height(BOTTOM_SPACE)) + } +} + +@Composable +private fun buildBodyWithLearnMore(onLearnMore: () -> Unit) = buildAnnotatedString { + val learnMoreText = stringResource(R.string.gutenberg_kit_announcement_learn_more) + val body = stringResource(R.string.gutenberg_kit_announcement_body, learnMoreText) + val linkStart = body.indexOf(learnMoreText) + val link = LinkAnnotation.Clickable( + tag = "learn_more", + styles = TextLinkStyles( + style = SpanStyle(color = MaterialTheme.colorScheme.primary), + ), + linkInteractionListener = { onLearnMore() }, + ) + append(body.substring(0, linkStart)) + withLink(link) { append(learnMoreText) } + val suffixStart = linkStart + learnMoreText.length + if (suffixStart < body.length) append(body.substring(suffixStart)) +} + +@Composable +private fun BottomSheetHandle(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(width = HANDLE_WIDTH, height = HANDLE_HEIGHT) + .alpha(HANDLE_ALPHA) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(HANDLE_HEIGHT / 2), + ), + ) + } +} + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun GutenbergKitAnnouncementScreenPreview() { + AppThemeM3 { + GutenbergKitAnnouncementScreen( + onActivate = {}, + onMaybeLater = {}, + onLearnMore = {}, + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt index f3b007d5d2db..e65f166c98bb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt @@ -1,5 +1,7 @@ package org.wordpress.android.ui.posts +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.util.config.GutenbergKitFeature @@ -13,7 +15,8 @@ import javax.inject.Singleton @Singleton class GutenbergKitFeatureChecker @Inject constructor( private val experimentalFeatures: ExperimentalFeatures, - private val gutenbergKitFeature: GutenbergKitFeature + private val gutenbergKitFeature: GutenbergKitFeature, + private val appPrefsWrapper: AppPrefsWrapper ) { /** * Data class containing the state of all GutenbergKit-related feature flags. @@ -21,41 +24,54 @@ class GutenbergKitFeatureChecker @Inject constructor( data class FeatureState( val isExperimentalBlockEditorEnabled: Boolean, val isGutenbergKitFeatureEnabled: Boolean, - val isDisableExperimentalBlockEditorEnabled: Boolean + val isDisableExperimentalBlockEditorEnabled: Boolean, + val siteOverride: Boolean? = null ) { /** - * Determines if GutenbergKit should be enabled based on the feature states. + * Determines if GutenbergKit should be used for editor routing. + * + * Resolution: a per-site override (set or cleared via the announcement sheet or Site + * Settings) wins over everything except the `DISABLE_EXPERIMENTAL_BLOCK_EDITOR` kill + * switch. When absent, falls back to the experimental flag. + * + * The remote `gutenberg_kit` feature flag is deliberately NOT part of editor routing + * — it only gates the visibility of opt-in surfaces (the announcement bottom sheet and + * the Site Settings toggle). This lets us roll out the announcement to a percentage of + * users without simultaneously flipping the default editor. When we're ready to make + * GutenbergKit the default for everyone, the change is a one-line edit here. */ val isGutenbergKitEnabled: Boolean - get() = (isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled) && - !isDisableExperimentalBlockEditorEnabled + get() { + val resolved = siteOverride ?: isExperimentalBlockEditorEnabled + return resolved && !isDisableExperimentalBlockEditorEnabled + } } /** - * Gets the current state of all GutenbergKit-related feature flags. - * - * @return FeatureState containing all flag states and the computed enabled state + * Gets the current state of all GutenbergKit-related feature flags for the given site (if any). */ - fun getFeatureState(): FeatureState { + @JvmOverloads + fun getFeatureState(site: SiteModel? = null): FeatureState { return FeatureState( isExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled(Feature.EXPERIMENTAL_BLOCK_EDITOR), isGutenbergKitFeatureEnabled = gutenbergKitFeature.isEnabled(), isDisableExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled( Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR - ) + ), + siteOverride = site?.url?.let { appPrefsWrapper.getGutenbergKitSiteOverride(it) } ) } /** - * Determines if GutenbergKit is enabled based on feature flags. - * - * The feature is enabled if: - * - Either the experimental block editor is enabled OR the GutenbergKit feature flag is on - * - AND the disable experimental block editor flag is NOT enabled - * - * @return true if GutenbergKit should be enabled, false otherwise + * Determines if GutenbergKit is enabled based on feature flags (and optional per-site opt-in). */ - fun isGutenbergKitEnabled(): Boolean { - return getFeatureState().isGutenbergKitEnabled + @JvmOverloads + fun isGutenbergKitEnabled(site: SiteModel? = null): Boolean { + return getFeatureState(site).isGutenbergKitEnabled } + + /** + * Whether the user-facing remote feature flag is on (controls opt-in surfaces). + */ + fun isGutenbergKitRemoteFeatureEnabled(): Boolean = gutenbergKitFeature.isEnabled() } 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 343ecf7dc484..b3d6be776b67 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 @@ -127,6 +127,9 @@ public enum DeletablePrefKey implements PrefKey { SHOULD_AUTO_ENABLE_GUTENBERG_FOR_THE_NEW_POSTS_PHASE_2, GUTENBERG_OPT_IN_DIALOG_SHOWN, GUTENBERG_FOCAL_POINT_PICKER_TOOLTIP_SHOWN, + GUTENBERG_KIT_OPT_IN_SITES, + GUTENBERG_KIT_OPT_OUT_SITES, + GUTENBERG_KIT_ANNOUNCEMENT_DEFERRED_UNTIL, POST_LIST_AUTHOR_FILTER, POST_LIST_VIEW_LAYOUT_TYPE, @@ -831,6 +834,118 @@ public static boolean isGutenbergInfoPopupDisplayed(String siteURL) { return urls != null && urls.contains(siteURL); } + /** + * Returns the explicit per-site override for GutenbergKit, or {@code null} if the user has + * not set one. When {@code null}, downstream resolution in {@code GutenbergKitFeatureChecker} + * falls back to the experimental and remote feature flags. + */ + @Nullable + public static Boolean getGutenbergKitSiteOverride(String siteURL) { + if (TextUtils.isEmpty(siteURL)) { + return null; + } + if (siteSetContains(DeletablePrefKey.GUTENBERG_KIT_OPT_OUT_SITES, siteURL)) { + return Boolean.FALSE; + } + if (siteSetContains(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES, siteURL)) { + return Boolean.TRUE; + } + return null; + } + + /** + * Sets an explicit per-site override for GutenbergKit, replacing any prior override for the + * site. The site is added to the opt-in or opt-out set (per {@code enabled}) and removed from + * the other so the two sets stay mutually exclusive. + */ + public static void setGutenbergKitSiteOverride(String siteURL, boolean enabled) { + if (TextUtils.isEmpty(siteURL)) { + return; + } + DeletablePrefKey added = enabled + ? DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES + : DeletablePrefKey.GUTENBERG_KIT_OPT_OUT_SITES; + DeletablePrefKey removed = enabled + ? DeletablePrefKey.GUTENBERG_KIT_OPT_OUT_SITES + : DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES; + addToSiteSet(added, siteURL); + removeFromSiteSet(removed, siteURL); + } + + /** + * Returns {@code true} if {@code siteURL} is currently a member of the StringSet at {@code key}. + * A missing entry or a value of the wrong type is treated as absence. + */ + private static boolean siteSetContains(DeletablePrefKey key, String siteURL) { + try { + Set urls = prefs().getStringSet(key.name(), null); + return urls != null && urls.contains(siteURL); + } catch (ClassCastException exp) { + return false; + } + } + + /** + * Adds {@code siteURL} to the StringSet at {@code key}, creating the set if it does not exist. + * No-ops if the stored value is of the wrong type. + */ + private static void addToSiteSet(DeletablePrefKey key, String siteURL) { + Set urls; + try { + urls = prefs().getStringSet(key.name(), null); + } catch (ClassCastException exp) { + return; + } + Set newUrls = new HashSet<>(); + if (urls != null) newUrls.addAll(urls); + newUrls.add(siteURL); + prefs().edit().putStringSet(key.name(), newUrls).apply(); + } + + /** + * Removes {@code siteURL} from the StringSet at {@code key}. No-ops if the site is not present + * or the stored value is of the wrong type. + */ + private static void removeFromSiteSet(DeletablePrefKey key, String siteURL) { + Set urls; + try { + urls = prefs().getStringSet(key.name(), null); + } catch (ClassCastException exp) { + return; + } + if (urls == null || !urls.contains(siteURL)) return; + Set newUrls = new HashSet<>(urls); + newUrls.remove(siteURL); + prefs().edit().putStringSet(key.name(), newUrls).apply(); + } + + /** + * Returns the wall-clock timestamp (millis) before which the GutenbergKit announcement should + * not be re-shown for {@code siteURL}. Returns {@code 0L} if no deferral is set (i.e., free + * to show now, subject to the other gates). + */ + public static long getGutenbergKitAnnouncementDeferredUntil(String siteURL) { + if (TextUtils.isEmpty(siteURL)) { + return 0L; + } + return prefs().getLong(gutenbergKitDeferralKey(siteURL), 0L); + } + + /** + * Defers the GutenbergKit announcement for {@code siteURL} until the given wall-clock + * timestamp (millis). + */ + public static void setGutenbergKitAnnouncementDeferredUntil(String siteURL, long timestampMillis) { + if (TextUtils.isEmpty(siteURL)) { + return; + } + prefs().edit().putLong(gutenbergKitDeferralKey(siteURL), timestampMillis).apply(); + } + + private static String gutenbergKitDeferralKey(String siteURL) { + return DeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_DEFERRED_UNTIL.name() + "_" + siteURL; + } + public static void setGutenbergInfoPopupDisplayed(String siteURL, boolean isDisplayed) { if (isGutenbergInfoPopupDisplayed(siteURL)) { return; 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 4e851c660cd1..1a23a89139aa 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 @@ -575,6 +575,18 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setStatsNewStatsSuggestionLastDismissedAt(timestamp: Long) = AppPrefs.setStatsNewStatsSuggestionLastDismissedAt(timestamp) + fun getGutenbergKitSiteOverride(siteUrl: String?): Boolean? = + AppPrefs.getGutenbergKitSiteOverride(siteUrl) + + fun setGutenbergKitSiteOverride(siteUrl: String?, enabled: Boolean) = + AppPrefs.setGutenbergKitSiteOverride(siteUrl, enabled) + + fun getGutenbergKitAnnouncementDeferredUntil(siteUrl: String?): Long = + AppPrefs.getGutenbergKitAnnouncementDeferredUntil(siteUrl) + + fun setGutenbergKitAnnouncementDeferredUntil(siteUrl: String?, timestampMillis: Long) = + AppPrefs.setGutenbergKitAnnouncementDeferredUntil(siteUrl, timestampMillis) + companion object { private const val LIGHT_MODE_ID = 0 private const val DARK_MODE_ID = 1 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 11553a28297e..f634cf2fed35 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 @@ -82,6 +82,9 @@ import org.wordpress.android.util.PlansConstants; import org.wordpress.android.ui.posts.EditorCapabilityResolver; import org.wordpress.android.ui.posts.EditorCapabilityState; +import org.wordpress.android.ui.posts.GutenbergKitAnnouncementController; +import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker; +import org.wordpress.android.datasets.SiteSettingsProvider; import org.wordpress.android.ui.prefs.EditTextPreferenceWithValidation.ValidationType; import org.wordpress.android.ui.prefs.SiteSettingsFormatDialog.FormatType; import org.wordpress.android.ui.prefs.homepage.HomepageSettingsDialog; @@ -195,6 +198,9 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; @Inject EditorCapabilityResolver mEditorCapabilityResolver; + @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; + @Inject GutenbergKitAnnouncementController mGutenbergKitAnnouncementController; + @Inject SiteSettingsProvider mSiteSettingsProvider; private BloggingRemindersViewModel mBloggingRemindersViewModel; @@ -230,6 +236,7 @@ public class SiteSettingsFragment extends PreferenceFragment private WPSwitchPreference mGutenbergDefaultForNewPosts; private WPSwitchPreference mUseThemeStylesPref; private WPSwitchPreference mUseThirdPartyBlocksPref; + private WPSwitchPreference mGutenbergKitPref; private DetailListPreference mCategoryPref; private DetailListPreference mFormatPref; private WPPreference mDateFormatPref; @@ -852,6 +859,8 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { mSiteSettings.setUseThemeStyles((Boolean) newValue); } else if (preference == mUseThirdPartyBlocksPref) { mSiteSettings.setUseThirdPartyBlocks((Boolean) newValue); + } else if (preference == mGutenbergKitPref) { + mGutenbergKitAnnouncementController.setOverride(mSite, (Boolean) newValue); } else if (preference == mBloggingPromptsPref) { final boolean isEnabled = (boolean) newValue; mPromptsSettingsHelper.updatePromptsCardEnabledBlocking(mSite.getId(), isEnabled); @@ -1044,6 +1053,10 @@ public void initPreferences() { (WPSwitchPreference) getChangePref(R.string.pref_key_use_third_party_blocks); mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); + mGutenbergKitPref = + (WPSwitchPreference) getChangePref(R.string.pref_key_gutenberg_kit_enabled); + mGutenbergKitPref.setChecked(mGutenbergKitFeatureChecker.isGutenbergKitEnabled(mSite)); + mSiteAcceleratorSettings = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings); mSiteAcceleratorSettingsNested = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings_nested); @@ -1115,6 +1128,14 @@ public void initPreferences() { R.string.site_settings_use_third_party_blocks_unsupported)); } + // hide the GutenbergKit opt-in switch unless the remote feature flag is on + if (!mGutenbergKitFeatureChecker.isGutenbergKitRemoteFeatureEnabled()) { + WPPrefUtils.removePreference(this, R.string.pref_key_site_editor, + R.string.pref_key_gutenberg_kit_enabled); + } else { + refreshGutenbergKitToggleAvailability(); + } + // hide Admin options depending of capabilities on this site if ((!isAccessedViaWPComRest && !mSite.isSelfHostedAdmin()) || (isAccessedViaWPComRest && !mSite.getHasCapabilityManageOptions())) { @@ -1163,6 +1184,25 @@ public void initPreferences() { initTaxonomies(); } + /** + * On Aztec-default sites the GBKit toggle is shown disabled with an explanatory summary — + * switching mobileEditor to "gutenberg" is a prerequisite. The state must refresh whenever + * site settings change (e.g., the user just flipped "Use block editor as default for new + * posts" on this screen), not only at first inflation. + */ + private void refreshGutenbergKitToggleAvailability() { + if (mGutenbergKitPref == null) return; + if (mSiteSettingsProvider.isBlockEditorDefault(mSite)) { + mGutenbergKitPref.setEnabled(true); + mGutenbergKitPref.setSummary(R.string.site_settings_gutenberg_kit_enabled_summary); + } else { + mGutenbergKitPref.setEnabled(false); + mGutenbergKitPref.setSummary( + getString(R.string.site_settings_gutenberg_kit_enabled_summary) + "\n\n" + + getString(R.string.site_settings_gutenberg_kit_enabled_unsupported)); + } + } + private void initTaxonomies() { mTaxonomiesNavMenuViewModel = new ViewModelProvider(getAppCompatActivity(), mViewModelFactory) .get(TaxonomiesNavMenuViewModel.class); @@ -1240,7 +1280,7 @@ public void setEditingEnabled(boolean enabled) { mDeleteSitePref, mJpMonitorActivePref, mJpMonitorEmailNotesPref, mJpSsoPref, mJpMonitorWpNotesPref, mJpBruteForcePref, mJpAllowlistPref, mJpMatchEmailPref, mJpUseTwoFactorPref, mGutenbergDefaultForNewPosts, mUseThemeStylesPref, mUseThirdPartyBlocksPref, - mHomepagePref, mBloggingPromptsPref + mGutenbergKitPref, mHomepagePref, mBloggingPromptsPref }; for (Preference preference : editablePreference) { @@ -1586,6 +1626,10 @@ public void setPreferencesFromSiteSettings() { mGutenbergDefaultForNewPosts.setChecked(SiteUtils.isBlockEditorDefaultForNewPost(mSite)); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); + if (mGutenbergKitPref != null) { + mGutenbergKitPref.setChecked(mGutenbergKitFeatureChecker.isGutenbergKitEnabled(mSite)); + refreshGutenbergKitToggleAvailability(); + } setAdFreeHostingChecked(mSiteSettings.isAdFreeHostingEnabled()); boolean checked = mSiteSettings.isImprovedSearchEnabled() || mSiteSettings.getJetpackSearchEnabled(); mImprovedSearch.setChecked(checked); diff --git a/WordPress/src/main/res/values/key_strings.xml b/WordPress/src/main/res/values/key_strings.xml index 8d84013f5305..bfd7ae58cd8c 100644 --- a/WordPress/src/main/res/values/key_strings.xml +++ b/WordPress/src/main/res/values/key_strings.xml @@ -57,6 +57,7 @@ wp_pref_key_gutenberg_default_for_new_posts wp_pref_key_use_theme_styles wp_pref_key_use_third_party_blocks + wp_pref_key_gutenberg_kit_enabled 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 d40fae768691..2b3e667b0258 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -706,6 +706,17 @@ Load third-party blocks from plugins installed on your site. Your site doesn\'t support loading third-party blocks in the editor. Unable to connect to your site. Some functionality might be limited. + Try the new editor + Opt in to the next-generation block editor for this site + Switch to the block editor first to try the new editor. + + + A better block editor is coming + The next-generation block editor is ready, and it brings more new features.\n\nStarting in June 2026, it will become the default editor for everyone. %1$s + Activate + Maybe later + Learn more + https://wordpress.com/support/editors/ 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 fbe62957730e..49d856cfcb7f 100644 --- a/WordPress/src/main/res/xml/site_settings.xml +++ b/WordPress/src/main/res/xml/site_settings.xml @@ -144,6 +144,12 @@ android:summary="@string/site_settings_use_third_party_blocks_summary" android:title="@string/site_settings_use_third_party_blocks" /> + + 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 123f9f3bfad1..92a9a5aa90ab 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 @@ -107,6 +107,10 @@ class MySiteViewModelTest : BaseUnitTest() { @Mock lateinit var siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice + @Mock + lateinit var gutenbergKitAnnouncementController: + org.wordpress.android.ui.posts.GutenbergKitAnnouncementController + private lateinit var viewModel: MySiteViewModel private lateinit var uiModels: MutableList private lateinit var snackbars: MutableList @@ -164,6 +168,7 @@ class MySiteViewModelTest : BaseUnitTest() { siteCapabilityChecker, gutenbergEditorPreloader, siteConnectivityBannerViewModelSlice, + gutenbergKitAnnouncementController, ) uiModels = mutableListOf() snackbars = mutableListOf() @@ -418,6 +423,36 @@ class MySiteViewModelTest : BaseUnitTest() { ) } + /* GUTENBERGKIT ANNOUNCEMENT */ + + @Test + fun `onResume posts gutenberg kit announcement event when controller says show`() = test { + initSelectedSite() + whenever(gutenbergKitAnnouncementController.shouldShowAnnouncement(siteTest)).thenReturn(true) + val observed = mutableListOf() + viewModel.onShowGutenbergKitAnnouncement.observeForever { event -> + event?.getContentIfNotHandled()?.let { observed.add(it) } + } + + viewModel.onResume() + + assertThat(observed).containsExactly(siteTest) + } + + @Test + fun `onResume does not post gutenberg kit announcement event when controller says skip`() = test { + initSelectedSite() + whenever(gutenbergKitAnnouncementController.shouldShowAnnouncement(siteTest)).thenReturn(false) + val observed = mutableListOf() + viewModel.onShowGutenbergKitAnnouncement.observeForever { event -> + event?.getContentIfNotHandled()?.let { observed.add(it) } + } + + viewModel.onResume() + + assertThat(observed).isEmpty() + } + @Suppress("LongParameterList") private fun initSelectedSite( isSiteUsingWpComRestApi: Boolean = true, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt index 5fe36784dc39..4abc005d3d73 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/EditorCapabilityResolverTest.kt @@ -43,7 +43,7 @@ class EditorCapabilityResolverTest { ) // Defaults that let resolution reach `Available` unless // a test overrides them. - whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(true) + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(any())).thenReturn(true) whenever(gutenbergKitPluginsFeature.isEnabled()).thenReturn(true) whenever(editorSettingsRepository.getSupportsEditorAssetsForSite(any())).thenReturn(true) whenever(editorSettingsRepository.getSupportsEditorSettingsForSite(any())).thenReturn(true) @@ -54,13 +54,24 @@ class EditorCapabilityResolverTest { @Test fun `third-party blocks hidden when GutenbergKit disabled`() { - whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(false) + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(any())).thenReturn(false) val result = resolver.resolveThirdPartyBlocks(site) assertThat(result).isEqualTo(EditorCapabilityState.Hidden) } + @Test + fun `third-party blocks consult per-site GutenbergKit override`() { + val otherSite = SiteModel().apply { url = "https://other.example.com" } + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(site)).thenReturn(true) + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(otherSite)).thenReturn(false) + + assertThat(resolver.resolveThirdPartyBlocks(site)) + .isInstanceOf(EditorCapabilityState.Available::class.java) + assertThat(resolver.resolveThirdPartyBlocks(otherSite)).isEqualTo(EditorCapabilityState.Hidden) + } + @Test fun `third-party blocks hidden when plugins feature disabled`() { whenever(gutenbergKitPluginsFeature.isEnabled()).thenReturn(false) @@ -72,7 +83,7 @@ class EditorCapabilityResolverTest { @Test fun `third-party blocks hidden when both feature flags disabled`() { - whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(false) + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(any())).thenReturn(false) // lenient(): the resolver short-circuits on the GutenbergKit flag, so the plugins // stub is never read — strict mocking would treat that as a smell. lenient().`when`(gutenbergKitPluginsFeature.isEnabled()).thenReturn(false) @@ -139,13 +150,24 @@ class EditorCapabilityResolverTest { @Test fun `theme styles hidden when GutenbergKit disabled`() { - whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled()).thenReturn(false) + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(any())).thenReturn(false) val result = resolver.resolveThemeStyles(site) assertThat(result).isEqualTo(EditorCapabilityState.Hidden) } + @Test + fun `theme styles consult per-site GutenbergKit override`() { + val otherSite = SiteModel().apply { url = "https://other.example.com" } + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(site)).thenReturn(true) + whenever(gutenbergKitFeatureChecker.isGutenbergKitEnabled(otherSite)).thenReturn(false) + + assertThat(resolver.resolveThemeStyles(site)) + .isInstanceOf(EditorCapabilityState.Available::class.java) + assertThat(resolver.resolveThemeStyles(otherSite)).isEqualTo(EditorCapabilityState.Hidden) + } + @Test fun `theme styles available even when plugins feature disabled`() { // lenient(): the assertion is precisely that resolveThemeStyles ignores this flag, diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementControllerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementControllerTest.kt new file mode 100644 index 000000000000..47b7c838f936 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementControllerTest.kt @@ -0,0 +1,132 @@ +package org.wordpress.android.ui.posts + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.datasets.SiteSettingsProvider +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import java.time.Clock +import java.time.Instant +import java.time.ZoneId + +@RunWith(MockitoJUnitRunner::class) +class GutenbergKitAnnouncementControllerTest { + @Mock private lateinit var featureChecker: GutenbergKitFeatureChecker + @Mock private lateinit var siteSettingsProvider: SiteSettingsProvider + @Mock private lateinit var appPrefsWrapper: AppPrefsWrapper + + private val now = Instant.parse("2026-05-25T12:00:00Z") + private val clock = Clock.fixed(now, ZoneId.of("UTC")) + private val site = SiteModel().apply { url = SITE_URL } + + private lateinit var controller: GutenbergKitAnnouncementController + + @Before + fun setUp() { + controller = GutenbergKitAnnouncementController( + featureChecker, siteSettingsProvider, appPrefsWrapper, clock + ) + // Defaults that let shouldShowAnnouncement reach `true` unless a test overrides them. + whenever(featureChecker.isGutenbergKitRemoteFeatureEnabled()).thenReturn(true) + whenever(siteSettingsProvider.isBlockEditorDefault(site)).thenReturn(true) + whenever(appPrefsWrapper.getGutenbergKitSiteOverride(SITE_URL)).thenReturn(null) + whenever(appPrefsWrapper.getGutenbergKitAnnouncementDeferredUntil(SITE_URL)).thenReturn(0L) + } + + @Test + fun `shouldShowAnnouncement is true when all gates pass`() { + assertThat(controller.shouldShowAnnouncement(site)).isTrue() + } + + @Test + fun `shouldShowAnnouncement is false when site URL is empty`() { + val emptyUrlSite = SiteModel().apply { url = "" } + assertThat(controller.shouldShowAnnouncement(emptyUrlSite)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is false when site URL is null`() { + val nullUrlSite = SiteModel() + assertThat(controller.shouldShowAnnouncement(nullUrlSite)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is false when remote flag is off`() { + whenever(featureChecker.isGutenbergKitRemoteFeatureEnabled()).thenReturn(false) + assertThat(controller.shouldShowAnnouncement(site)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is false when site does not default to block editor`() { + whenever(siteSettingsProvider.isBlockEditorDefault(site)).thenReturn(false) + assertThat(controller.shouldShowAnnouncement(site)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is false when site has already opted in`() { + whenever(appPrefsWrapper.getGutenbergKitSiteOverride(SITE_URL)).thenReturn(true) + assertThat(controller.shouldShowAnnouncement(site)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is false when site has already opted out`() { + whenever(appPrefsWrapper.getGutenbergKitSiteOverride(SITE_URL)).thenReturn(false) + assertThat(controller.shouldShowAnnouncement(site)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is false while deferral is still in the future`() { + whenever(appPrefsWrapper.getGutenbergKitAnnouncementDeferredUntil(SITE_URL)) + .thenReturn(now.toEpochMilli() + 1) + assertThat(controller.shouldShowAnnouncement(site)).isFalse() + } + + @Test + fun `shouldShowAnnouncement is true once deferral has expired`() { + whenever(appPrefsWrapper.getGutenbergKitAnnouncementDeferredUntil(SITE_URL)) + .thenReturn(now.toEpochMilli() - 1) + assertThat(controller.shouldShowAnnouncement(site)).isTrue() + } + + @Test + fun `shouldShowAnnouncement is true when deferral equals current clock`() { + whenever(appPrefsWrapper.getGutenbergKitAnnouncementDeferredUntil(SITE_URL)) + .thenReturn(now.toEpochMilli()) + assertThat(controller.shouldShowAnnouncement(site)).isTrue() + } + + @Test + fun `onActivate writes a positive per-site override and clears any deferral`() { + controller.onActivate(site) + verify(appPrefsWrapper).setGutenbergKitSiteOverride(SITE_URL, true) + verify(appPrefsWrapper).setGutenbergKitAnnouncementDeferredUntil(SITE_URL, 0L) + } + + @Test + fun `setOverride with false writes opt-out and clears any deferral`() { + controller.setOverride(site, false) + verify(appPrefsWrapper).setGutenbergKitSiteOverride(SITE_URL, false) + verify(appPrefsWrapper).setGutenbergKitAnnouncementDeferredUntil(SITE_URL, 0L) + } + + @Test + fun `onMaybeLater defers for one week from now and does not write an override`() { + controller.onMaybeLater(site) + val expected = now.toEpochMilli() + GutenbergKitAnnouncementController.DEFER_DURATION_MILLIS + verify(appPrefsWrapper).setGutenbergKitAnnouncementDeferredUntil(SITE_URL, expected) + verify(appPrefsWrapper, never()).setGutenbergKitSiteOverride(eq(SITE_URL), any()) + } + + companion object { + private const val SITE_URL = "https://example.com" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt index 35b22ab7a8d2..bf541fbead60 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt @@ -7,6 +7,8 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.util.config.GutenbergKitFeature @@ -19,11 +21,14 @@ class GutenbergKitFeatureCheckerTest { @Mock private lateinit var gutenbergKitFeature: GutenbergKitFeature + @Mock + private lateinit var appPrefsWrapper: AppPrefsWrapper + private lateinit var featureChecker: GutenbergKitFeatureChecker @Before fun setUp() { - featureChecker = GutenbergKitFeatureChecker(experimentalFeatures, gutenbergKitFeature) + featureChecker = GutenbergKitFeatureChecker(experimentalFeatures, gutenbergKitFeature, appPrefsWrapper) } // Helper method to setup mock behavior @@ -106,7 +111,9 @@ class GutenbergKitFeatureCheckerTest { } @Test - fun `isGutenbergKitEnabled returns true when GutenbergKit feature is enabled`() { + fun `remote feature flag alone does not enable GutenbergKit for editor routing`() { + // The remote `gutenberg_kit` flag only gates the announcement and Site Settings toggle + // visibility. Editor routing requires either the experimental flag or a per-site opt-in. setupFeatureFlags( experimentalBlockEditor = false, gutenbergKitEnabled = true, @@ -115,11 +122,11 @@ class GutenbergKitFeatureCheckerTest { val result = featureChecker.isGutenbergKitEnabled() - assertThat(result).isTrue() + assertThat(result).isFalse() } @Test - fun `isGutenbergKitEnabled returns true when both experimental and GutenbergKit features are enabled`() { + fun `isGutenbergKitEnabled returns true when experimental flag is on regardless of remote flag`() { setupFeatureFlags( experimentalBlockEditor = true, gutenbergKitEnabled = true, @@ -232,28 +239,118 @@ class GutenbergKitFeatureCheckerTest { } @Test - fun `feature is enabled when at least one enabling flag is true and disable flag is false`() { - val enabledTestCases = listOf( - Triple(true, false, false), // Only experimental - Triple(false, true, false), // Only GutenbergKit - Triple(true, true, false) // Both enabled + fun `per-site opt-in enables GutenbergKit when no other flag is set`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isTrue() + } + + @Test + fun `remote feature flag on with no override does not enable for a site`() { + // Editor routing only: announcement visibility is checked via + // `isGutenbergKitRemoteFeatureEnabled()` separately. + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = true, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(null) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() + } + + @Test + fun `per-site opt-out wins over experimental flag`() { + setupFeatureFlags( + experimentalBlockEditor = true, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(false) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() + } + + @Test + fun `per-site opt-in wins when remote and experimental flags are off`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isTrue() + } + + @Test + fun `per-site opt-in wins when remote flag is on`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = true, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isTrue() + } + + @Test + fun `disable flag overrides per-site opt-in`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = true + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() + } + + @Test + fun `editor routing is enabled only by experimental flag or per-site opt-in`() { + // The remote `gutenberg_kit` flag is intentionally NOT an editor-routing input — it only + // gates announcement visibility. Editor routing requires experimental OR per-site opt-in. + data class Case( + val experimental: Boolean, + val gutenbergKitRemote: Boolean, + val siteOverride: Boolean?, + val expected: Boolean, + ) + val cases = listOf( + Case(experimental = true, gutenbergKitRemote = false, siteOverride = null, expected = true), + Case(experimental = true, gutenbergKitRemote = true, siteOverride = null, expected = true), + Case(experimental = false, gutenbergKitRemote = true, siteOverride = null, expected = false), + Case(experimental = false, gutenbergKitRemote = false, siteOverride = true, expected = true), + Case(experimental = false, gutenbergKitRemote = true, siteOverride = true, expected = true), + Case(experimental = true, gutenbergKitRemote = true, siteOverride = false, expected = false), + Case(experimental = false, gutenbergKitRemote = false, siteOverride = null, expected = false), ) - enabledTestCases.forEach { (experimental, gutenbergKit, disable) -> + cases.forEach { case -> setupFeatureFlags( - experimentalBlockEditor = experimental, - gutenbergKitEnabled = gutenbergKit, - disableExperimentalBlockEditor = disable + experimentalBlockEditor = case.experimental, + gutenbergKitEnabled = case.gutenbergKitRemote, + disableExperimentalBlockEditor = false ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")) + .thenReturn(case.siteOverride) - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result) - .withFailMessage( - "Should be true when at least one enabling flag is true " + - "(experimental=$experimental, gutenbergKit=$gutenbergKit)" - ) - .isTrue() + assertThat(featureChecker.isGutenbergKitEnabled(site)) + .withFailMessage("Case $case") + .isEqualTo(case.expected) } } } From a1399cd6b10aa656d4a270627ef17fe38a303534 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 27 May 2026 10:45:46 -0600 Subject: [PATCH 2/2] Remove the 'Disable experimental block editor' experimental feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now redundant: with the announcement work decoupling the remote `gutenberg_kit` flag from editor routing, GutenbergKit is no longer default-on. The kill switch's original purpose was to override the remote flag forcing GBKit on — that no longer happens. Also the inverse naming made it easy to misclick (toggling "Disable…" on is the opt-out path). Routing resolution simplifies to `siteOverride ?: isExperimentalBlockEditorEnabled`. --- .../android/ui/posts/EditorLauncher.kt | 5 +- .../ui/posts/GutenbergKitFeatureChecker.kt | 12 +- .../ExperimentalFeatures.kt | 5 - .../ExperimentalFeaturesViewModel.kt | 6 +- WordPress/src/main/res/values/strings.xml | 2 - .../posts/GutenbergKitFeatureCheckerTest.kt | 208 ++---------------- .../ExperimentalFeaturesViewModelTest.kt | 23 +- 7 files changed, 22 insertions(+), 239 deletions(-) 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 47c5ce1e3029..7fb6040f728b 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 @@ -122,8 +122,6 @@ class EditorLauncher @Inject constructor( private fun logFeatureDisabledReason(featureState: GutenbergKitFeatureChecker.FeatureState) { val reason = when { - featureState.isDisableExperimentalBlockEditorEnabled -> - "the experimental block editor is explicitly disabled" featureState.siteOverride == false -> "this site has an explicit GutenbergKit opt-out" featureState.siteOverride == null && !featureState.isExperimentalBlockEditorEnabled -> @@ -147,8 +145,7 @@ class EditorLauncher @Inject constructor( ): String { return "(experimental_block_editor: ${featureState.isExperimentalBlockEditorEnabled}, " + "gutenberg_kit_remote_flag: ${featureState.isGutenbergKitFeatureEnabled}, " + - "site_override: ${featureState.siteOverride}, " + - "disable_experimental_block_editor: ${featureState.isDisableExperimentalBlockEditorEnabled})" + "site_override: ${featureState.siteOverride})" } private fun logEditorDecision( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt index e65f166c98bb..2a863617244b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitFeatureChecker.kt @@ -24,15 +24,13 @@ class GutenbergKitFeatureChecker @Inject constructor( data class FeatureState( val isExperimentalBlockEditorEnabled: Boolean, val isGutenbergKitFeatureEnabled: Boolean, - val isDisableExperimentalBlockEditorEnabled: Boolean, val siteOverride: Boolean? = null ) { /** * Determines if GutenbergKit should be used for editor routing. * * Resolution: a per-site override (set or cleared via the announcement sheet or Site - * Settings) wins over everything except the `DISABLE_EXPERIMENTAL_BLOCK_EDITOR` kill - * switch. When absent, falls back to the experimental flag. + * Settings) wins. When absent, falls back to the experimental flag. * * The remote `gutenberg_kit` feature flag is deliberately NOT part of editor routing * — it only gates the visibility of opt-in surfaces (the announcement bottom sheet and @@ -41,10 +39,7 @@ class GutenbergKitFeatureChecker @Inject constructor( * GutenbergKit the default for everyone, the change is a one-line edit here. */ val isGutenbergKitEnabled: Boolean - get() { - val resolved = siteOverride ?: isExperimentalBlockEditorEnabled - return resolved && !isDisableExperimentalBlockEditorEnabled - } + get() = siteOverride ?: isExperimentalBlockEditorEnabled } /** @@ -55,9 +50,6 @@ class GutenbergKitFeatureChecker @Inject constructor( return FeatureState( isExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled(Feature.EXPERIMENTAL_BLOCK_EDITOR), isGutenbergKitFeatureEnabled = gutenbergKitFeature.isEnabled(), - isDisableExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled( - Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR - ), siteOverride = site?.url?.let { appPrefsWrapper.getGutenbergKitSiteOverride(it) } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt index 890ec39b3952..94856e9d2d79 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt @@ -20,11 +20,6 @@ class ExperimentalFeatures @Inject constructor( val labelResId: Int, val descriptionResId: Int ) { - DISABLE_EXPERIMENTAL_BLOCK_EDITOR( - "disable_experimental_block_editor", - R.string.disable_experimental_block_editor, - R.string.disable_experimental_block_editor_description - ), EXPERIMENTAL_BLOCK_EDITOR( "experimental_block_editor", R.string.experimental_block_editor, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt index 3df29d27033b..ee3cc838a0a8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt @@ -13,13 +13,11 @@ import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.util.AppLog.T import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper -import org.wordpress.android.util.config.GutenbergKitFeature import javax.inject.Inject @HiltViewModel internal class ExperimentalFeaturesViewModel @Inject constructor( private val experimentalFeatures: ExperimentalFeatures, - private val gutenbergKitFeature: GutenbergKitFeature, private val appLogWrapper: AppLogWrapper, private val appPrefsWrapper: AppPrefsWrapper, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, @@ -44,10 +42,8 @@ internal class ExperimentalFeaturesViewModel @Inject constructor( // Only show Post Types feature in debug builds return if (feature == Feature.EXPERIMENTAL_POST_TYPES) { BuildConfig.DEBUG - } else if (gutenbergKitFeature.isEnabled()) { - feature != Feature.EXPERIMENTAL_BLOCK_EDITOR } else { - feature != Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR + true } } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 2b3e667b0258..4f35c3df30fe 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -981,8 +981,6 @@ Experimental Features - Disable experimental block editor - Opt out of additional block types and settings Experimental block editor will become the default in a future release and the ability to disable it will be removed. Experimental block editor Access additional block types and settings diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt index bf541fbead60..a3ee3a606152 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt @@ -31,220 +31,76 @@ class GutenbergKitFeatureCheckerTest { featureChecker = GutenbergKitFeatureChecker(experimentalFeatures, gutenbergKitFeature, appPrefsWrapper) } - // Helper method to setup mock behavior private fun setupFeatureFlags( experimentalBlockEditor: Boolean = false, gutenbergKitEnabled: Boolean = false, - disableExperimentalBlockEditor: Boolean = false ) { whenever(experimentalFeatures.isEnabled(Feature.EXPERIMENTAL_BLOCK_EDITOR)) .thenReturn(experimentalBlockEditor) whenever(gutenbergKitFeature.isEnabled()).thenReturn(gutenbergKitEnabled) - whenever(experimentalFeatures.isEnabled(Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR)) - .thenReturn(disableExperimentalBlockEditor) } // ===== Feature State Tests ===== @Test fun `getFeatureState returns correct individual flag values when all flags are false`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = false) val featureState = featureChecker.getFeatureState() assertThat(featureState.isExperimentalBlockEditorEnabled).isFalse() assertThat(featureState.isGutenbergKitFeatureEnabled).isFalse() - assertThat(featureState.isDisableExperimentalBlockEditorEnabled).isFalse() - assertThat(featureState.isGutenbergKitEnabled).isFalse() - } - - @Test - fun `getFeatureState returns correct individual flag values when all flags are true`() { - setupFeatureFlags( - experimentalBlockEditor = true, - gutenbergKitEnabled = true, - disableExperimentalBlockEditor = true - ) - - val featureState = featureChecker.getFeatureState() - - assertThat(featureState.isExperimentalBlockEditorEnabled).isTrue() - assertThat(featureState.isGutenbergKitFeatureEnabled).isTrue() - assertThat(featureState.isDisableExperimentalBlockEditorEnabled).isTrue() - // Should be false because disable flag overrides assertThat(featureState.isGutenbergKitEnabled).isFalse() } @Test fun `getFeatureState returns correct values for mixed flag states`() { - setupFeatureFlags( - experimentalBlockEditor = true, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = true, gutenbergKitEnabled = false) val featureState = featureChecker.getFeatureState() assertThat(featureState.isExperimentalBlockEditorEnabled).isTrue() assertThat(featureState.isGutenbergKitFeatureEnabled).isFalse() - assertThat(featureState.isDisableExperimentalBlockEditorEnabled).isFalse() assertThat(featureState.isGutenbergKitEnabled).isTrue() } - // ===== GutenbergKit Enabled Logic Tests ===== + // ===== Editor-routing Logic Tests ===== @Test fun `isGutenbergKitEnabled returns true when experimental block editor is enabled`() { - setupFeatureFlags( - experimentalBlockEditor = true, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = true, gutenbergKitEnabled = false) - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result).isTrue() + assertThat(featureChecker.isGutenbergKitEnabled()).isTrue() } @Test fun `remote feature flag alone does not enable GutenbergKit for editor routing`() { // The remote `gutenberg_kit` flag only gates the announcement and Site Settings toggle // visibility. Editor routing requires either the experimental flag or a per-site opt-in. - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = true, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = true) - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result).isFalse() + assertThat(featureChecker.isGutenbergKitEnabled()).isFalse() } @Test fun `isGutenbergKitEnabled returns true when experimental flag is on regardless of remote flag`() { - setupFeatureFlags( - experimentalBlockEditor = true, - gutenbergKitEnabled = true, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = true, gutenbergKitEnabled = true) - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result).isTrue() + assertThat(featureChecker.isGutenbergKitEnabled()).isTrue() } @Test fun `isGutenbergKitEnabled returns false when all flags are disabled`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) - - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result).isFalse() - } - - @Test - fun `isGutenbergKitEnabled returns false when disable flag is enabled regardless of other flags`() { - setupFeatureFlags( - experimentalBlockEditor = true, - gutenbergKitEnabled = true, - disableExperimentalBlockEditor = true - ) - - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result).isFalse() - } - - @Test - fun `isGutenbergKitEnabled returns false when only disable flag is enabled`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = true - ) - - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result).isFalse() - } - - // ===== Edge Cases and Consistency Tests ===== - - @Test - fun `isGutenbergKitEnabled matches getFeatureState isGutenbergKitEnabled for all combinations`() { - val testCases = listOf( - Triple(false, false, false), - Triple(false, false, true), - Triple(false, true, false), - Triple(false, true, true), - Triple(true, false, false), - Triple(true, false, true), - Triple(true, true, false), - Triple(true, true, true) - ) - - testCases.forEach { (experimental, gutenbergKit, disable) -> - setupFeatureFlags( - experimentalBlockEditor = experimental, - gutenbergKitEnabled = gutenbergKit, - disableExperimentalBlockEditor = disable - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = false) - val directResult = featureChecker.isGutenbergKitEnabled() - val stateResult = featureChecker.getFeatureState().isGutenbergKitEnabled - - assertThat(directResult) - .withFailMessage( - "Results should match for flags: experimental=$experimental, " + - "gutenbergKit=$gutenbergKit, disable=$disable" - ) - .isEqualTo(stateResult) - } + assertThat(featureChecker.isGutenbergKitEnabled()).isFalse() } - @Test - fun `disable flag always overrides other settings`() { - val testCases = listOf( - Triple(false, false, true), // Only disable flag - Triple(false, true, true), // GutenbergKit + disable - Triple(true, false, true), // Experimental + disable - Triple(true, true, true) // All flags on - ) - - testCases.forEach { (experimental, gutenbergKit, disable) -> - setupFeatureFlags( - experimentalBlockEditor = experimental, - gutenbergKitEnabled = gutenbergKit, - disableExperimentalBlockEditor = disable - ) - - val result = featureChecker.isGutenbergKitEnabled() - - assertThat(result) - .withFailMessage( - "Should be false when disable flag is true " + - "(experimental=$experimental, gutenbergKit=$gutenbergKit)" - ) - .isFalse() - } - } + // ===== Per-site Override Tests ===== @Test fun `per-site opt-in enables GutenbergKit when no other flag is set`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = false) val site = SiteModel().apply { url = "https://example.com" } whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) @@ -255,11 +111,7 @@ class GutenbergKitFeatureCheckerTest { fun `remote feature flag on with no override does not enable for a site`() { // Editor routing only: announcement visibility is checked via // `isGutenbergKitRemoteFeatureEnabled()` separately. - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = true, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = true) val site = SiteModel().apply { url = "https://example.com" } whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(null) @@ -268,11 +120,7 @@ class GutenbergKitFeatureCheckerTest { @Test fun `per-site opt-out wins over experimental flag`() { - setupFeatureFlags( - experimentalBlockEditor = true, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = true, gutenbergKitEnabled = false) val site = SiteModel().apply { url = "https://example.com" } whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(false) @@ -281,11 +129,7 @@ class GutenbergKitFeatureCheckerTest { @Test fun `per-site opt-in wins when remote and experimental flags are off`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = false) val site = SiteModel().apply { url = "https://example.com" } whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) @@ -294,30 +138,13 @@ class GutenbergKitFeatureCheckerTest { @Test fun `per-site opt-in wins when remote flag is on`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = true, - disableExperimentalBlockEditor = false - ) + setupFeatureFlags(experimentalBlockEditor = false, gutenbergKitEnabled = true) val site = SiteModel().apply { url = "https://example.com" } whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) assertThat(featureChecker.isGutenbergKitEnabled(site)).isTrue() } - @Test - fun `disable flag overrides per-site opt-in`() { - setupFeatureFlags( - experimentalBlockEditor = false, - gutenbergKitEnabled = false, - disableExperimentalBlockEditor = true - ) - val site = SiteModel().apply { url = "https://example.com" } - whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) - - assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() - } - @Test fun `editor routing is enabled only by experimental flag or per-site opt-in`() { // The remote `gutenberg_kit` flag is intentionally NOT an editor-routing input — it only @@ -342,7 +169,6 @@ class GutenbergKitFeatureCheckerTest { setupFeatureFlags( experimentalBlockEditor = case.experimental, gutenbergKitEnabled = case.gutenbergKitRemote, - disableExperimentalBlockEditor = false ) val site = SiteModel().apply { url = "https://example.com" } whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt index 9ba55cc46192..c08bd35d61fb 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt @@ -14,16 +14,12 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper -import org.wordpress.android.util.config.GutenbergKitFeature @ExperimentalCoroutinesApi class ExperimentalFeaturesViewModelTest : BaseUnitTest() { @Mock private lateinit var experimentalFeatures: ExperimentalFeatures - @Mock - private lateinit var gutenbergKitFeature: GutenbergKitFeature - @Mock private lateinit var appLogWrapper: AppLogWrapper @@ -38,31 +34,15 @@ class ExperimentalFeaturesViewModelTest : BaseUnitTest() { @Before fun setUp() { whenever(experimentalFeatures.isEnabled(any())).thenReturn(false) - whenever(gutenbergKitFeature.isEnabled()).thenReturn(false) } @Test - fun `init shows disable block editor when gutenberg kit is enabled`() = test { - whenever(gutenbergKitFeature.isEnabled()).thenReturn(true) - - createViewModel() - - val states = viewModel.switchStates.value - - assertThat(states).containsKey(Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR) - assertThat(states).doesNotContainKey(Feature.EXPERIMENTAL_BLOCK_EDITOR) - } - - @Test - fun `init shows experimental block editor when gutenberg kit is disabled`() = test { - whenever(gutenbergKitFeature.isEnabled()).thenReturn(false) - + fun `init shows experimental block editor feature`() = test { createViewModel() val states = viewModel.switchStates.value assertThat(states).containsKey(Feature.EXPERIMENTAL_BLOCK_EDITOR) - assertThat(states).doesNotContainKey(Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR) } @Test @@ -133,7 +113,6 @@ class ExperimentalFeaturesViewModelTest : BaseUnitTest() { private fun createViewModel() { viewModel = ExperimentalFeaturesViewModel( experimentalFeatures = experimentalFeatures, - gutenbergKitFeature = gutenbergKitFeature, appLogWrapper = appLogWrapper, appPrefsWrapper = appPrefsWrapper, analyticsTrackerWrapper = analyticsTrackerWrapper