From eb0768acebd00543c887e91ccc839411cc075d2b Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 4 Aug 2026 15:04:40 -0400 Subject: [PATCH 1/5] Let every user disable New Stats The android_new_stats remote flag did two jobs: it routed users to New Stats, and it hid the "Disable new stats" overflow item whenever it was on. Users on the rollout had no way back. Add an explicit NEW_STATS_USER_OPTED_OUT preference that beats the flag, so routing becomes !optedOut && (flagEnabled || optedIn), and always show the overflow item. The rollout default is unchanged; only the escape hatch is new. Extract the routing decision into NewStatsRouting, which the My Site menu and the Stats widgets now share instead of duplicating the condition. Opting back in clears the opt-out, and the "try new stats" suggestion no longer nags users who deliberately left. Also reuse the experimental-features feedback dialog when New Stats is disabled. FeedbackDialog moves to ui/compose/components with a message parameter, and navigation is deferred until the dialog is answered since finishing the activity would otherwise tear it down. --- RELEASE-NOTES.txt | 1 + .../ui/compose/components/FeedbackDialog.kt | 39 +++++++++++ .../ui/mysite/cards/ListItemActionHandler.kt | 9 +-- .../android/ui/newstats/NewStatsActivity.kt | 66 ++++++++++++------ .../android/ui/newstats/NewStatsRouting.kt | 38 +++++++++++ .../wordpress/android/ui/prefs/AppPrefs.java | 10 +++ .../android/ui/prefs/AppPrefsWrapper.kt | 5 ++ .../ExperimentalFeaturesActivity.kt | 8 ++- .../ExperimentalFeaturesScreen.kt | 21 ------ .../android/ui/stats/refresh/StatsFragment.kt | 8 ++- .../refresh/lists/widget/utils/WidgetUtils.kt | 11 +-- WordPress/src/main/res/values/strings.xml | 1 + .../mysite/cards/ListItemActionHandlerTest.kt | 43 ++---------- .../ui/newstats/NewStatsRoutingTest.kt | 67 +++++++++++++++++++ 14 files changed, 233 insertions(+), 94 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/compose/components/FeedbackDialog.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index a5e9d347a47f..6099106a3beb 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -6,6 +6,7 @@ * [**] Fixed media uploads and the Media Library failing on Jetpack-connected self-hosted sites that have Application Passwords disabled. * [*] Stats now refresh when you return to the screen, so your latest data appears without a manual pull-to-refresh. [https://github.com/wordpress-mobile/WordPress-Android/pull/23112] * [*] Links from emails that open outside the app now show a clear message when no browser is available, instead of closing the app. +* [*] You can now switch back to the previous Stats screen at any time from the Stats overflow menu, and your choice is remembered. 26.9 ----- diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/FeedbackDialog.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/FeedbackDialog.kt new file mode 100644 index 000000000000..19b9c9994450 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/FeedbackDialog.kt @@ -0,0 +1,39 @@ +package org.wordpress.android.ui.compose.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.wordpress.android.R + +/** + * Asks the user whether they'd like to share feedback after turning a feature off. + * + * @param message Feature-specific prompt, e.g. "Are you willing to share feedback on the new stats?" + * @param onDismiss Called when the user declines or dismisses the dialog + * @param onSendFeedback Called when the user opts to send feedback + */ +@Composable +fun FeedbackDialog( + message: String, + onDismiss: () -> Unit, + onSendFeedback: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(R.string.experimental_features_feedback_dialog_title)) }, + text = { Text(text = message) }, + confirmButton = { + Button(onClick = onSendFeedback) { + Text(text = stringResource(R.string.send_feedback)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.experimental_features_feedback_dialog_decline)) + } + } + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt index f6ea59acb767..b2dd5b883503 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt @@ -8,16 +8,14 @@ import org.wordpress.android.ui.blaze.blazecampaigns.campaignlisting.CampaignLis import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper import org.wordpress.android.ui.mysite.SiteNavigationAction import org.wordpress.android.ui.mysite.items.listitem.ListItemAction -import org.wordpress.android.ui.prefs.AppPrefsWrapper -import org.wordpress.android.util.config.NewStatsFeatureConfig +import org.wordpress.android.ui.newstats.NewStatsRouting import javax.inject.Inject class ListItemActionHandler @Inject constructor( private val accountStore: AccountStore, private val jetpackFeatureRemovalPhaseHelper: JetpackFeatureRemovalPhaseHelper, private val blazeFeatureUtils: BlazeFeatureUtils, - private val newStatsFeatureConfig: NewStatsFeatureConfig, - private val appPrefsWrapper: AppPrefsWrapper + private val newStatsRouting: NewStatsRouting ) { fun handleAction( action: ListItemAction, @@ -61,8 +59,7 @@ class ListItemActionHandler @Inject constructor( // If it's a WordPress.com or Jetpack site, show the Stats screen. site.isWPCom || site.isJetpackInstalled && site.isJetpackConnected -> { - // Show New Stats when the remote flag is on (rollout) or the user has opted in locally. - if (newStatsFeatureConfig.isEnabled() || appPrefsWrapper.getNewStatsUserOptedIn()) { + if (newStatsRouting.isNewStatsEnabled()) { SiteNavigationAction.OpenNewStats } else { SiteNavigationAction.OpenStats(site) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 28897043ab6c..bba8e9ff0409 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,6 +68,7 @@ import org.wordpress.android.WordPress import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.ActivityNavigator +import org.wordpress.android.ui.compose.components.FeedbackDialog import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.components.AddCardBottomSheet @@ -124,7 +126,6 @@ import org.wordpress.android.ui.stats.refresh.utils.StatsLaunchedFrom import org.wordpress.android.analytics.AnalyticsTracker.Stat import org.wordpress.android.util.AppLog import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper -import org.wordpress.android.util.config.NewStatsFeatureConfig import javax.inject.Inject @AndroidEntryPoint @@ -142,7 +143,7 @@ class NewStatsActivity : BaseAppCompatActivity() { lateinit var analyticsTracker: AnalyticsTrackerWrapper @Inject - lateinit var newStatsFeatureConfig: NewStatsFeatureConfig + lateinit var newStatsRouting: NewStatsRouting @Inject lateinit var activityNavigator: ActivityNavigator @@ -154,14 +155,31 @@ class NewStatsActivity : BaseAppCompatActivity() { selectSiteFromIntentIfNeeded() val shouldShowIntro = !appPrefsWrapper.getNewStatsIntroShown() - val canSwitchToOldStats = !newStatsFeatureConfig.isEnabled() setContent { AppThemeM3 { + var showFeedbackDialog by rememberSaveable { mutableStateOf(false) } + + if (showFeedbackDialog) { + FeedbackDialog( + message = stringResource(R.string.stats_new_stats_feedback_dialog_message), + onDismiss = { + showFeedbackDialog = false + leaveNewStats(sendFeedback = false) + }, + onSendFeedback = { + showFeedbackDialog = false + leaveNewStats(sendFeedback = true) + } + ) + } + NewStatsScreen( onBackPressed = onBackPressedDispatcher::onBackPressed, - showSwitchToOldStats = canSwitchToOldStats, - onSwitchToOldStats = ::switchToOldStats, + onSwitchToOldStats = { + switchToOldStats() + showFeedbackDialog = true + }, showIntroBottomSheet = shouldShowIntro, onIntroDismissed = { appPrefsWrapper @@ -184,21 +202,32 @@ class NewStatsActivity : BaseAppCompatActivity() { } } + /** + * Records the opt-out. Navigation is deferred until the feedback dialog is answered, since + * finishing this activity straight away would tear the dialog down with it. + */ private fun switchToOldStats() { analyticsTracker.track(Stat.STATS_NEW_STATS_DISABLED) - appPrefsWrapper.setNewStatsUserOptedIn(false) - appPrefsWrapper.setNewStatsIntroShown(false) - selectedSiteRepository.getSelectedSite()?.let { site -> - StatsActivity.start( - this, - site, - launchedFrom = StatsLaunchedFrom.STATS_TOGGLE - ) - finish() + newStatsRouting.optOut() + } + + private fun leaveNewStats(sendFeedback: Boolean) { + val site = selectedSiteRepository.getSelectedSite() ?: return + StatsActivity.start( + this, + site, + launchedFrom = StatsLaunchedFrom.STATS_TOGGLE + ) + // Started after old Stats so the form sits on top of it and backs out to it. + if (sendFeedback) { + ActivityLauncher.viewFeedbackForm(this, FEEDBACK_PREFIX_STATS) } + finish() } companion object { + private const val FEEDBACK_PREFIX_STATS = "Stats" + fun start(context: Context) { context.startActivity(Intent(context, NewStatsActivity::class.java)) } @@ -250,7 +279,6 @@ private fun StatsOverflowMenu( @Composable private fun NewStatsScreen( onBackPressed: () -> Unit, - showSwitchToOldStats: Boolean = false, onSwitchToOldStats: () -> Unit = {}, showIntroBottomSheet: Boolean = false, onIntroDismissed: () -> Unit = {}, @@ -354,11 +382,9 @@ private fun NewStatsScreen( ) } } - if (showSwitchToOldStats) { - StatsOverflowMenu( - onSwitchToOldStats = onSwitchToOldStats - ) - } + StatsOverflowMenu( + onSwitchToOldStats = onSwitchToOldStats + ) } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt new file mode 100644 index 000000000000..600eb1df813a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.ui.newstats + +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.config.NewStatsFeatureConfig +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Single source of truth for whether New Stats is shown, shared by the My Site menu and the + * Stats widgets so the two can't drift apart. + */ +@Singleton +class NewStatsRouting @Inject constructor( + private val newStatsFeatureConfig: NewStatsFeatureConfig, + private val appPrefsWrapper: AppPrefsWrapper +) { + /** + * New Stats is the default when the remote flag is on (rollout) or the user opted in locally, + * but an explicit opt-out always wins so users on the rollout can switch back. + */ + fun isNewStatsEnabled(): Boolean = + !appPrefsWrapper.getNewStatsUserOptedOut() && + (newStatsFeatureConfig.isEnabled() || appPrefsWrapper.getNewStatsUserOptedIn()) + + fun optIn() { + appPrefsWrapper.setNewStatsUserOptedOut(false) + appPrefsWrapper.setNewStatsUserOptedIn(true) + } + + fun optOut() { + appPrefsWrapper.setNewStatsUserOptedOut(true) + appPrefsWrapper.setNewStatsUserOptedIn(false) + // Show the intro again if the user later opts back in. + appPrefsWrapper.setNewStatsIntroShown(false) + } + + fun hasOptedOut(): Boolean = appPrefsWrapper.getNewStatsUserOptedOut() +} 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 a52538f5ce52..547cb2b0acb4 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 @@ -69,6 +69,8 @@ public enum DeletablePrefKey implements PrefKey { NEW_STATS_USER_OPTED_IN, + NEW_STATS_USER_OPTED_OUT, + STATS_NEW_STATS_SUGGESTION_SHOWN, STATS_NEW_STATS_SUGGESTION_LAST_DISMISSED_AT, @@ -2156,6 +2158,14 @@ public static void setNewStatsUserOptedIn(boolean optedIn) { setBoolean(DeletablePrefKey.NEW_STATS_USER_OPTED_IN, optedIn); } + public static boolean getNewStatsUserOptedOut() { + return getBoolean(DeletablePrefKey.NEW_STATS_USER_OPTED_OUT, false); + } + + public static void setNewStatsUserOptedOut(boolean optedOut) { + setBoolean(DeletablePrefKey.NEW_STATS_USER_OPTED_OUT, optedOut); + } + /** * One-time migration for users who enabled New Stats via the old experimental-features toggle, * before it was replaced by the remote flag + local opt-in preference. Without this, those users 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 1292b8dceb6c..8fc18b7bf05e 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 @@ -582,6 +582,11 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra fun setNewStatsUserOptedIn(optedIn: Boolean) = AppPrefs.setNewStatsUserOptedIn(optedIn) + fun getNewStatsUserOptedOut(): Boolean = AppPrefs.getNewStatsUserOptedOut() + + fun setNewStatsUserOptedOut(optedOut: Boolean) = + AppPrefs.setNewStatsUserOptedOut(optedOut) + fun getStatsNewStatsSuggestionShown(): Boolean = AppPrefs.getStatsNewStatsSuggestionShown() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt index 8e1f17cf30d7..4c8467d70e72 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt @@ -4,10 +4,13 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.ui.compose.components.FeedbackDialog import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.util.extensions.setContent @@ -24,7 +27,7 @@ class ExperimentalFeaturesActivity : BaseAppCompatActivity() { val features by viewModel.switchStates.collectAsStateWithLifecycle() val showNetworkDebuggingError by viewModel.showNetworkDebuggingError.collectAsStateWithLifecycle() - val showDialog = remember { mutableStateOf(false) } + val showDialog = rememberSaveable { mutableStateOf(false) } if (showNetworkDebuggingError) { NetworkDebuggingErrorDialog( @@ -34,6 +37,7 @@ class ExperimentalFeaturesActivity : BaseAppCompatActivity() { if (showDialog.value) { FeedbackDialog( + message = stringResource(R.string.experimental_features_feedback_dialog_message), onDismiss = { showDialog.value = false }, onSendFeedback = { showDialog.value = false diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt index 73b242897a7b..b19122acf912 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -127,26 +126,6 @@ fun FeatureToggle( ) } -@Composable -fun FeedbackDialog(onDismiss: () -> Unit, onSendFeedback: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(R.string.experimental_features_feedback_dialog_title)) }, - text = { Text(text = stringResource(R.string.experimental_features_feedback_dialog_message)) }, - confirmButton = { - Button(onClick = onSendFeedback) { - Text(text = stringResource(R.string.send_feedback)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(text = stringResource(R.string.experimental_features_feedback_dialog_decline)) - } - } - ) -} - - @Composable fun NetworkDebuggingErrorDialog( onDismiss: () -> Unit diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsFragment.kt index a5b8a3f81d0b..363ac2021de7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/StatsFragment.kt @@ -35,6 +35,7 @@ import org.wordpress.android.ui.jetpackoverlay.JetpackOverlayConnectedFeature import org.wordpress.android.ui.main.WPMainNavigationView.PageType.MY_SITE import org.wordpress.android.ui.mysite.jetpackbadge.JetpackPoweredBottomSheetFragment import org.wordpress.android.ui.newstats.NewStatsActivity +import org.wordpress.android.ui.newstats.NewStatsRouting import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.stats.refresh.StatsViewModel.StatsModuleUiModel @@ -87,6 +88,9 @@ class StatsFragment : Fragment(R.layout.stats_fragment), ScrollableViewInitializ @Inject lateinit var appPrefsWrapper: AppPrefsWrapper + @Inject + lateinit var newStatsRouting: NewStatsRouting + @Inject lateinit var jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil @@ -155,7 +159,7 @@ class StatsFragment : Fragment(R.layout.stats_fragment), ScrollableViewInitializ private fun switchToNewStats() { if (!isAdded) return analyticsTracker.track(Stat.STATS_NEW_STATS_ENABLED) - appPrefsWrapper.setNewStatsUserOptedIn(true) + newStatsRouting.optIn() NewStatsActivity.start(requireContext()) requireActivity().finish() } @@ -163,6 +167,8 @@ class StatsFragment : Fragment(R.layout.stats_fragment), ScrollableViewInitializ @Suppress("ReturnCount") private fun maybeShowNewStatsSuggestion() { if (appPrefsWrapper.getStatsNewStatsSuggestionShown()) return + // Don't nag users who deliberately switched back to old Stats. + if (newStatsRouting.hasOptedOut()) return // Avoid stacking on top of the Jetpack-powered bottom sheet or the feature-removal overlay, // both of which may show on a fresh Stats activity launch. if (jetpackBrandingUtils.shouldShowJetpackPoweredBottomSheet()) return diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt index 8233a2fb6e7b..bbf0b2178170 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt @@ -21,7 +21,7 @@ import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel.PeriodDa import org.wordpress.android.fluxc.network.utils.StatsGranularity import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.newstats.NewStatsActivity -import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.newstats.NewStatsRouting import org.wordpress.android.ui.stats.StatsTimeframe import org.wordpress.android.ui.stats.refresh.StatsActivity import org.wordpress.android.ui.stats.refresh.lists.widget.IS_WIDE_VIEW_KEY @@ -32,7 +32,6 @@ import org.wordpress.android.ui.stats.refresh.lists.widget.configuration.StatsCo import org.wordpress.android.ui.stats.refresh.lists.widget.configuration.StatsColorSelectionViewModel.Color.LIGHT import org.wordpress.android.ui.stats.refresh.lists.widget.configuration.StatsWidgetConfigureFragment.WidgetType import org.wordpress.android.ui.stats.refresh.utils.StatsLaunchedFrom -import org.wordpress.android.util.config.NewStatsFeatureConfig import org.wordpress.android.util.image.ImageManager import org.wordpress.android.util.image.ImageType.ICON import org.wordpress.android.viewmodel.ResourceProvider @@ -51,8 +50,7 @@ class WidgetUtils @Inject constructor( @Named(UI_THREAD) private val mainDispatcher: CoroutineDispatcher, val imageManager: ImageManager, - private val newStatsFeatureConfig: NewStatsFeatureConfig, - private val appPrefsWrapper: AppPrefsWrapper + private val newStatsRouting: NewStatsRouting ) { private val coroutineScope = CoroutineScope(mainDispatcher) fun isWidgetWiderThanLimit( @@ -235,10 +233,7 @@ class WidgetUtils return Random(Date().time).nextInt() } - // Mirrors the routing used by the My Site menu (ListItemActionHandler): open New Stats when the - // remote flag is on (rollout) or the user has opted in locally. - private fun isNewStatsEnabled(): Boolean = - newStatsFeatureConfig.isEnabled() || appPrefsWrapper.getNewStatsUserOptedIn() + private fun isNewStatsEnabled(): Boolean = newStatsRouting.isNewStatsEnabled() fun getLastWeekPeriodData(visitsAndViewsModel: VisitsAndViewsModel): PeriodData? { val currentDateForSite = WeeklyRoundupUtils.parseStandardDate(visitsAndViewsModel.period) ?: return null diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 72a3d2d66515..7a9dd76b2be3 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1018,6 +1018,7 @@ We\'ve built a brand new Stats experience. Want to give it a try? You can always go back to the old one from the Stats menu. Try it out Maybe later + Are you willing to share feedback on the new Stats experience? New Stats A better way to understand your site\'s performance. diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandlerTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandlerTest.kt index f65bb4adeb8e..c0e924236278 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandlerTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandlerTest.kt @@ -16,8 +16,7 @@ import org.wordpress.android.ui.blaze.blazecampaigns.campaignlisting.CampaignLis import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper import org.wordpress.android.ui.mysite.SiteNavigationAction import org.wordpress.android.ui.mysite.items.listitem.ListItemAction -import org.wordpress.android.ui.prefs.AppPrefsWrapper -import org.wordpress.android.util.config.NewStatsFeatureConfig +import org.wordpress.android.ui.newstats.NewStatsRouting import kotlin.test.assertEquals @ExperimentalCoroutinesApi @@ -33,10 +32,7 @@ class ListItemActionHandlerTest: BaseUnitTest() { lateinit var blazeFeatureUtils: BlazeFeatureUtils @Mock - lateinit var newStatsFeatureConfig: NewStatsFeatureConfig - - @Mock - lateinit var appPrefsWrapper: AppPrefsWrapper + lateinit var newStatsRouting: NewStatsRouting private val site = SiteModel() @@ -48,8 +44,7 @@ class ListItemActionHandlerTest: BaseUnitTest() { accountStore, jetpackFeatureRemovalPhaseHelper, blazeFeatureUtils, - newStatsFeatureConfig, - appPrefsWrapper + newStatsRouting ) } @@ -167,32 +162,9 @@ class ListItemActionHandlerTest: BaseUnitTest() { } @Test - fun `stats item click emits OpenNewStats when NEW_STATS feature is enabled and site is WPCom`() { - site.setIsWPCom(true) - whenever(newStatsFeatureConfig.isEnabled()).thenReturn(true) - - val navigationAction = invokeItemClickAction(action = ListItemAction.STATS) - - assertEquals(SiteNavigationAction.OpenNewStats, navigationAction) - } - - @Test - fun `stats item click emits OpenNewStats when NEW_STATS feature is enabled and site is Jetpack`() { - site.setIsJetpackConnected(true) - site.setIsWPCom(true) - whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(newStatsFeatureConfig.isEnabled()).thenReturn(true) - - val navigationAction = invokeItemClickAction(action = ListItemAction.STATS) - - assertEquals(SiteNavigationAction.OpenNewStats, navigationAction) - } - - @Test - fun `stats item click emits OpenNewStats when user opted in even if NEW_STATS feature is disabled`() { + fun `stats item click emits OpenNewStats when New Stats is enabled`() { site.setIsWPCom(true) - whenever(newStatsFeatureConfig.isEnabled()).thenReturn(false) - whenever(appPrefsWrapper.getNewStatsUserOptedIn()).thenReturn(true) + whenever(newStatsRouting.isNewStatsEnabled()).thenReturn(true) val navigationAction = invokeItemClickAction(action = ListItemAction.STATS) @@ -200,10 +172,9 @@ class ListItemActionHandlerTest: BaseUnitTest() { } @Test - fun `stats item click emits OpenStats when NEW_STATS feature is disabled and user has not opted in`() { + fun `stats item click emits OpenStats when New Stats is disabled`() { site.setIsWPCom(true) - whenever(newStatsFeatureConfig.isEnabled()).thenReturn(false) - whenever(appPrefsWrapper.getNewStatsUserOptedIn()).thenReturn(false) + whenever(newStatsRouting.isNewStatsEnabled()).thenReturn(false) val navigationAction = invokeItemClickAction(action = ListItemAction.STATS) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt new file mode 100644 index 000000000000..569bb7885b78 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt @@ -0,0 +1,67 @@ +package org.wordpress.android.ui.newstats + +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.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.util.config.NewStatsFeatureConfig +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(MockitoJUnitRunner::class) +class NewStatsRoutingTest { + @Mock + lateinit var newStatsFeatureConfig: NewStatsFeatureConfig + + @Mock + lateinit var appPrefsWrapper: AppPrefsWrapper + + private lateinit var newStatsRouting: NewStatsRouting + + @Before + fun setUp() { + newStatsRouting = NewStatsRouting(newStatsFeatureConfig, appPrefsWrapper) + } + + @Test + fun `new stats is enabled when the remote flag is on`() { + whenever(newStatsFeatureConfig.isEnabled()).thenReturn(true) + + assertTrue(newStatsRouting.isNewStatsEnabled()) + } + + @Test + fun `new stats is enabled when the user opted in and the remote flag is off`() { + whenever(newStatsFeatureConfig.isEnabled()).thenReturn(false) + whenever(appPrefsWrapper.getNewStatsUserOptedIn()).thenReturn(true) + + assertTrue(newStatsRouting.isNewStatsEnabled()) + } + + @Test + fun `new stats is disabled when the remote flag is off and the user has not opted in`() { + whenever(newStatsFeatureConfig.isEnabled()).thenReturn(false) + whenever(appPrefsWrapper.getNewStatsUserOptedIn()).thenReturn(false) + + assertFalse(newStatsRouting.isNewStatsEnabled()) + } + + @Test + fun `opting out beats the remote flag`() { + whenever(appPrefsWrapper.getNewStatsUserOptedOut()).thenReturn(true) + + assertFalse(newStatsRouting.isNewStatsEnabled()) + } + + /** Without this, a user who opted out could never get back into New Stats. */ + @Test + fun `opting in clears a previous opt-out`() { + newStatsRouting.optIn() + + verify(appPrefsWrapper).setNewStatsUserOptedOut(false) + } +} From 0167eabc01a97223f0761a3238cd8c8298d5ca21 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 4 Aug 2026 15:10:37 -0400 Subject: [PATCH 2/5] Stop the intro sheet reappearing over the feedback dialog optOut() reset NEW_STATS_INTRO_SHOWN, and NewStatsActivity.onCreate re-reads that flag on every recreation. Rotating after tapping "Disable new stats" left shouldShowIntro true, so the intro bottom sheet mounted on top of the feedback dialog and looked like the dialog had vanished. This was unreachable until navigation was deferred for the dialog, since the activity used to finish on the same frame. Reset the flag in optIn() instead. Same outcome for the user - the intro shows when they return to New Stats - but it is set from StatsFragment while New Stats is off screen, so it can no longer re-arm under a live NewStatsActivity. --- .../android/ui/newstats/NewStatsRouting.kt | 5 +++-- .../android/ui/newstats/NewStatsRoutingTest.kt | 13 ++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt index 600eb1df813a..b36572bf2b7a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt @@ -25,13 +25,14 @@ class NewStatsRouting @Inject constructor( fun optIn() { appPrefsWrapper.setNewStatsUserOptedOut(false) appPrefsWrapper.setNewStatsUserOptedIn(true) + // Reintroduce New Stats on the way in. Resetting this on opt-out instead would re-arm the + // intro sheet while NewStatsActivity is still on screen, so it reappears on recreation. + appPrefsWrapper.setNewStatsIntroShown(false) } fun optOut() { appPrefsWrapper.setNewStatsUserOptedOut(true) appPrefsWrapper.setNewStatsUserOptedIn(false) - // Show the intro again if the user later opts back in. - appPrefsWrapper.setNewStatsIntroShown(false) } fun hasOptedOut(): Boolean = appPrefsWrapper.getNewStatsUserOptedOut() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt index 569bb7885b78..60dca47867ff 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/NewStatsRoutingTest.kt @@ -5,6 +5,8 @@ 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.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.ui.prefs.AppPrefsWrapper @@ -59,9 +61,18 @@ class NewStatsRoutingTest { /** Without this, a user who opted out could never get back into New Stats. */ @Test - fun `opting in clears a previous opt-out`() { + fun `opting in clears a previous opt-out and re-arms the intro`() { newStatsRouting.optIn() verify(appPrefsWrapper).setNewStatsUserOptedOut(false) + verify(appPrefsWrapper).setNewStatsIntroShown(false) + } + + /** Re-arming it here would make the intro sheet reappear if New Stats is recreated. */ + @Test + fun `opting out leaves the intro flag alone`() { + newStatsRouting.optOut() + + verify(appPrefsWrapper, never()).setNewStatsIntroShown(any()) } } From a055dc06ffd08bf75ac0a0f0f300cbd0c0e5d263 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 4 Aug 2026 15:29:16 -0400 Subject: [PATCH 3/5] Keep the opt-out across sign-out, and always leave New Stats Three fixes from review: leaveNewStats() bailed out when there was no selected site, after optOut() had already persisted. The dialog closed, no navigation happened and finish() never ran, leaving the user on a screen nothing else routes them to. Only the old Stats launch needs a site, so guard just that and always finish. NEW_STATS_USER_OPTED_OUT was a DeletablePrefKey, which AppPrefs.reset() wipes on sign-out. Since it is the only thing that can override the android_new_stats rollout flag, signing out silently pushed the user back into New Stats. Move it to UndeletablePrefKey, alongside the other "user said no" flags. NewStatsRouting's docstring claimed to be the single source of truth, but shortcuts, deep links, notifications and the activity log all route straight to old Stats. Say so instead. --- .../android/ui/newstats/NewStatsActivity.kt | 16 ++++++++++------ .../android/ui/newstats/NewStatsRouting.kt | 8 ++++++-- .../org/wordpress/android/ui/prefs/AppPrefs.java | 11 +++++++---- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index bba8e9ff0409..820339904cfb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -212,12 +212,16 @@ class NewStatsActivity : BaseAppCompatActivity() { } private fun leaveNewStats(sendFeedback: Boolean) { - val site = selectedSiteRepository.getSelectedSite() ?: return - StatsActivity.start( - this, - site, - launchedFrom = StatsLaunchedFrom.STATS_TOGGLE - ) + // The opt-out is already persisted, so always finish - bailing out here would strand the + // user on a screen the rest of the app no longer routes them to. Old Stats needs a site, + // but without one we can still leave and fall back to whatever is underneath. + selectedSiteRepository.getSelectedSite()?.let { site -> + StatsActivity.start( + this, + site, + launchedFrom = StatsLaunchedFrom.STATS_TOGGLE + ) + } // Started after old Stats so the form sits on top of it and backs out to it. if (sendFeedback) { ActivityLauncher.viewFeedbackForm(this, FEEDBACK_PREFIX_STATS) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt index b36572bf2b7a..3a8af1e2d261 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsRouting.kt @@ -6,8 +6,12 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Single source of truth for whether New Stats is shown, shared by the My Site menu and the - * Stats widgets so the two can't drift apart. + * Decides whether New Stats is shown, shared by the My Site menu and the Stats widgets so the two + * can't drift apart. + * + * Note this does not yet govern every route into Stats: shortcuts, deep links, notifications and + * the activity log go straight to the old [org.wordpress.android.ui.stats.refresh.StatsActivity] + * via ActivityLauncher.viewBlogStats, regardless of this decision. */ @Singleton class NewStatsRouting @Inject constructor( 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 547cb2b0acb4..b0dab86f4d62 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 @@ -69,8 +69,6 @@ public enum DeletablePrefKey implements PrefKey { NEW_STATS_USER_OPTED_IN, - NEW_STATS_USER_OPTED_OUT, - STATS_NEW_STATS_SUGGESTION_SHOWN, STATS_NEW_STATS_SUGGESTION_LAST_DISMISSED_AT, @@ -248,6 +246,11 @@ public enum UndeletablePrefKey implements PrefKey { BOOKMARKS_SAVED_LOCALLY_DIALOG_SHOWN, + // Set when the user explicitly turns New Stats off. Undeletable so the choice survives + // sign-out - it is the only thing that can override the android_new_stats rollout flag, + // so erasing it would silently push the user back into New Stats. + NEW_STATS_USER_OPTED_OUT, + // When we need to show the snackbar indicating how notifications can be navigated through SWIPE_TO_NAVIGATE_NOTIFICATIONS, @@ -2159,11 +2162,11 @@ public static void setNewStatsUserOptedIn(boolean optedIn) { } public static boolean getNewStatsUserOptedOut() { - return getBoolean(DeletablePrefKey.NEW_STATS_USER_OPTED_OUT, false); + return getBoolean(UndeletablePrefKey.NEW_STATS_USER_OPTED_OUT, false); } public static void setNewStatsUserOptedOut(boolean optedOut) { - setBoolean(DeletablePrefKey.NEW_STATS_USER_OPTED_OUT, optedOut); + setBoolean(UndeletablePrefKey.NEW_STATS_USER_OPTED_OUT, optedOut); } /** From 1875e9b71120c8acfb7670bd5632484ae4ea4e2f Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 4 Aug 2026 15:32:15 -0400 Subject: [PATCH 4/5] Drop the WidgetUtils routing delegate isNewStatsEnabled() only forwarded to NewStatsRouting. Both call sites now use the injected router directly, removing the indirection the router was added to get rid of. --- .../ui/stats/refresh/lists/widget/utils/WidgetUtils.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt index bbf0b2178170..1905e72d7ed3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stats/refresh/lists/widget/utils/WidgetUtils.kt @@ -179,7 +179,7 @@ class WidgetUtils statsTimeframe: StatsTimeframe, granularity: StatsGranularity? = null ): PendingIntent { - val intent = if (isNewStatsEnabled()) { + val intent = if (newStatsRouting.isNewStatsEnabled()) { Intent(context, NewStatsActivity::class.java).apply { putExtra(WordPress.LOCAL_SITE_ID, localSiteId) // Mirror the row-tap (template) path: CLEAR_TOP forces an already-running @@ -208,7 +208,7 @@ class WidgetUtils private fun getPendingTemplate(context: Context): PendingIntent { // The per-row fill-in intents already carry the LOCAL_SITE_ID extra, which is merged into // whichever component this template targets, so New Stats receives the tapped row's site. - val targetActivity = if (isNewStatsEnabled()) { + val targetActivity = if (newStatsRouting.isNewStatsEnabled()) { NewStatsActivity::class.java } else { StatsActivity::class.java @@ -233,8 +233,6 @@ class WidgetUtils return Random(Date().time).nextInt() } - private fun isNewStatsEnabled(): Boolean = newStatsRouting.isNewStatsEnabled() - fun getLastWeekPeriodData(visitsAndViewsModel: VisitsAndViewsModel): PeriodData? { val currentDateForSite = WeeklyRoundupUtils.parseStandardDate(visitsAndViewsModel.period) ?: return null val lastWeekStartDate = currentDateForSite.minusWeeks(1).with(TemporalAdjusters.previousOrSame(MONDAY)) From b41f9d63a2d37feefe2e8110367603d344c5860f Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 5 Aug 2026 14:07:25 -0400 Subject: [PATCH 5/5] Update RELEASE-NOTES.txt Co-authored-by: David Calhoun --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 6099106a3beb..02253ea329d2 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -6,7 +6,7 @@ * [**] Fixed media uploads and the Media Library failing on Jetpack-connected self-hosted sites that have Application Passwords disabled. * [*] Stats now refresh when you return to the screen, so your latest data appears without a manual pull-to-refresh. [https://github.com/wordpress-mobile/WordPress-Android/pull/23112] * [*] Links from emails that open outside the app now show a clear message when no browser is available, instead of closing the app. -* [*] You can now switch back to the previous Stats screen at any time from the Stats overflow menu, and your choice is remembered. +* [*] Switching between the old and new Stats screen is available from the Stats overflow menu while we continue iterating on the new Stats design. 26.9 -----