From 2cbc9bc96df38544b25bd1c68201fb1875ff1155 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:43:19 -0600 Subject: [PATCH 1/5] Add per-site GutenbergKit opt-in and announcement bottom sheet Surfaces the new GutenbergKit editor to users ahead of its default rollout. Adds a one-time announcement bottom sheet on app start and a per-site toggle in Site Settings to opt in or out. Gated on the remote feature flag and skipped for sites where the block editor is disabled. --- .../android/ui/main/WPMainActivity.java | 6 ++ .../android/ui/posts/EditorLauncher.kt | 4 +- ...nbergKitAnnouncementBottomSheetFragment.kt | 98 +++++++++++++++++++ .../ui/posts/GutenbergKitFeatureChecker.kt | 40 ++++---- .../wordpress/android/ui/prefs/AppPrefs.java | 48 +++++++++ .../android/ui/prefs/AppPrefsWrapper.kt | 10 ++ .../ui/prefs/SiteSettingsFragment.java | 18 +++- .../viewmodel/main/WPMainActivityViewModel.kt | 17 ++++ ...utenberg_kit_announcement_bottom_sheet.xml | 75 ++++++++++++++ WordPress/src/main/res/values/key_strings.xml | 1 + WordPress/src/main/res/values/strings.xml | 10 ++ WordPress/src/main/res/xml/site_settings.xml | 6 ++ .../posts/GutenbergKitFeatureCheckerTest.kt | 33 ++++++- .../main/WPMainActivityViewModelTest.kt | 4 + 14 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt create mode 100644 WordPress/src/main/res/layout/gutenberg_kit_announcement_bottom_sheet.xml diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index 77eae30b9768..fdba11b4818c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -107,6 +107,7 @@ import org.wordpress.android.ui.photopicker.MediaPickerLauncher; import org.wordpress.android.ui.posts.EditorConstants; import org.wordpress.android.ui.posts.EditorLauncher; +import org.wordpress.android.ui.posts.GutenbergKitAnnouncementBottomSheetFragment; import org.wordpress.android.ui.posts.PostUtils.EntryPoint; import org.wordpress.android.ui.prefs.AppPrefs; import org.wordpress.android.ui.prefs.AppSettingsActivity; @@ -672,6 +673,11 @@ private void initViewModel() { .show(getSupportFragmentManager(), FeatureAnnouncementDialogFragment.TAG); }); + mViewModel.getOnGutenbergKitAnnouncementRequested().observe(this, siteUrl -> { + GutenbergKitAnnouncementBottomSheetFragment.newInstance(siteUrl) + .show(getSupportFragmentManager(), GutenbergKitAnnouncementBottomSheetFragment.TAG); + }); + mFloatingActionButton.setOnClickListener(v -> { PageType selectedPage = getSelectedPage(); if (selectedPage != null) mViewModel.onFabClicked(getSelectedSite(), selectedPage); 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..95050a745adf 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) 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..5f8c631e4e03 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt @@ -0,0 +1,98 @@ +package org.wordpress.android.ui.posts + +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.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 org.wordpress.android.R +import org.wordpress.android.ui.WPWebViewActivity +import org.wordpress.android.ui.prefs.AppPrefs + +/** + * One-time announcement bottom sheet for the upcoming GutenbergKit editor. + * Opts the user in for the currently selected site, or dismisses. Provides a + * "Learn more" link that opens a web page. + */ +class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() { + private var siteUrl: String? = null + var onOptIn: (() -> Unit)? = null + + 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. + ViewCompat.setOnApplyWindowInsetsListener(view) { v, _ -> + v.setPadding(v.paddingLeft, 0, v.paddingRight, v.paddingBottom) + WindowInsetsCompat.CONSUMED + } + + siteUrl = arguments?.getString(ARG_SITE_URL) + + bindBodyWithLearnMore(view.findViewById(R.id.body_text)) + + view.findViewById(R.id.try_now_button).setOnClickListener { + siteUrl?.let { AppPrefs.setGutenbergKitEnabledForSite(it, true) } + onOptIn?.invoke() + dismiss() + } + + view.findViewById(R.id.maybe_later_button).setOnClickListener { + dismiss() + } + } + + private fun bindBodyWithLearnMore(textView: TextView) { + val body = getString(R.string.gutenberg_kit_announcement_body) + val learnMore = getString(R.string.gutenberg_kit_announcement_learn_more) + val combined = SpannableStringBuilder(body).append(' ').append(learnMore) + val start = combined.length - learnMore.length + val end = combined.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" + private const val ARG_SITE_URL = "site_url" + + @JvmStatic + fun newInstance(siteUrl: String?): GutenbergKitAnnouncementBottomSheetFragment { + return GutenbergKitAnnouncementBottomSheetFragment().apply { + arguments = Bundle().apply { putString(ARG_SITE_URL, siteUrl) } + } + } + } +} 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..74f7b8b8b4cd 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,44 @@ class GutenbergKitFeatureChecker @Inject constructor( data class FeatureState( val isExperimentalBlockEditorEnabled: Boolean, val isGutenbergKitFeatureEnabled: Boolean, - val isDisableExperimentalBlockEditorEnabled: Boolean + val isDisableExperimentalBlockEditorEnabled: Boolean, + val isEnabledForSite: Boolean = false ) { /** * Determines if GutenbergKit should be enabled based on the feature states. */ val isGutenbergKitEnabled: Boolean - get() = (isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled) && + get() = (isExperimentalBlockEditorEnabled || + isGutenbergKitFeatureEnabled || + isEnabledForSite) && !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 - ) + ), + isEnabledForSite = site?.url?.let { appPrefsWrapper.isGutenbergKitEnabledForSite(it) } ?: false ) } /** - * 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..2bc914d6fcc3 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,7 @@ 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, POST_LIST_AUTHOR_FILTER, POST_LIST_VIEW_LAYOUT_TYPE, @@ -322,6 +323,8 @@ public enum UndeletablePrefKey implements PrefKey { // These preferences persist across logout/login cycles. IS_TRACK_NETWORK_REQUESTS_ENABLED, TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, + + GUTENBERG_KIT_ANNOUNCEMENT_SHOWN, } static SharedPreferences prefs() { @@ -831,6 +834,51 @@ public static boolean isGutenbergInfoPopupDisplayed(String siteURL) { return urls != null && urls.contains(siteURL); } + public static boolean isGutenbergKitEnabledForSite(String siteURL) { + if (TextUtils.isEmpty(siteURL)) { + return false; + } + Set urls; + try { + urls = prefs().getStringSet(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES.name(), null); + } catch (ClassCastException exp) { + return false; + } + return urls != null && urls.contains(siteURL); + } + + public static void setGutenbergKitEnabledForSite(String siteURL, boolean enabled) { + if (TextUtils.isEmpty(siteURL)) { + return; + } + Set urls; + try { + urls = prefs().getStringSet(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES.name(), null); + } catch (ClassCastException exp) { + return; + } + + Set newUrls = new HashSet<>(); + if (urls != null) { + newUrls.addAll(urls); + } + if (enabled) { + newUrls.add(siteURL); + } else { + newUrls.remove(siteURL); + } + + prefs().edit().putStringSet(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES.name(), newUrls).apply(); + } + + public static boolean wasGutenbergKitAnnouncementShown() { + return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_SHOWN.name(), false); + } + + public static void setGutenbergKitAnnouncementShown(boolean shown) { + prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_SHOWN.name(), shown).apply(); + } + 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..8926d7eaf641 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,16 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setStatsNewStatsSuggestionLastDismissedAt(timestamp: Long) = AppPrefs.setStatsNewStatsSuggestionLastDismissedAt(timestamp) + fun isGutenbergKitEnabledForSite(siteUrl: String?): Boolean = + AppPrefs.isGutenbergKitEnabledForSite(siteUrl) + + fun setGutenbergKitEnabledForSite(siteUrl: String?, enabled: Boolean) = + AppPrefs.setGutenbergKitEnabledForSite(siteUrl, enabled) + + var wasGutenbergKitAnnouncementShown: Boolean + get() = AppPrefs.wasGutenbergKitAnnouncementShown() + set(value) = AppPrefs.setGutenbergKitAnnouncementShown(value) + 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..d0a9ab9bfbcd 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 @@ -230,6 +230,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 +853,8 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { mSiteSettings.setUseThemeStyles((Boolean) newValue); } else if (preference == mUseThirdPartyBlocksPref) { mSiteSettings.setUseThirdPartyBlocks((Boolean) newValue); + } else if (preference == mGutenbergKitPref) { + AppPrefs.setGutenbergKitEnabledForSite(mSite.getUrl(), (Boolean) newValue); } else if (preference == mBloggingPromptsPref) { final boolean isEnabled = (boolean) newValue; mPromptsSettingsHelper.updatePromptsCardEnabledBlocking(mSite.getId(), isEnabled); @@ -1044,6 +1047,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(AppPrefs.isGutenbergKitEnabledForSite(mSite.getUrl())); + mSiteAcceleratorSettings = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings); mSiteAcceleratorSettingsNested = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings_nested); @@ -1115,6 +1122,12 @@ 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); + } + // hide Admin options depending of capabilities on this site if ((!isAccessedViaWPComRest && !mSite.isSelfHostedAdmin()) || (isAccessedViaWPComRest && !mSite.getHasCapabilityManageOptions())) { @@ -1240,7 +1253,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 +1599,9 @@ public void setPreferencesFromSiteSettings() { mGutenbergDefaultForNewPosts.setChecked(SiteUtils.isBlockEditorDefaultForNewPost(mSite)); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); + if (mGutenbergKitPref != null) { + mGutenbergKitPref.setChecked(AppPrefs.isGutenbergKitEnabledForSite(mSite.getUrl())); + } setAdFreeHostingChecked(mSiteSettings.isAdFreeHostingEnabled()); boolean checked = mSiteSettings.isImprovedSearchEnabled() || mSiteSettings.getJetpackSearchEnabled(); mImprovedSearch.setChecked(checked); diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt index 411a4e08cc92..eed5d71eb7cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt @@ -29,12 +29,14 @@ import org.wordpress.android.ui.main.analytics.MainCreateSheetTracker import org.wordpress.android.ui.main.utils.MainCreateSheetHelper import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.mysite.cards.dashboard.bloggingprompts.BloggingPromptAttribution +import org.wordpress.android.ui.posts.GutenbergKitFeatureChecker import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.privacy.banner.domain.ShouldAskPrivacyConsent import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.ui.whatsnew.FeatureAnnouncementProvider import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.FluxCUtils +import org.wordpress.android.util.SiteUtils import org.wordpress.android.util.SiteUtils.hasFullAccessToContent import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import org.wordpress.android.viewmodel.Event @@ -58,6 +60,7 @@ class WPMainActivityViewModel @Inject constructor( private val shouldAskPrivacyConsent: ShouldAskPrivacyConsent, private val mainCreateSheetHelper: MainCreateSheetHelper, private val mainCreateSheetTracker: MainCreateSheetTracker, + private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, ) : ScopedViewModel(mainDispatcher) { private var isStarted = false @@ -79,6 +82,9 @@ class WPMainActivityViewModel @Inject constructor( private val _onFeatureAnnouncementRequested = SingleLiveEvent() val onFeatureAnnouncementRequested: LiveData = _onFeatureAnnouncementRequested + private val _onGutenbergKitAnnouncementRequested = SingleLiveEvent() + val onGutenbergKitAnnouncementRequested: LiveData = _onGutenbergKitAnnouncementRequested + private val _createPostWithBloggingPrompt = SingleLiveEvent() val createPostWithBloggingPrompt: LiveData = _createPostWithBloggingPrompt @@ -253,6 +259,17 @@ class WPMainActivityViewModel @Inject constructor( setMainFabUiState(showFab, site, page) checkAndShowFeatureAnnouncement() + checkAndShowGutenbergKitAnnouncement(site) + } + + private fun checkAndShowGutenbergKitAnnouncement(site: SiteModel?) { + if (!gutenbergKitFeatureChecker.isGutenbergKitRemoteFeatureEnabled()) return + if (appPrefsWrapper.wasGutenbergKitAnnouncementShown) return + if (site == null) return + if (!SiteUtils.isBlockEditorDefaultForNewPost(site)) return + + appPrefsWrapper.wasGutenbergKitAnnouncementShown = true + _onGutenbergKitAnnouncementRequested.postValue(site.url) } private fun checkAndShowFeatureAnnouncement() { 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..98388cb033b5 --- /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 71931f0c758f..c1e3cbef646b 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -706,6 +706,16 @@ Load third-party blocks from plugins installed on your site. Your site doesn\'t support loading third-party blocks in the editor. Failed to fetch site settings – some editor functionality may be limited. + Try the new editor + Opt in to the next-generation block editor for this site + + + A better block editor is coming + The next generation block editor is coming – and it brings all of the long-missing WordPress Core blocks. You can try it now on this site and switch back anytime from Site Settings.\n\nStarting in May, it will become the default editor for everyone. + Try it now + 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/posts/GutenbergKitFeatureCheckerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergKitFeatureCheckerTest.kt index 35b22ab7a8d2..b0fb55eddf36 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 @@ -231,6 +236,32 @@ class GutenbergKitFeatureCheckerTest { } } + @Test + fun `isGutenbergKitEnabled returns true when only per-site opt-in is set`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.isGutenbergKitEnabledForSite("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.isGutenbergKitEnabledForSite("https://example.com")).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() + } + @Test fun `feature is enabled when at least one enabling flag is true and disable flag is false`() { val enabledTestCases = listOf( diff --git a/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt index 3254941410d0..0423ebfbc2da 100644 --- a/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt @@ -98,6 +98,9 @@ class WPMainActivityViewModelTest : BaseUnitTest() { @Mock private lateinit var mainCreateSheetTracker: MainCreateSheetTracker + @Mock + private lateinit var gutenbergKitFeatureChecker: org.wordpress.android.ui.posts.GutenbergKitFeatureChecker + private val featureAnnouncement = FeatureAnnouncement( "14.7", 2, @@ -150,6 +153,7 @@ class WPMainActivityViewModelTest : BaseUnitTest() { shouldAskPrivacyConsent, mainCreateSheetHelper, mainCreateSheetTracker, + gutenbergKitFeatureChecker, NoDelayCoroutineDispatcher(), ) viewModel.onFeatureAnnouncementRequested.observeForever( From 5f1f5fcd06a36b3fe9ae62200a61863e94db352b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 20 May 2026 21:51:33 -0600 Subject: [PATCH 2/5] Switch GutenbergKit opt-in to global flag with per-site override The announcement bottom sheet now sets a single app-wide opt-in instead of a per-site flag. The Site Settings toggle becomes a tri-state override (opt-in / opt-out / follow global), stored as two StringSets so absence means "follow global." Resolution: siteOverride ?? globalOptIn, still gated by the experimental, remote, and kill-switch flags. --- .../android/ui/main/WPMainActivity.java | 4 +- ...nbergKitAnnouncementBottomSheetFragment.kt | 20 +---- .../ui/posts/GutenbergKitFeatureChecker.kt | 17 +++-- .../wordpress/android/ui/prefs/AppPrefs.java | 76 ++++++++++++++----- .../android/ui/prefs/AppPrefsWrapper.kt | 12 ++- .../ui/prefs/SiteSettingsFragment.java | 11 ++- .../viewmodel/main/WPMainActivityViewModel.kt | 6 +- WordPress/src/main/res/values/strings.xml | 2 +- .../posts/GutenbergKitFeatureCheckerTest.kt | 44 ++++++++++- 9 files changed, 136 insertions(+), 56 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index fdba11b4818c..393084f5539b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -673,8 +673,8 @@ private void initViewModel() { .show(getSupportFragmentManager(), FeatureAnnouncementDialogFragment.TAG); }); - mViewModel.getOnGutenbergKitAnnouncementRequested().observe(this, siteUrl -> { - GutenbergKitAnnouncementBottomSheetFragment.newInstance(siteUrl) + mViewModel.getOnGutenbergKitAnnouncementRequested().observe(this, unused -> { + new GutenbergKitAnnouncementBottomSheetFragment() .show(getSupportFragmentManager(), GutenbergKitAnnouncementBottomSheetFragment.TAG); }); 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 index 5f8c631e4e03..b5f970291675 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt @@ -22,13 +22,10 @@ import org.wordpress.android.ui.prefs.AppPrefs /** * One-time announcement bottom sheet for the upcoming GutenbergKit editor. - * Opts the user in for the currently selected site, or dismisses. Provides a - * "Learn more" link that opens a web page. + * Sets the app-wide opt-in flag, or dismisses. Provides a "Learn more" link + * that opens a web page. */ class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() { - private var siteUrl: String? = null - var onOptIn: (() -> Unit)? = null - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -44,13 +41,10 @@ class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() WindowInsetsCompat.CONSUMED } - siteUrl = arguments?.getString(ARG_SITE_URL) - bindBodyWithLearnMore(view.findViewById(R.id.body_text)) view.findViewById(R.id.try_now_button).setOnClickListener { - siteUrl?.let { AppPrefs.setGutenbergKitEnabledForSite(it, true) } - onOptIn?.invoke() + AppPrefs.setGutenbergKitUserOptedIn(true) dismiss() } @@ -86,13 +80,5 @@ class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() companion object { const val TAG = "GutenbergKitAnnouncementBottomSheetFragment" - private const val ARG_SITE_URL = "site_url" - - @JvmStatic - fun newInstance(siteUrl: String?): GutenbergKitAnnouncementBottomSheetFragment { - return GutenbergKitAnnouncementBottomSheetFragment().apply { - arguments = Bundle().apply { putString(ARG_SITE_URL, siteUrl) } - } - } } } 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 74f7b8b8b4cd..e01745e9b994 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 @@ -25,16 +25,20 @@ class GutenbergKitFeatureChecker @Inject constructor( val isExperimentalBlockEditorEnabled: Boolean, val isGutenbergKitFeatureEnabled: Boolean, val isDisableExperimentalBlockEditorEnabled: Boolean, - val isEnabledForSite: Boolean = false + val isUserOptedIn: Boolean = false, + val siteOverride: Boolean? = null ) { /** * Determines if GutenbergKit should be enabled based on the feature states. */ val isGutenbergKitEnabled: Boolean - get() = (isExperimentalBlockEditorEnabled || - isGutenbergKitFeatureEnabled || - isEnabledForSite) && - !isDisableExperimentalBlockEditorEnabled + get() { + val perUserOrSite = siteOverride ?: isUserOptedIn + return (isExperimentalBlockEditorEnabled || + isGutenbergKitFeatureEnabled || + perUserOrSite) && + !isDisableExperimentalBlockEditorEnabled + } } /** @@ -48,7 +52,8 @@ class GutenbergKitFeatureChecker @Inject constructor( isDisableExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled( Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR ), - isEnabledForSite = site?.url?.let { appPrefsWrapper.isGutenbergKitEnabledForSite(it) } ?: false + isUserOptedIn = appPrefsWrapper.isGutenbergKitUserOptedIn, + siteOverride = site?.url?.let { appPrefsWrapper.getGutenbergKitSiteOverride(it) } ) } 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 2bc914d6fcc3..aeb472163d21 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 @@ -128,6 +128,7 @@ public enum DeletablePrefKey implements PrefKey { GUTENBERG_OPT_IN_DIALOG_SHOWN, GUTENBERG_FOCAL_POINT_PICKER_TOOLTIP_SHOWN, GUTENBERG_KIT_OPT_IN_SITES, + GUTENBERG_KIT_OPT_OUT_SITES, POST_LIST_AUTHOR_FILTER, POST_LIST_VIEW_LAYOUT_TYPE, @@ -325,6 +326,7 @@ public enum UndeletablePrefKey implements PrefKey { TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, GUTENBERG_KIT_ANNOUNCEMENT_SHOWN, + GUTENBERG_KIT_USER_OPT_IN, } static SharedPreferences prefs() { @@ -834,41 +836,79 @@ public static boolean isGutenbergInfoPopupDisplayed(String siteURL) { return urls != null && urls.contains(siteURL); } - public static boolean isGutenbergKitEnabledForSite(String siteURL) { + /** + * Returns the explicit per-site override for GutenbergKit, or {@code null} if the user has + * not set one (in which case the global opt-in flag should be used). + */ + @Nullable + public static Boolean getGutenbergKitSiteOverride(String siteURL) { if (TextUtils.isEmpty(siteURL)) { - return false; + return null; } - Set urls; + 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; + } + + 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); + } + + private static boolean siteSetContains(DeletablePrefKey key, String siteURL) { try { - urls = prefs().getStringSet(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES.name(), null); + Set urls = prefs().getStringSet(key.name(), null); + return urls != null && urls.contains(siteURL); } catch (ClassCastException exp) { return false; } - return urls != null && urls.contains(siteURL); } - public static void setGutenbergKitEnabledForSite(String siteURL, boolean enabled) { - if (TextUtils.isEmpty(siteURL)) { + 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(); + } + + private static void removeFromSiteSet(DeletablePrefKey key, String siteURL) { Set urls; try { - urls = prefs().getStringSet(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES.name(), null); + 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(); + } - Set newUrls = new HashSet<>(); - if (urls != null) { - newUrls.addAll(urls); - } - if (enabled) { - newUrls.add(siteURL); - } else { - newUrls.remove(siteURL); - } + public static boolean isGutenbergKitUserOptedIn() { + return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), false); + } - prefs().edit().putStringSet(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES.name(), newUrls).apply(); + public static void setGutenbergKitUserOptedIn(boolean optedIn) { + prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), optedIn).apply(); } public static boolean wasGutenbergKitAnnouncementShown() { 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 8926d7eaf641..8b6aa06f3ab2 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,11 +575,15 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setStatsNewStatsSuggestionLastDismissedAt(timestamp: Long) = AppPrefs.setStatsNewStatsSuggestionLastDismissedAt(timestamp) - fun isGutenbergKitEnabledForSite(siteUrl: String?): Boolean = - AppPrefs.isGutenbergKitEnabledForSite(siteUrl) + fun getGutenbergKitSiteOverride(siteUrl: String?): Boolean? = + AppPrefs.getGutenbergKitSiteOverride(siteUrl) - fun setGutenbergKitEnabledForSite(siteUrl: String?, enabled: Boolean) = - AppPrefs.setGutenbergKitEnabledForSite(siteUrl, enabled) + fun setGutenbergKitSiteOverride(siteUrl: String?, enabled: Boolean) = + AppPrefs.setGutenbergKitSiteOverride(siteUrl, enabled) + + var isGutenbergKitUserOptedIn: Boolean + get() = AppPrefs.isGutenbergKitUserOptedIn() + set(value) = AppPrefs.setGutenbergKitUserOptedIn(value) var wasGutenbergKitAnnouncementShown: Boolean get() = AppPrefs.wasGutenbergKitAnnouncementShown() 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 d0a9ab9bfbcd..e6f2f8c7777c 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 @@ -854,7 +854,7 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { } else if (preference == mUseThirdPartyBlocksPref) { mSiteSettings.setUseThirdPartyBlocks((Boolean) newValue); } else if (preference == mGutenbergKitPref) { - AppPrefs.setGutenbergKitEnabledForSite(mSite.getUrl(), (Boolean) newValue); + AppPrefs.setGutenbergKitSiteOverride(mSite.getUrl(), (Boolean) newValue); } else if (preference == mBloggingPromptsPref) { final boolean isEnabled = (boolean) newValue; mPromptsSettingsHelper.updatePromptsCardEnabledBlocking(mSite.getId(), isEnabled); @@ -1049,7 +1049,7 @@ public void initPreferences() { mGutenbergKitPref = (WPSwitchPreference) getChangePref(R.string.pref_key_gutenberg_kit_enabled); - mGutenbergKitPref.setChecked(AppPrefs.isGutenbergKitEnabledForSite(mSite.getUrl())); + mGutenbergKitPref.setChecked(resolveGutenbergKitEnabledForSite()); mSiteAcceleratorSettings = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings); mSiteAcceleratorSettingsNested = @@ -1176,6 +1176,11 @@ public void initPreferences() { initTaxonomies(); } + private boolean resolveGutenbergKitEnabledForSite() { + Boolean override = AppPrefs.getGutenbergKitSiteOverride(mSite.getUrl()); + return override != null ? override : AppPrefs.isGutenbergKitUserOptedIn(); + } + private void initTaxonomies() { mTaxonomiesNavMenuViewModel = new ViewModelProvider(getAppCompatActivity(), mViewModelFactory) .get(TaxonomiesNavMenuViewModel.class); @@ -1600,7 +1605,7 @@ public void setPreferencesFromSiteSettings() { mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); if (mGutenbergKitPref != null) { - mGutenbergKitPref.setChecked(AppPrefs.isGutenbergKitEnabledForSite(mSite.getUrl())); + mGutenbergKitPref.setChecked(resolveGutenbergKitEnabledForSite()); } setAdFreeHostingChecked(mSiteSettings.isAdFreeHostingEnabled()); boolean checked = mSiteSettings.isImprovedSearchEnabled() || mSiteSettings.getJetpackSearchEnabled(); diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt index eed5d71eb7cc..8c45466ecc1e 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt @@ -82,8 +82,8 @@ class WPMainActivityViewModel @Inject constructor( private val _onFeatureAnnouncementRequested = SingleLiveEvent() val onFeatureAnnouncementRequested: LiveData = _onFeatureAnnouncementRequested - private val _onGutenbergKitAnnouncementRequested = SingleLiveEvent() - val onGutenbergKitAnnouncementRequested: LiveData = _onGutenbergKitAnnouncementRequested + private val _onGutenbergKitAnnouncementRequested = SingleLiveEvent() + val onGutenbergKitAnnouncementRequested: LiveData = _onGutenbergKitAnnouncementRequested private val _createPostWithBloggingPrompt = SingleLiveEvent() val createPostWithBloggingPrompt: LiveData = _createPostWithBloggingPrompt @@ -269,7 +269,7 @@ class WPMainActivityViewModel @Inject constructor( if (!SiteUtils.isBlockEditorDefaultForNewPost(site)) return appPrefsWrapper.wasGutenbergKitAnnouncementShown = true - _onGutenbergKitAnnouncementRequested.postValue(site.url) + _onGutenbergKitAnnouncementRequested.postValue(Unit) } private fun checkAndShowFeatureAnnouncement() { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index c1e3cbef646b..2317e3ceb4f6 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -711,7 +711,7 @@ A better block editor is coming - The next generation block editor is coming – and it brings all of the long-missing WordPress Core blocks. You can try it now on this site and switch back anytime from Site Settings.\n\nStarting in May, it will become the default editor for everyone. + The next-generation block editor is ready, and it brings more new features.\n\nStarting in May 2026, it will become the default editor for everyone. Try it now Maybe later Learn more 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 b0fb55eddf36..8aefe73e5a86 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 @@ -244,7 +244,47 @@ class GutenbergKitFeatureCheckerTest { disableExperimentalBlockEditor = false ) val site = SiteModel().apply { url = "https://example.com" } - whenever(appPrefsWrapper.isGutenbergKitEnabledForSite("https://example.com")).thenReturn(true) + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isTrue() + } + + @Test + fun `isGutenbergKitEnabled returns true when only global opt-in is set`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + whenever(appPrefsWrapper.isGutenbergKitUserOptedIn).thenReturn(true) + + assertThat(featureChecker.isGutenbergKitEnabled()).isTrue() + } + + @Test + fun `per-site opt-out overrides global opt-in`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.isGutenbergKitUserOptedIn).thenReturn(true) + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(false) + + assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() + } + + @Test + fun `per-site opt-in overrides global opt-out`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = false, + disableExperimentalBlockEditor = false + ) + val site = SiteModel().apply { url = "https://example.com" } + whenever(appPrefsWrapper.isGutenbergKitUserOptedIn).thenReturn(false) + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) assertThat(featureChecker.isGutenbergKitEnabled(site)).isTrue() } @@ -257,7 +297,7 @@ class GutenbergKitFeatureCheckerTest { disableExperimentalBlockEditor = true ) val site = SiteModel().apply { url = "https://example.com" } - whenever(appPrefsWrapper.isGutenbergKitEnabledForSite("https://example.com")).thenReturn(true) + whenever(appPrefsWrapper.getGutenbergKitSiteOverride("https://example.com")).thenReturn(true) assertThat(featureChecker.isGutenbergKitEnabled(site)).isFalse() } From 1ff1259002b881723d17d3e36227565d25ae0c92 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 20 May 2026 22:06:20 -0600 Subject: [PATCH 3/5] Adapt to trunk APIs after rebase SiteUtils.isBlockEditorDefaultForNewPost is now @Deprecated in favor of SiteSettingsProvider.isBlockEditorDefault; inject the provider into the view model and route the announcement gate through it. Restore the GutenbergKitFeatureChecker field on SiteSettingsFragment that was dropped during conflict resolution against trunk's new EditorCapabilityResolver block. --- .../org/wordpress/android/ui/prefs/SiteSettingsFragment.java | 2 ++ .../android/viewmodel/main/WPMainActivityViewModel.kt | 5 +++-- .../android/viewmodel/main/WPMainActivityViewModelTest.kt | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) 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 e6f2f8c7777c..99a2eef36e4b 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,7 @@ 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.GutenbergKitFeatureChecker; 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 +196,7 @@ public class SiteSettingsFragment extends PreferenceFragment @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject BloggingPromptsSettingsHelper mPromptsSettingsHelper; @Inject EditorCapabilityResolver mEditorCapabilityResolver; + @Inject GutenbergKitFeatureChecker mGutenbergKitFeatureChecker; private BloggingRemindersViewModel mBloggingRemindersViewModel; diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt index 8c45466ecc1e..ee1ef096c61c 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.firstOrNull import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.SiteStore @@ -36,7 +37,6 @@ import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.ui.whatsnew.FeatureAnnouncementProvider import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.FluxCUtils -import org.wordpress.android.util.SiteUtils import org.wordpress.android.util.SiteUtils.hasFullAccessToContent import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import org.wordpress.android.viewmodel.Event @@ -61,6 +61,7 @@ class WPMainActivityViewModel @Inject constructor( private val mainCreateSheetHelper: MainCreateSheetHelper, private val mainCreateSheetTracker: MainCreateSheetTracker, private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker, + private val siteSettingsProvider: SiteSettingsProvider, @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, ) : ScopedViewModel(mainDispatcher) { private var isStarted = false @@ -266,7 +267,7 @@ class WPMainActivityViewModel @Inject constructor( if (!gutenbergKitFeatureChecker.isGutenbergKitRemoteFeatureEnabled()) return if (appPrefsWrapper.wasGutenbergKitAnnouncementShown) return if (site == null) return - if (!SiteUtils.isBlockEditorDefaultForNewPost(site)) return + if (!siteSettingsProvider.isBlockEditorDefault(site)) return appPrefsWrapper.wasGutenbergKitAnnouncementShown = true _onGutenbergKitAnnouncementRequested.postValue(Unit) diff --git a/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt index 0423ebfbc2da..ed5d953ccb28 100644 --- a/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModelTest.kt @@ -101,6 +101,9 @@ class WPMainActivityViewModelTest : BaseUnitTest() { @Mock private lateinit var gutenbergKitFeatureChecker: org.wordpress.android.ui.posts.GutenbergKitFeatureChecker + @Mock + private lateinit var siteSettingsProvider: org.wordpress.android.datasets.SiteSettingsProvider + private val featureAnnouncement = FeatureAnnouncement( "14.7", 2, @@ -154,6 +157,7 @@ class WPMainActivityViewModelTest : BaseUnitTest() { mainCreateSheetHelper, mainCreateSheetTracker, gutenbergKitFeatureChecker, + siteSettingsProvider, NoDelayCoroutineDispatcher(), ) viewModel.onFeatureAnnouncementRequested.observeForever( From c81f1dc56ca843a806fa293f1730417f058265cc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 21 May 2026 08:15:42 -0600 Subject: [PATCH 4/5] Document new GutenbergKit AppPrefs entries Add javadocs to the per-site override read/write pair, the underlying StringSet helpers, and the global opt-in + announcement-shown getters and setters so callers can pick the right primitive without re-reading the implementation. --- .../wordpress/android/ui/prefs/AppPrefs.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 aeb472163d21..130d853a6c6a 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 @@ -854,6 +854,11 @@ public static Boolean getGutenbergKitSiteOverride(String siteURL) { 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; @@ -868,6 +873,10 @@ public static void setGutenbergKitSiteOverride(String siteURL, boolean enabled) 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); @@ -877,6 +886,10 @@ private static boolean siteSetContains(DeletablePrefKey key, String siteURL) { } } + /** + * 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 { @@ -890,6 +903,10 @@ private static void addToSiteSet(DeletablePrefKey key, String 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 { @@ -903,18 +920,32 @@ private static void removeFromSiteSet(DeletablePrefKey key, String siteURL) { prefs().edit().putStringSet(key.name(), newUrls).apply(); } + /** + * Returns {@code true} if the user has opted into GutenbergKit app-wide via the announcement + * sheet. The opt-in is the baseline that per-site overrides modify. + */ public static boolean isGutenbergKitUserOptedIn() { return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), false); } + /** + * Sets the app-wide GutenbergKit opt-in. Persists across logout (undeletable pref). + */ public static void setGutenbergKitUserOptedIn(boolean optedIn) { prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), optedIn).apply(); } + /** + * Returns {@code true} if the GutenbergKit announcement bottom sheet has been presented to + * this user. Used to ensure the announcement is shown at most once. + */ public static boolean wasGutenbergKitAnnouncementShown() { return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_SHOWN.name(), false); } + /** + * Records whether the GutenbergKit announcement bottom sheet has been shown. + */ public static void setGutenbergKitAnnouncementShown(boolean shown) { prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_SHOWN.name(), shown).apply(); } From f7297c3831355f5f361e543d89788c199bf57fe4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 21 May 2026 08:15:50 -0600 Subject: [PATCH 5/5] Pass site through GutenbergKit gate in EditorCapabilityResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Theme Styles and Third-Party Blocks gates short-circuited on isGutenbergKitEnabled() with no site argument, so the resolver only saw the global opt-in and ignored per-site overrides. The launcher, which uses the site-aware overload, could open GBKit for a site opted in locally while the resolver still reported Hidden — the editor would then run without those capabilities applied. Pass site through to the checker so both gates use the same view of "is GBKit on for this site" that the launcher does. --- .../ui/posts/EditorCapabilityResolver.kt | 4 +-- .../ui/posts/EditorCapabilityResolverTest.kt | 30 ++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) 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/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,