Support category changed mechanism in homepage

- Homepage cannot referesh UI whenever an injected component is changed
- Extract categories related codes to a mixin

Test: manual, robotest
Fixes: 179792445
Change-Id: I1c13c541ce07b9c36fe984a035623985b5603560
diff --git a/src/com/android/settings/SettingsActivity.java b/src/com/android/settings/SettingsActivity.java
index 7dd5fe4..12f63ea 100644
--- a/src/com/android/settings/SettingsActivity.java
+++ b/src/com/android/settings/SettingsActivity.java
@@ -36,10 +36,8 @@
 import android.os.UserHandle;
 import android.os.UserManager;
 import android.text.TextUtils;
-import android.util.FeatureFlagUtils;
 import android.util.Log;
 import android.view.View;
-import android.view.Window;
 import android.widget.Button;
 
 import androidx.annotation.Nullable;
@@ -55,7 +53,6 @@
 import com.android.internal.util.ArrayUtils;
 import com.android.settings.Settings.WifiSettingsActivity;
 import com.android.settings.applications.manageapplications.ManageApplications;
-import com.android.settings.core.FeatureFlags;
 import com.android.settings.core.OnActivityResultListener;
 import com.android.settings.core.SettingsBaseActivity;
 import com.android.settings.core.SubSettingLauncher;
@@ -70,7 +67,6 @@
 import com.android.settingslib.development.DevelopmentSettingsEnabler;
 import com.android.settingslib.drawer.DashboardCategory;
 
-import com.google.android.material.transition.platform.MaterialSharedAxis;
 import com.google.android.setupcompat.util.WizardManagerHelper;
 
 import java.util.ArrayList;
@@ -689,7 +685,7 @@
         if (somethingChanged) {
             Log.d(LOG_TAG, "Enabled state changed for some tiles, reloading all categories "
                     + changedList.toString());
-            updateCategories();
+            mCategoryMixin.updateCategories();
         } else {
             Log.d(LOG_TAG, "No enabled state changed, skipping updateCategory call");
         }
