diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index ec7fb24f5e68..88c8fd73ef9b 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,7 @@ 26.8 ----- * [**] Resolved an issue where the editor could become impossible to exit when it failed to load. +* [*] 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..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..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..2852abf9687d 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 siteOverride: Boolean? = null ) { /** - * Determines if GutenbergKit should be enabled based on the feature states. + * Determines if GutenbergKit should be enabled. + * + * Resolution: a per-site override, when present, wins over everything except the + * `DISABLE_EXPERIMENTAL_BLOCK_EDITOR` kill switch. When absent, falls back to the + * experimental flag and the remote feature flag. */ val isGutenbergKitEnabled: Boolean - get() = (isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled) && - !isDisableExperimentalBlockEditorEnabled + get() { + val baseEnabled = isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled + val resolved = siteOverride ?: baseEnabled + 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..926d50e8db64 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,71 @@ class GutenbergKitFeatureCheckerTest { } } + @Test + 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 `per-site opt-out wins over remote feature flag`() { + setupFeatureFlags( + experimentalBlockEditor = false, + gutenbergKitEnabled = true, + 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-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 `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(