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..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 @@ -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, unused -> { + new GutenbergKitAnnouncementBottomSheetFragment() + .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..b5f970291675 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitAnnouncementBottomSheetFragment.kt @@ -0,0 +1,84 @@ +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. + * Sets the app-wide opt-in flag, or dismisses. Provides a "Learn more" link + * that opens a web page. + */ +class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() { + 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 + } + + bindBodyWithLearnMore(view.findViewById(R.id.body_text)) + + view.findViewById(R.id.try_now_button).setOnClickListener { + AppPrefs.setGutenbergKitUserOptedIn(true) + 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" + } +} 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..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 @@ -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,49 @@ class GutenbergKitFeatureChecker @Inject constructor( data class FeatureState( val isExperimentalBlockEditorEnabled: Boolean, val isGutenbergKitFeatureEnabled: Boolean, - val isDisableExperimentalBlockEditorEnabled: Boolean + val isDisableExperimentalBlockEditorEnabled: Boolean, + 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) && - !isDisableExperimentalBlockEditorEnabled + get() { + val perUserOrSite = siteOverride ?: isUserOptedIn + return (isExperimentalBlockEditorEnabled || + isGutenbergKitFeatureEnabled || + perUserOrSite) && + !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 - ) + ), + isUserOptedIn = appPrefsWrapper.isGutenbergKitUserOptedIn, + 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..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 @@ -127,6 +127,8 @@ 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, POST_LIST_AUTHOR_FILTER, POST_LIST_VIEW_LAYOUT_TYPE, @@ -322,6 +324,9 @@ 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, + GUTENBERG_KIT_USER_OPT_IN, } static SharedPreferences prefs() { @@ -831,6 +836,89 @@ 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 (in which case the global opt-in flag should be used). + */ + @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; + } + + 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 { + Set urls = prefs().getStringSet(key.name(), null); + return urls != null && urls.contains(siteURL); + } catch (ClassCastException exp) { + return false; + } + } + + 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(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(); + } + + public static boolean isGutenbergKitUserOptedIn() { + return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), false); + } + + public static void setGutenbergKitUserOptedIn(boolean optedIn) { + prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), optedIn).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..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,6 +575,20 @@ 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) + + var isGutenbergKitUserOptedIn: Boolean + get() = AppPrefs.isGutenbergKitUserOptedIn() + set(value) = AppPrefs.setGutenbergKitUserOptedIn(value) + + 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..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; @@ -230,6 +232,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 +855,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.setGutenbergKitSiteOverride(mSite.getUrl(), (Boolean) newValue); } else if (preference == mBloggingPromptsPref) { final boolean isEnabled = (boolean) newValue; mPromptsSettingsHelper.updatePromptsCardEnabledBlocking(mSite.getId(), isEnabled); @@ -1044,6 +1049,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(resolveGutenbergKitEnabledForSite()); + mSiteAcceleratorSettings = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings); mSiteAcceleratorSettingsNested = (PreferenceScreen) getClickPref(R.string.pref_key_site_accelerator_settings_nested); @@ -1115,6 +1124,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())) { @@ -1163,6 +1178,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); @@ -1240,7 +1260,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 +1606,9 @@ public void setPreferencesFromSiteSettings() { mGutenbergDefaultForNewPosts.setChecked(SiteUtils.isBlockEditorDefaultForNewPost(mSite)); mUseThemeStylesPref.setChecked(mSiteSettings.getUseThemeStyles()); mUseThirdPartyBlocksPref.setChecked(mSiteSettings.getUseThirdPartyBlocks()); + if (mGutenbergKitPref != null) { + mGutenbergKitPref.setChecked(resolveGutenbergKitEnabledForSite()); + } 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..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 @@ -29,6 +30,7 @@ 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 @@ -58,6 +60,8 @@ class WPMainActivityViewModel @Inject constructor( private val shouldAskPrivacyConsent: ShouldAskPrivacyConsent, 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 @@ -79,6 +83,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 +260,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 (!siteSettingsProvider.isBlockEditorDefault(site)) return + + appPrefsWrapper.wasGutenbergKitAnnouncementShown = true + _onGutenbergKitAnnouncementRequested.postValue(Unit) } 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..2317e3ceb4f6 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 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 + 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..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 @@ -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,72 @@ 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.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() + } + + @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 `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..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 @@ -98,6 +98,12 @@ class WPMainActivityViewModelTest : BaseUnitTest() { @Mock private lateinit var mainCreateSheetTracker: MainCreateSheetTracker + @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, @@ -150,6 +156,8 @@ class WPMainActivityViewModelTest : BaseUnitTest() { shouldAskPrivacyConsent, mainCreateSheetHelper, mainCreateSheetTracker, + gutenbergKitFeatureChecker, + siteSettingsProvider, NoDelayCoroutineDispatcher(), ) viewModel.onFeatureAnnouncementRequested.observeForever(