From 2bfdd304fe3d790a7925bba0fc20aabaebdf1b08 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] 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 | 121 +++++++++++++++ .../GutenbergKitAnnouncementController.kt | 60 ++++++++ .../ui/posts/GutenbergKitFeatureChecker.kt | 54 ++++--- .../wordpress/android/ui/prefs/AppPrefs.java | 115 +++++++++++++++ .../android/ui/prefs/AppPrefsWrapper.kt | 12 ++ .../ui/prefs/SiteSettingsFragment.java | 46 +++++- ...utenberg_kit_announcement_bottom_sheet.xml | 75 ++++++++++ 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, 826 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/res/layout/gutenberg_kit_announcement_bottom_sheet.xml 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..4f4cceba9964 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt @@ -0,0 +1,121 @@ +package org.wordpress.android.ui.posts + +import android.content.DialogInterface +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.color.MaterialColors +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.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 only renders and forwards + * button taps. + */ +@AndroidEntryPoint +class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() { + @Inject lateinit var controller: GutenbergKitAnnouncementController + + private var decisionRecorded = false + + 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 = inflater.inflate(R.layout.gutenberg_kit_announcement_bottom_sheet, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Prevent Material's BottomSheetDialog from applying status-bar insets as top padding. + // Consume only the status-bar inset so IME and gesture insets still propagate to children. + ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + v.setPadding(v.paddingLeft, 0, v.paddingRight, v.paddingBottom) + WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) + .build() + } + + bindBodyWithLearnMore(view.findViewById(R.id.body_text)) + + view.findViewById(R.id.activate_button).setOnClickListener { + controller.onActivate(site) + decisionRecorded = true + dismiss() + } + + view.findViewById(R.id.maybe_later_button).setOnClickListener { + controller.onMaybeLater(site) + decisionRecorded = true + dismiss() + } + } + + 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) + } + + private fun bindBodyWithLearnMore(textView: TextView) { + val learnMore = getString(R.string.gutenberg_kit_announcement_learn_more) + val combined = SpannableStringBuilder( + getString(R.string.gutenberg_kit_announcement_body, learnMore) + ) + val start = combined.indexOf(learnMore) + val end = start + learnMore.length + val color = MaterialColors.getColor(textView, androidx.appcompat.R.attr.colorPrimary) + combined.setSpan(object : ClickableSpan() { + override fun onClick(widget: View) { + WPWebViewActivity.openURL( + requireContext(), + getString(R.string.gutenberg_kit_learn_more_url) + ) + } + + override fun updateDrawState(ds: TextPaint) { + super.updateDrawState(ds) + ds.isUnderlineText = false + } + }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + combined.setSpan(ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + textView.text = combined + textView.movementMethod = LinkMovementMethod.getInstance() + } + + 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/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/layout/gutenberg_kit_announcement_bottom_sheet.xml b/WordPress/src/main/res/layout/gutenberg_kit_announcement_bottom_sheet.xml new file mode 100644 index 000000000000..f4a5a05cfebb --- /dev/null +++ b/WordPress/src/main/res/layout/gutenberg_kit_announcement_bottom_sheet.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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) } } }