diff --git a/src/com/android/settings/core/CategoryMixin.java b/src/com/android/settings/core/CategoryMixin.java
new file mode 100644
index 0000000..8d0a412
--- /dev/null
+++ b/src/com/android/settings/core/CategoryMixin.java
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.core;
+
+import static androidx.lifecycle.Lifecycle.Event.ON_PAUSE;
+import static androidx.lifecycle.Lifecycle.Event.ON_RESUME;
+
+import android.annotation.Nullable;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.AsyncTask;
+import android.text.TextUtils;
+import android.util.ArraySet;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.OnLifecycleEvent;
+
+import com.android.settings.dashboard.CategoryManager;
+import com.android.settingslib.drawer.Tile;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A mixin that handles live categories for Injection
+ */
+public class CategoryMixin implements LifecycleObserver {
+
+    private static final String TAG = "CategoryMixin";
+    private static final String DATA_SCHEME_PKG = "package";
+
+    // Serves as a temporary list of tiles to ignore until we heard back from the PM that they
+    // are disabled.
+    private static final ArraySet<ComponentName> sTileDenylist = new ArraySet<>();
+
+    private final Context mContext;
+    private final PackageReceiver mPackageReceiver = new PackageReceiver();
+    private final List<CategoryListener> mCategoryListeners = new ArrayList<>();
+    private int mCategoriesUpdateTaskCount;
+
+    public CategoryMixin(Context context) {
+        mContext = context;
+    }
+
+    /**
+     * Resume Lifecycle event
+     */
+    @OnLifecycleEvent(ON_RESUME)
+    public void onResume() {
+        final IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
+        filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+        filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
+        filter.addAction(Intent.ACTION_PACKAGE_REPLACED);
+        filter.addDataScheme(DATA_SCHEME_PKG);
+        mContext.registerReceiver(mPackageReceiver, filter);
+
+        updateCategories();
+    }
+
+    /**
+     * Pause Lifecycle event
+     */
+    @OnLifecycleEvent(ON_PAUSE)
+    public void onPause() {
+        mContext.unregisterReceiver(mPackageReceiver);
+    }
+
+    /**
+     * Add a category listener
+     */
+    public void addCategoryListener(CategoryListener listener) {
+        mCategoryListeners.add(listener);
+    }
+
+    /**
+     * Remove a category listener
+     */
+    public void removeCategoryListener(CategoryListener listener) {
+        mCategoryListeners.remove(listener);
+    }
+
+    /**
+     * Updates dashboard categories.
+     */
+    public void updateCategories() {
+        updateCategories(false /* fromBroadcast */);
+    }
+
+    void addToDenylist(ComponentName component) {
+        sTileDenylist.add(component);
+    }
+
+    void removeFromDenylist(ComponentName component) {
+        sTileDenylist.remove(component);
+    }
+
+    @VisibleForTesting
+    void onCategoriesChanged(Set<String> categories) {
+        mCategoryListeners.forEach(listener -> listener.onCategoriesChanged(categories));
+    }
+
+    private void updateCategories(boolean fromBroadcast) {
+        // Only allow at most 2 tasks existing at the same time since when the first one is
+        // executing, there may be new data from the second update request.
+        // Ignore the third update request because the second task is still waiting for the first
+        // task to complete in a serial thread, which will get the latest data.
+        if (mCategoriesUpdateTaskCount < 2) {
+            new CategoriesUpdateTask().execute(fromBroadcast);
+        }
+    }
+
+    /**
+     * A handler implementing a {@link CategoryMixin}
+     */
+    public interface CategoryHandler {
+        /** returns a {@link CategoryMixin} */
+        CategoryMixin getCategoryMixin();
+    }
+
+    /**
+     *  A listener receiving category change events.
+     */
+    public interface CategoryListener {
+        /**
+         * @param categories the changed categories that have to be refreshed, or null to force
+         *                   refreshing all.
+         */
+        void onCategoriesChanged(@Nullable Set<String> categories);
+    }
+
+    private class CategoriesUpdateTask extends AsyncTask<Boolean, Void, Set<String>> {
+
+        private final CategoryManager mCategoryManager;
+        private Map<ComponentName, Tile> mPreviousTileMap;
+
+        CategoriesUpdateTask() {
+            mCategoriesUpdateTaskCount++;
+            mCategoryManager = CategoryManager.get(mContext);
+        }
+
+        @Override
+        protected Set<String> doInBackground(Boolean... params) {
+            mPreviousTileMap = mCategoryManager.getTileByComponentMap();
+            mCategoryManager.reloadAllCategories(mContext);
+            mCategoryManager.updateCategoryFromDenylist(sTileDenylist);
+            return getChangedCategories(params[0]);
+        }
+
+        @Override
+        protected void onPostExecute(Set<String> categories) {
+            if (categories == null || !categories.isEmpty()) {
+                onCategoriesChanged(categories);
+            }
+            mCategoriesUpdateTaskCount--;
+        }
+
+        // Return the changed categories that have to be refreshed, or null to force refreshing all.
+        private Set<String> getChangedCategories(boolean fromBroadcast) {
+            if (!fromBroadcast) {
+                // Always refresh for non-broadcast case.
+                return null;
+            }
+
+            final Set<String> changedCategories = new ArraySet<>();
+            final Map<ComponentName, Tile> currentTileMap =
+                    mCategoryManager.getTileByComponentMap();
+            currentTileMap.forEach((component, currentTile) -> {
+                final Tile previousTile = mPreviousTileMap.get(component);
+                // Check if the tile is newly added.
+                if (previousTile == null) {
+                    Log.i(TAG, "Tile added: " + component.flattenToShortString());
+                    changedCategories.add(currentTile.getCategory());
+                    return;
+                }
+
+                // Check if the title or summary has changed.
+                if (!TextUtils.equals(currentTile.getTitle(mContext),
+                        previousTile.getTitle(mContext))
+                        || !TextUtils.equals(currentTile.getSummary(mContext),
+                        previousTile.getSummary(mContext))) {
+                    Log.i(TAG, "Tile changed: " + component.flattenToShortString());
+                    changedCategories.add(currentTile.getCategory());
+                }
+            });
+
+            // Check if any previous tile is removed.
+            final Set<ComponentName> removal = new ArraySet(mPreviousTileMap.keySet());
+            removal.removeAll(currentTileMap.keySet());
+            removal.forEach(component -> {
+                Log.i(TAG, "Tile removed: " + component.flattenToShortString());
+                changedCategories.add(mPreviousTileMap.get(component).getCategory());
+            });
+
+            return changedCategories;
+        }
+    }
+
+    private class PackageReceiver extends BroadcastReceiver {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            updateCategories(true /* fromBroadcast */);
+        }
+    }
+}
diff --git a/src/com/android/settings/core/SettingsBaseActivity.java b/src/com/android/settings/core/SettingsBaseActivity.java
index 6dba83b..47993cf 100644
--- a/src/com/android/settings/core/SettingsBaseActivity.java
+++ b/src/com/android/settings/core/SettingsBaseActivity.java
@@ -16,21 +16,15 @@
 package com.android.settings.core;
 
 import android.annotation.LayoutRes;
