Bug 1348820 - Setup A/B experiment for enabling Activity Stream in Nightly. r?grisha draft
authorSebastian Kaspari <s.kaspari@gmail.com>
Mon, 20 Mar 2017 19:46:39 +0100
changeset 551712 8739644d06a3b558d2a8c440e33168a412bad9e2
parent 501522 8d967436d696d1f8e3fb33cf7e3d32a72457ffa6
child 551713 caf079c5cb2f90c8a7b10bf91935a457b1b9b128
push id51149
push users.kaspari@gmail.com
push dateMon, 27 Mar 2017 11:35:35 +0000
reviewersgrisha
bugs1348820
milestone55.0a1
Bug 1348820 - Setup A/B experiment for enabling Activity Stream in Nightly. r?grisha This is a bit complicated. But most of that code should go away again as soon as we can stop shipping the opt-out preference. With this patch we have three flags that can be controlled via Switchboard: * activity-stream: This is our global kill switch and allows us to pull the feature if needed. A user has to be in this experiment to ever see activity stream. The goal is to enable this experiment for 100% of the Nightly audience. * activity-stream-opt-out: This is experiment will enable the Activity Stream by default. The goal is to enable this experiment for 50% of the Nightly audience. * activity-stream-settings: This experiment controls the visibility of a setting to enable/disable activity stream (settings -> advanced -> experimental features). This allows us to control whether users can opt-in or opt-out of the activity experiment. The goal is to enable this for 100% of the Nightly audience. MozReview-Commit-ID: BwEoTK6QMQx
mobile/android/base/java/org/mozilla/gecko/Experiments.java
mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java
mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStreamPreference.java
mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
mobile/android/base/resources/xml/preferences_advanced.xml
--- a/mobile/android/base/java/org/mozilla/gecko/Experiments.java
+++ b/mobile/android/base/java/org/mozilla/gecko/Experiments.java
@@ -54,16 +54,22 @@ public class Experiments {
     public static final String URLBAR_SHOW_EV_CERT_OWNER = "urlbar-show-ev-cert-owner";
 
     // Play HLS videos in a VideoView (Bug 1313391)
     public static final String HLS_VIDEO_PLAYBACK = "hls-video-playback";
 
     // Make new activity stream panel available (to replace top sites) (Bug 1313316)
     public static final String ACTIVITY_STREAM = "activity-stream";
 
+    // Show a setting in "experimental features" for enabling/disabling activity stream.
+    public static final String ACTIVITY_STREAM_SETTING = "activity-stream-setting";
+
+    // Enable Activity stream by default for users in the "opt out" group.
+    public static final String ACTIVITY_STREAM_OPT_OUT = "activity-stream-opt-out";
+
     // Tabs tray: Arrange tabs in two columns in portrait mode
     public static final String COMPACT_TABS = "compact-tabs";
 
     /**
      * Returns if a user is in certain local experiment.
      * @param experiment Name of experiment to look up
      * @return returns value for experiment or false if experiment does not exist.
      */
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStream.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.activitystream;
 
 import android.content.Context;
+import android.content.SharedPreferences;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.text.TextUtils;
 
 import org.mozilla.gecko.switchboard.SwitchBoard;
 
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.Experiments;
@@ -43,45 +44,108 @@ public class ActivityStream {
      * - https://github.com/mozilla/activity-stream/issues/1311
      */
     private static final List<String> UNDESIRED_LABELS = Arrays.asList(
             "render",
             "login",
             "edit"
     );
 
-    public static boolean isEnabled(Context context) {
-        if (!isUserEligible(context)) {
-            // If the user is not eligible then disable activity stream. Even if it has been
-            //  enabled before.
-            return false;
+    /**
+     * Returns true if the user has made an active decision: Enabling or disabling Activity Stream.
+     */
+    public static boolean hasUserEnabledOrDisabled(Context context) {
+        final SharedPreferences preferences = GeckoSharedPrefs.forApp(context);
+        return preferences.contains(GeckoPreferences.PREFS_ACTIVITY_STREAM);
+    }
+
+    /**
+     * Set the user's decision: Enable or disable Activity Stream.
+     */
+    public static void setUserEnabled(Context context, boolean value) {
+        GeckoSharedPrefs.forApp(context).edit()
+                .putBoolean(GeckoPreferences.PREFS_ACTIVITY_STREAM, value)
+                .apply();
+    }
+
+    /**
+     * Returns true if Activity Stream has been enabled by the user. Before calling this method
+     * hasUserEnabledOrDisabled() should be used to determine whether the user actually has made
+     * a decision.
+     */
+    public static boolean isEnabledByUser(Context context) {
+        final SharedPreferences preferences = GeckoSharedPrefs.forApp(context);
+        if (!preferences.contains(GeckoPreferences.PREFS_ACTIVITY_STREAM)) {
+            throw new IllegalStateException("User hasn't made a decision. Call hasUserEnabledOrDisabled() before calling this method");
         }
 
-        return GeckoSharedPrefs.forApp(context)
-                .getBoolean(GeckoPreferences.PREFS_ACTIVITY_STREAM, false);
+        return preferences.getBoolean(GeckoPreferences.PREFS_ACTIVITY_STREAM, /* should not be used */ false);
+    }
+
+    /**
+     * Is Activity Stream enabled by an A/B experiment?
+     */
+    public static boolean isEnabledByExperiment(Context context) {
+        // For users in the "opt out" group Activity Stream is enabled by default.
+        return SwitchBoard.isInExperiment(context, Experiments.ACTIVITY_STREAM_OPT_OUT);
     }
 
     /**
-     * Is the user eligible to use activity stream or should we hide it from settings etc.?
+     * Is Activity Stream enabled? Either actively by the user or by an experiment?
      */
-    public static boolean isUserEligible(Context context) {
-        if (AppConstants.MOZ_ANDROID_ACTIVITY_STREAM) {
-            // If the build flag is enabled then just show the option to the user.
-            return true;
+    public static boolean isEnabled(Context context) {
+        // (1) Can Activity Steam be enabled on this device?
+        if (!canBeEnabled(context)) {
+            return false;
+        }
+
+        // (2) Has Activity Stream be enabled/disabled by the user?
+        if (hasUserEnabledOrDisabled(context)) {
+            return isEnabledByUser(context);
+        }
+
+        // (3) Is Activity Stream enabled by an experiment?
+        return isEnabledByExperiment(context);
+    }
+
+    /**
+     * Can the user enable/disable Activity Stream (Returns true) or is this completely controlled by us?
+     */
+    public static boolean isUserSwitchable(Context context) {
+        // (1) Can Activity Steam be enabled on this device?
+        if (!canBeEnabled(context)) {
+            return false;
         }
 
-        if (AppConstants.NIGHTLY_BUILD && SwitchBoard.isInExperiment(context, Experiments.ACTIVITY_STREAM)) {
-            // If this is a nightly build and the user is part of the activity stream experiment then
-            // the option should be visible too. The experiment is limited to Nightly too but I want
-            // to make really sure that this isn't riding the trains accidentally.
-            return true;
+        // (2) Is the user part of the experiment for showing the settings UI?
+        return SwitchBoard.isInExperiment(context, Experiments.ACTIVITY_STREAM_SETTING);
+    }
+
+    /**
+     * This method returns true if Activity Stream can be enabled - by the user or an experiment.
+     * Whether a setting shows up or whether the user is in an experiment group is evaluated
+     * separately from this method. However if this methods returns false then Activity Stream
+     * should never be visible/enabled - no matter what build or what experiments are active.
+     */
+    public static boolean canBeEnabled(Context context) {
+        if (!AppConstants.NIGHTLY_BUILD) {
+            // If this is not a Nightly build then hide Activity Stream completely. We can control
+            // this via the Switchboard experiment too but I want to make really sure that this
+            // isn't riding the trains accidentally.
+            return false;
         }
 
-        // For everyone else activity stream is not available yet.
-        return false;
+        if (!SwitchBoard.isInExperiment(context, Experiments.ACTIVITY_STREAM)) {
+            // This is our kill switch. If the user is not part of this experiment then show no
+            // Activity Stream UI.
+            return false;
+        }
+
+        // Activity stream can be enabled. Whether it is depends on other experiments and settings.
+        return true;
     }
 
     /**
      * Query whether we want to display Activity Stream as a Home Panel (within the HomePager),
      * or as a HomePager replacement.
      */
     public static boolean isHomePanel() {
         return true;
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ActivityStreamPreference.java
@@ -0,0 +1,72 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.activitystream;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+import android.util.AttributeSet;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * A custom switch preference that is used while we allow users to opt-out from using Activity Stream.
+ */
+public class ActivityStreamPreference extends SwitchPreference {
+    @SuppressWarnings("unused") // Used from XML
+    public ActivityStreamPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+        init(context);
+    }
+
+    @SuppressWarnings("unused") // Used from XML
+    public ActivityStreamPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+        init(context);
+    }
+
+    @SuppressWarnings("unused") // Used from XML
+    public ActivityStreamPreference(Context context, AttributeSet attrs) {
+        super(context, attrs);
+        init(context);
+    }
+
+    @SuppressWarnings("unused") // Used from XML
+    public ActivityStreamPreference(Context context) {
+        super(context);
+        init(context);
+    }
+
+    private void init(Context context) {
+        // The SwitchPreference shouldn't do any persistence itself. We want to avoid that a value
+        // is written that is not set by the user but set from an experiment.
+        setPersistent(false);
+
+        setChecked(ActivityStream.isEnabled(context));
+    }
+
+    @Override
+    public boolean isPersistent() {
+        // Just be absolutely sure that no one re-sets this value since calling init().
+        return false;
+    }
+
+    @Override
+    protected void onClick() {
+        super.onClick();
+
+        ActivityStream.setUserEnabled(getContext(), isChecked());
+
+        // We require a restart for this change to take effect. This is not nice, but this setting
+        // is not something we want to ship outside of Nightly anyways.
+        ThreadUtils.postDelayedToUiThread(new Runnable() {
+            @Override
+            public void run() {
+                GeckoAppShell.scheduleRestart();
+            }
+        }, 1000);
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
+++ b/mobile/android/base/java/org/mozilla/gecko/preferences/GeckoPreferences.java
@@ -163,17 +163,17 @@ public class GeckoPreferences
     private static final String PREFS_FEEDBACK_LINK = NON_PREF_PREFIX + "feedback.link";
     public static final String PREFS_NOTIFICATIONS_CONTENT = NON_PREF_PREFIX + "notifications.content";
     public static final String PREFS_NOTIFICATIONS_CONTENT_LEARN_MORE = NON_PREF_PREFIX + "notifications.content.learn_more";
     public static final String PREFS_NOTIFICATIONS_WHATS_NEW = NON_PREF_PREFIX + "notifications.whats_new";
     public static final String PREFS_APP_UPDATE_LAST_BUILD_ID = "app.update.last_build_id";
     public static final String PREFS_READ_PARTNER_CUSTOMIZATIONS_PROVIDER = NON_PREF_PREFIX + "distribution.read_partner_customizations_provider";
     public static final String PREFS_READ_PARTNER_BOOKMARKS_PROVIDER = NON_PREF_PREFIX + "distribution.read_partner_bookmarks_provider";
     public static final String PREFS_CUSTOM_TABS = NON_PREF_PREFIX + "customtabs";
-    public static final String PREFS_ACTIVITY_STREAM = NON_PREF_PREFIX + "activitystream";
+    public static final String PREFS_ACTIVITY_STREAM = NON_PREF_PREFIX + "experiments.activitystream";
     public static final String PREFS_CATEGORY_EXPERIMENTAL_FEATURES = NON_PREF_PREFIX + "category_experimental";
     public static final String PREFS_COMPACT_TABS = NON_PREF_PREFIX + "compact_tabs";
     public static final String PREFS_SHOW_QUIT_MENU = NON_PREF_PREFIX + "distribution.show_quit_menu";
     public static final String PREFS_SEARCH_SUGGESTIONS_ENABLED = "browser.search.suggest.enabled";
     public static final String PREFS_DEFAULT_BROWSER = NON_PREF_PREFIX + "default_browser.link";
 
     private static final String ACTION_STUMBLER_UPLOAD_PREF = "STUMBLER_PREF";
 
@@ -709,18 +709,18 @@ public class GeckoPreferences
                         continue;
                     }
                 } else if (PREFS_SCREEN_ADVANCED.equals(key) &&
                         !Restrictions.isAllowed(this, Restrictable.ADVANCED_SETTINGS)) {
                     preferences.removePreference(pref);
                     i--;
                     continue;
                 } else if (PREFS_CATEGORY_EXPERIMENTAL_FEATURES.equals(key)
-                        && !AppConstants.MOZ_ANDROID_ACTIVITY_STREAM
-                        && !AppConstants.MOZ_ANDROID_CUSTOM_TABS) {
+                        && !AppConstants.MOZ_ANDROID_CUSTOM_TABS
+                        && !ActivityStream.isUserSwitchable(this)) {
                     preferences.removePreference(pref);
                     i--;
                     continue;
                 }
                 setupPreferences((PreferenceGroup) pref, prefs);
             } else {
                 if (HANDLERS.containsKey(key)) {
                     PrefHandler handler = HANDLERS.get(key);
@@ -910,17 +910,18 @@ public class GeckoPreferences
                         preferences.removePreference(pref);
                         i--;
                         continue;
                     }
                 } else if (PREFS_CUSTOM_TABS.equals(key) && !AppConstants.MOZ_ANDROID_CUSTOM_TABS) {
                     preferences.removePreference(pref);
                     i--;
                     continue;
-                } else if (PREFS_ACTIVITY_STREAM.equals(key) && !ActivityStream.isUserEligible(this)) {
+                } else if (PREFS_ACTIVITY_STREAM.equals(key)
+                        && !ActivityStream.isUserSwitchable(this)) {
                     preferences.removePreference(pref);
                     i--;
                     continue;
                 } else if (PREFS_COMPACT_TABS.equals(key)) {
                     if (HardwareUtils.isTablet()) {
                         preferences.removePreference(pref);
                         i--;
                         continue;
@@ -1236,23 +1237,16 @@ public class GeckoPreferences
         } else if (PREFS_TAB_QUEUE.equals(prefName)) {
             if ((Boolean) newValue && !TabQueueHelper.canDrawOverlays(this)) {
                 Intent promptIntent = new Intent(this, TabQueuePrompt.class);
                 startActivityForResult(promptIntent, REQUEST_CODE_TAB_QUEUE);
                 return false;
             }
         } else if (PREFS_NOTIFICATIONS_CONTENT.equals(prefName)) {
             FeedService.setup(this);
-        } else if (PREFS_ACTIVITY_STREAM.equals(prefName)) {
-            ThreadUtils.postDelayedToUiThread(new Runnable() {
-                @Override
-                public void run() {
-                    GeckoAppShell.scheduleRestart();
-                }
-            }, 1000);
         } else if (HANDLERS.containsKey(prefName)) {
             PrefHandler handler = HANDLERS.get(prefName);
             handler.onChange(this, preference, newValue);
         } else if (PREFS_SEARCH_SUGGESTIONS_ENABLED.equals(prefName)) {
             // Tell Gecko to transmit the current search engine data again, so
             // BrowserSearch is notified immediately about the new enabled state.
             EventDispatcher.getInstance().dispatch("SearchEngines:GetVisible", null);
         }
--- a/mobile/android/base/resources/xml/preferences_advanced.xml
+++ b/mobile/android/base/resources/xml/preferences_advanced.xml
@@ -79,20 +79,20 @@
                                                                 android:persistent="false"
                                                                 url="https://developer.mozilla.org/docs/Tools/Remote_Debugging/Debugging_Firefox_for_Android_with_WebIDE" />
     </PreferenceCategory>
 
     <PreferenceCategory
         android:key="android.not_a_preference.category_experimental"
         android:title="@string/pref_category_experimental">
 
-        <SwitchPreference android:key="android.not_a_preference.activitystream"
+        <org.mozilla.gecko.activitystream.ActivityStreamPreference
+            android:key="android.not_a_preference.experiments.activitystream"
             android:title="@string/pref_activity_stream"
-            android:summary="@string/pref_activity_stream_summary"
-            android:defaultValue="false" />
+            android:summary="@string/pref_activity_stream_summary" />
 
 
         <SwitchPreference android:key="android.not_a_preference.customtabs"
             android:title="@string/pref_custom_tabs"
             android:summary="@string/pref_custom_tabs_summary"
             android:defaultValue="false" />
 
     </PreferenceCategory>