-import android.annotation.Nullable;
 import android.app.ActivityManager;
 import android.app.ActivityOptions;
-import android.content.BroadcastReceiver;
 import android.content.ComponentName;
-import android.content.Context;
 import android.content.Intent;
-import android.content.IntentFilter;
 import android.content.pm.PackageManager;
 import android.content.res.TypedArray;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.UserHandle;
 import android.text.TextUtils;
-import android.util.ArraySet;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.MenuItem;
@@ -40,14 +34,14 @@
 import android.widget.Toolbar;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.fragment.app.FragmentActivity;
 
 import com.android.settings.R;
 import com.android.settings.SubSettings;
 import com.android.settings.Utils;
-import com.android.settings.dashboard.CategoryManager;
+import com.android.settings.core.CategoryMixin.CategoryHandler;
 import com.android.settingslib.core.lifecycle.HideNonSystemOverlayMixin;
-import com.android.settingslib.drawer.Tile;
 import com.android.settingslib.transition.SettingsTransitionHelper;
 import com.android.settingslib.transition.SettingsTransitionHelper.TransitionType;
 
@@ -56,12 +50,8 @@
 import com.google.android.setupcompat.util.WizardManagerHelper;
 import com.google.android.setupdesign.util.ThemeHelper;
 
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class SettingsBaseActivity extends FragmentActivity {
+/** Base activity for Settings pages */
+public class SettingsBaseActivity extends FragmentActivity implements CategoryHandler {
 
     /**
      * What type of page transition should be apply.
@@ -70,21 +60,18 @@
 
     protected static final boolean DEBUG_TIMING = false;
     private static final String TAG = "SettingsBaseActivity";
-    private static final String DATA_SCHEME_PKG = "package";
     private static final int DEFAULT_REQUEST = -1;
 
-    // Serves as a temporary list of tiles to ignore until we heard back from the PM that they
-    // are disabled.
-    private static ArraySet<ComponentName> sTileDenylist = new ArraySet<>();
-
-    private final PackageReceiver mPackageReceiver = new PackageReceiver();
-    private final List<CategoryListener> mCategoryListeners = new ArrayList<>();
-
+    protected CategoryMixin mCategoryMixin;
     protected CollapsingToolbarLayout mCollapsingToolbarLayout;
-    private int mCategoriesUpdateTaskCount;
     private Toolbar mToolbar;
 
     @Override
+    public CategoryMixin getCategoryMixin() {
+        return mCategoryMixin;
+    }
+
+    @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {
         if (Utils.isPageTransitionEnabled(this)) {
             // Enable Activity transitions
@@ -102,6 +89,9 @@
         getLifecycle().addObserver(new HideNonSystemOverlayMixin(this));
         TextAppearanceConfig.setShouldLoadFontSynchronously(true);
 
+        mCategoryMixin = new CategoryMixin(this);
+        getLifecycle().addObserver(mCategoryMixin);
+
         final TypedArray theme = getTheme().obtainStyledAttributes(android.R.styleable.Theme);
         if (!theme.getBoolean(android.R.styleable.Theme_windowNoTitle, false)) {
             requestWindowFeature(Window.FEATURE_NO_TITLE);
@@ -193,36 +183,14 @@
     }
 
     @Override
-    protected void onResume() {
-        super.onResume();
-        final IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
-        filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
-        filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
-        filter.addAction(Intent.ACTION_PACKAGE_REPLACED);
-        filter.addDataScheme(DATA_SCHEME_PKG);
-        registerReceiver(mPackageReceiver, filter);
-
-        updateCategories();
-    }
-
-    @Override
     protected void onPause() {
         // For accessibility activities launched from setup wizard.
         if (getTransitionType(getIntent()) == TransitionType.TRANSITION_FADE) {
             overridePendingTransition(R.anim.sud_stay, android.R.anim.fade_out);
         }
-        unregisterReceiver(mPackageReceiver);
         super.onPause();
     }
 
-    public void addCategoryListener(CategoryListener listener) {
-        mCategoryListeners.add(listener);
-    }
-
-    public void remCategoryListener(CategoryListener listener) {
-        mCategoryListeners.remove(listener);
-    }
-
     @Override
     public void setContentView(@LayoutRes int layoutResID) {
         final ViewGroup parent = findViewById(R.id.content_frame);
@@ -270,13 +238,6 @@
         return true;
     }
 
-    private void onCategoriesChanged(Set<String> categories) {
-        final int N = mCategoryListeners.size();
-        for (int i = 0; i < N; i++) {
-            mCategoryListeners.get(i).onCategoriesChanged(categories);
-        }
-    }
-
     private boolean isLockTaskModePinned() {
         final ActivityManager activityManager =
                 getApplicationContext().getSystemService(ActivityManager.class);
@@ -300,9 +261,9 @@
         boolean isEnabled = state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
         if (isEnabled != enabled || state == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
             if (enabled) {
-                sTileDenylist.remove(component);
+                mCategoryMixin.removeFromDenylist(component);
             } else {
-                sTileDenylist.add(component);
+                mCategoryMixin.addToDenylist(component);
             }
             pm.setComponentEnabledSetting(component, enabled
                             ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
@@ -313,29 +274,12 @@
         return false;
     }
 
-    /**
-     * Updates dashboard categories. Only necessary to call this after setTileEnabled
-     */
-    public void updateCategories() {
-        updateCategories(false /* fromBroadcast */);
-    }
-
-    private void updateCategories(boolean fromBroadcast) {
-        // Only allow at most 2 tasks existing at the same time since when the first one is
-        // executing, there may be new data from the second update request.
-        // Ignore the third update request because the second task is still waiting for the first
-        // task to complete in a serial thread, which will get the latest data.
-        if (mCategoriesUpdateTaskCount < 2) {
-            new CategoriesUpdateTask().execute(fromBroadcast);
-        }
-    }
-
     private int getTransitionType(Intent intent) {
         return intent.getIntExtra(EXTRA_PAGE_TRANSITION_TYPE,
                 SettingsTransitionHelper.TransitionType.TRANSITION_SHARED_AXIS);
     }
 
-    @androidx.annotation.Nullable
+    @Nullable
     private Bundle createActivityOptionsBundleForTransition(
             @androidx.annotation.Nullable Bundle options) {
         if (mToolbar == null) {
@@ -352,87 +296,4 @@
         return mergedOptions;
     }
 
-    public interface CategoryListener {
-        /**
-         * @param categories the changed categories that have to be refreshed, or null to force
-         *                   refreshing all.
-         */
-        void onCategoriesChanged(@Nullable Set<String> categories);
-    }
-
-    private class CategoriesUpdateTask extends AsyncTask<Boolean, Void, Set<String>> {
-
-        private final Context mContext;
-        private final CategoryManager mCategoryManager;
-        private Map<ComponentName, Tile> mPreviousTileMap;
-
-        public CategoriesUpdateTask() {
-            mCategoriesUpdateTaskCount++;
-            mContext = SettingsBaseActivity.this;
-            mCategoryManager = CategoryManager.get(mContext);
-        }
-
-        @Override
-        protected Set<String> doInBackground(Boolean... params) {
-            mPreviousTileMap = mCategoryManager.getTileByComponentMap();
-            mCategoryManager.reloadAllCategories(mContext);
-            mCategoryManager.updateCategoryFromDenylist(sTileDenylist);
-            return getChangedCategories(params[0]);
-        }
-
-        @Override
-        protected void onPostExecute(Set<String> categories) {
-            if (categories == null || !categories.isEmpty()) {
-                onCategoriesChanged(categories);
-            }
-            mCategoriesUpdateTaskCount--;
-        }
-
-        // Return the changed categories that have to be refreshed, or null to force refreshing all.
-        private Set<String> getChangedCategories(boolean fromBroadcast) {
-            if (!fromBroadcast) {
-                // Always refresh for non-broadcast case.
-                return null;
-            }
-
-            final Set<String> changedCategories = new ArraySet<>();
-            final Map<ComponentName, Tile> currentTileMap =
-                    mCategoryManager.getTileByComponentMap();
-            currentTileMap.forEach((component, currentTile) -> {
-                final Tile previousTile = mPreviousTileMap.get(component);
-                // Check if the tile is newly added.
-                if (previousTile == null) {
-                    Log.i(TAG, "Tile added: " + component.flattenToShortString());
-                    changedCategories.add(currentTile.getCategory());
-                    return;
-                }
-
-                // Check if the title or summary has changed.
-                if (!TextUtils.equals(currentTile.getTitle(mContext),
-                        previousTile.getTitle(mContext))
-                        || !TextUtils.equals(currentTile.getSummary(mContext),
-                        previousTile.getSummary(mContext))) {
-                    Log.i(TAG, "Tile changed: " + component.flattenToShortString());
-                    changedCategories.add(currentTile.getCategory());
-                }
-            });
-
-            // Check if any previous tile is removed.
-            final Set<ComponentName> removal = new ArraySet(mPreviousTileMap.keySet());
-            removal.removeAll(currentTileMap.keySet());
-            removal.forEach(component -> {
-                Log.i(TAG, "Tile removed: " + component.flattenToShortString());
-                changedCategories.add(mPreviousTileMap.get(component).getCategory());
-            });
-
-            return changedCategories;
-        }
-    }
-
-    private class PackageReceiver extends BroadcastReceiver {
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            updateCategories(true /* fromBroadcast */);
-        }
-    }
 }
diff --git a/src/com/android/settings/dashboard/DashboardFragment.java b/src/com/android/settings/dashboard/DashboardFragment.java
index 29a11a3..dfd931d 100644
--- a/src/com/android/settings/dashboard/DashboardFragment.java
+++ b/src/com/android/settings/dashboard/DashboardFragment.java
@@ -35,8 +35,9 @@
 import com.android.settings.R;
 import com.android.settings.SettingsPreferenceFragment;
 import com.android.settings.core.BasePreferenceController;
+import com.android.settings.core.CategoryMixin.CategoryHandler;
+import com.android.settings.core.CategoryMixin.CategoryListener;
 import com.android.settings.core.PreferenceControllerListHelper;
-import com.android.settings.core.SettingsBaseActivity;
 import com.android.settings.overlay.FeatureFactory;
 import com.android.settings.widget.PrimarySwitchPreference;
 import com.android.settingslib.core.AbstractPreferenceController;
@@ -61,8 +62,7 @@
  * Base fragment for dashboard style UI containing a list of static and dynamic setting items.
  */
 public abstract class DashboardFragment extends SettingsPreferenceFragment
-        implements SettingsBaseActivity.CategoryListener, Indexable,
-        PreferenceGroup.OnExpandButtonClickListener,
+        implements CategoryListener, Indexable, PreferenceGroup.OnExpandButtonClickListener,
         BasePreferenceController.UiBlockListener {
     public static final String CATEGORY = "category";
     private static final String TAG = "DashboardFragment";
@@ -198,9 +198,9 @@
             return;
         }
         final Activity activity = getActivity();
-        if (activity instanceof SettingsBaseActivity) {
+        if (activity instanceof CategoryHandler) {
             mListeningToCategoryChange = true;
-            ((SettingsBaseActivity) activity).addCategoryListener(this);
+            ((CategoryHandler) activity).getCategoryMixin().addCategoryListener(this);
         }
         final ContentResolver resolver = getContentResolver();
         mDashboardTilePrefKeys.values().stream()
@@ -243,8 +243,8 @@
         unregisterDynamicDataObservers(new ArrayList<>(mRegisteredObservers));
         if (mListeningToCategoryChange) {
             final Activity activity = getActivity();
-            if (activity instanceof SettingsBaseActivity) {
-                ((SettingsBaseActivity) activity).remCategoryListener(this);
+            if (activity instanceof CategoryHandler) {
+                ((CategoryHandler) activity).getCategoryMixin().removeCategoryListener(this);
             }
             mListeningToCategoryChange = false;
         }
diff --git a/src/com/android/settings/homepage/SettingsHomepageActivity.java b/src/com/android/settings/homepage/SettingsHomepageActivity.java
index 5950e4b..f3fdf5a 100644
--- a/src/com/android/settings/homepage/SettingsHomepageActivity.java
+++ b/src/com/android/settings/homepage/SettingsHomepageActivity.java
@@ -38,13 +38,16 @@
 import com.android.settings.R;
 import com.android.settings.Utils;
 import com.android.settings.accounts.AvatarViewMixin;
+import com.android.settings.core.CategoryMixin;
 import com.android.settings.core.FeatureFlags;
 import com.android.settings.homepage.contextualcards.ContextualCardsFragment;
 import com.android.settings.overlay.FeatureFactory;
 import com.android.settingslib.core.lifecycle.HideNonSystemOverlayMixin;
 import com.android.settingslib.transition.SettingsTransitionHelper;
 
-public class SettingsHomepageActivity extends FragmentActivity {
+/** Settings homepage activity */
+public class SettingsHomepageActivity extends FragmentActivity implements
+        CategoryMixin.CategoryHandler {
 
     private static final String TAG = "SettingsHomepageActivity";
 
@@ -52,6 +55,12 @@
 
     private View mHomepageView;
     private View mSuggestionView;
+    private CategoryMixin mCategoryMixin;
+
+    @Override
+    public CategoryMixin getCategoryMixin() {
+        return mCategoryMixin;
+    }
 
     /**
      * Shows the homepage and shows/hides the suggestion together. Only allows to be executed once
@@ -87,6 +96,8 @@
                 .initSearchToolbar(this /* activity */, toolbar, SettingsEnums.SETTINGS_HOMEPAGE);
 
         getLifecycle().addObserver(new HideNonSystemOverlayMixin(this));
+        mCategoryMixin = new CategoryMixin(this);
+        getLifecycle().addObserver(mCategoryMixin);
 
         if (!getSystemService(ActivityManager.class).isLowRamDevice()) {
             // Only allow features on high ram devices.
diff --git a/tests/robotests/src/com/android/settings/core/CategoryMixinTest.java b/tests/robotests/src/com/android/settings/core/CategoryMixinTest.java
new file mode 100644
index 0000000..d64f95d
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/core/CategoryMixinTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.core;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.content.BroadcastReceiver;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.util.ArraySet;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.android.settings.core.CategoryMixin.CategoryListener;
+import com.android.settingslib.R;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+public class CategoryMixinTest {
+    private ActivityController<TestActivity> mActivityController;
+
+    @Before
+    public void setUp() {
+        mActivityController = Robolectric.buildActivity(TestActivity.class);
+    }
+
+    @Test
+    public void resumeActivity_shouldRegisterReceiver() {
+        mActivityController.setup();
+
+        final TestActivity activity = mActivityController.get();
+        assertThat(activity.getRegisteredReceivers()).isNotEmpty();
+    }
+
+    @Test
+    public void pauseActivity_shouldUnregisterReceiver() {
+        mActivityController.setup().pause();
+
+        final TestActivity activity = mActivityController.get();
+        assertThat(activity.getRegisteredReceivers()).isEmpty();
+    }
+
+    @Test
+    public void onCategoriesChanged_listenerAdded_shouldNotifyChanged() {
+        mActivityController.setup().pause();
+        final CategoryMixin categoryMixin = mActivityController.get().getCategoryMixin();
+        final CategoryListener listener = mock(CategoryListener.class);
+        categoryMixin.addCategoryListener(listener);
+
+        categoryMixin.onCategoriesChanged(new ArraySet<>());
+
+        verify(listener).onCategoriesChanged(anySet());
+    }
+
+    static class TestActivity extends AppCompatActivity implements CategoryMixin.CategoryHandler {
+
+        private CategoryMixin mCategoryMixin;
+        private List<BroadcastReceiver> mRegisteredReceivers = new ArrayList<>();
+
+        @Override
+        protected void onCreate(Bundle savedInstanceState) {
+            super.onCreate(savedInstanceState);
+            setTheme(R.style.Theme_AppCompat);
+            mCategoryMixin = new CategoryMixin(this);
+            getLifecycle().addObserver(mCategoryMixin);
+        }
+
+        @Override
+        public CategoryMixin getCategoryMixin() {
+            return mCategoryMixin;
+        }
+
+        @Override
+        public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+            mRegisteredReceivers.add(receiver);
+            return super.registerReceiver(receiver, filter);
+        }
+
+        @Override
+        public void unregisterReceiver(BroadcastReceiver receiver) {
+            mRegisteredReceivers.remove(receiver);
+            super.unregisterReceiver(receiver);
+        }
+
+        List<BroadcastReceiver> getRegisteredReceivers() {
+            return mRegisteredReceivers;
+        }
+    }
+}