Update NotificationChannelSettings Preferences in place to prevent re-layout.

(cherry picked from commit b409b640451cfc58fb529659b80cd872ed717095)

Fixes: 110093185
Test: ChannelListPreferenceControllerTest
Change-Id: If6acf305c44085e502a3304ea57e409ce049b40f
Merged-In: If6acf305c44085e502a3304ea57e409ce049b40f
diff --git a/src/com/android/settings/notification/app/ChannelListPreferenceController.java b/src/com/android/settings/notification/app/ChannelListPreferenceController.java
index 8a34672..88d960d 100644
--- a/src/com/android/settings/notification/app/ChannelListPreferenceController.java
+++ b/src/com/android/settings/notification/app/ChannelListPreferenceController.java
@@ -23,16 +23,14 @@
 import android.app.NotificationChannelGroup;
 import android.app.settings.SettingsEnums;
 import android.content.Context;
-import android.graphics.BlendMode;
-import android.graphics.BlendModeColorFilter;
 import android.graphics.drawable.Drawable;
-import android.graphics.drawable.GradientDrawable;
-import android.graphics.drawable.LayerDrawable;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.provider.Settings;
 import android.text.TextUtils;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.preference.Preference;
 import androidx.preference.PreferenceCategory;
 import androidx.preference.PreferenceGroup;
@@ -53,7 +51,8 @@
 public class ChannelListPreferenceController extends NotificationPreferenceController {
 
     private static final String KEY = "channels";
-    private static String KEY_GENERAL_CATEGORY = "categories";
+    private static final String KEY_GENERAL_CATEGORY = "categories";
+    private static final String KEY_ZERO_CATEGORIES = "zeroCategories";
     public static final String ARG_FROM_SETTINGS = "fromSettings";
 
     private List<NotificationChannelGroup> mChannelGroupList;
@@ -102,62 +101,192 @@
                 if (mContext == null) {
                     return;
                 }
-                populateList();
+                updateFullList(mPreference, mChannelGroupList);
             }
         }.execute();
     }
 
-    private void populateList() {
-        // TODO: if preference has children, compare with newly loaded list
-        mPreference.removeAll();
-
-        if (mChannelGroupList.isEmpty()) {
-            PreferenceCategory groupCategory = new PreferenceCategory(mContext);
-            groupCategory.setTitle(R.string.notification_channels);
-            groupCategory.setKey(KEY_GENERAL_CATEGORY);
-            mPreference.addPreference(groupCategory);
-
-            Preference empty = new Preference(mContext);
-            empty.setTitle(R.string.no_channels);
-            empty.setEnabled(false);
-            groupCategory.addPreference(empty);
-        } else {
-            populateGroupList();
-        }
-    }
-
-    private void populateGroupList() {
-        for (NotificationChannelGroup group : mChannelGroupList) {
-            PreferenceCategory groupCategory = new PreferenceCategory(mContext);
-            groupCategory.setOrderingAsAdded(true);
-            mPreference.addPreference(groupCategory);
-            if (group.getId() == null) {
-                groupCategory.setTitle(R.string.notification_channels_other);
-                groupCategory.setKey(KEY_GENERAL_CATEGORY);
+    /**
+     * Update the preferences group to match the
+     * @param groupPrefsList
+     * @param channelGroups
+     */
+    void updateFullList(@NonNull PreferenceCategory groupPrefsList,
+                @NonNull List<NotificationChannelGroup> channelGroups) {
+        if (channelGroups.isEmpty()) {
+            if (groupPrefsList.getPreferenceCount() == 1
+                    && KEY_ZERO_CATEGORIES.equals(groupPrefsList.getPreference(0).getKey())) {
+                // Ensure the titles are correct for the current language, but otherwise leave alone
+                PreferenceGroup groupCategory = (PreferenceGroup) groupPrefsList.getPreference(0);
+                groupCategory.setTitle(R.string.notification_channels);
+                groupCategory.getPreference(0).setTitle(R.string.no_channels);
             } else {
-                groupCategory.setTitle(group.getName());
-                groupCategory.setKey(group.getId());
-                populateGroupToggle(groupCategory, group);
+                // Clear any contents and create the 'zero-categories' group.
+                groupPrefsList.removeAll();
+
+                PreferenceCategory groupCategory = new PreferenceCategory(mContext);
+                groupCategory.setTitle(R.string.notification_channels);
+                groupCategory.setKey(KEY_ZERO_CATEGORIES);
+                groupPrefsList.addPreference(groupCategory);
+
+                Preference empty = new Preference(mContext);
+                empty.setTitle(R.string.no_channels);
+                empty.setEnabled(false);
+                groupCategory.addPreference(empty);
             }
-            if (!group.isBlocked()) {
-                final List<NotificationChannel> channels = group.getChannels();
-                Collections.sort(channels, CHANNEL_COMPARATOR);
-                int N = channels.size();
-                for (int i = 0; i < N; i++) {
-                    final NotificationChannel channel = channels.get(i);
-                    // conversations get their own section
-                    if (TextUtils.isEmpty(channel.getConversationId()) || channel.isDemoted()) {
-                        populateSingleChannelPrefs(groupCategory, channel, group.isBlocked());
-                    }
-                }
+        } else {
+            updateGroupList(groupPrefsList, channelGroups);
+        }
+    }
+
+    /**
+     * Looks for the category for the given group's key at the expected index, if that doesn't
+     * match, it checks all groups, and if it can't find that group anywhere, it creates it.
+     */
+    @NonNull
+    private PreferenceCategory findOrCreateGroupCategoryForKey(
+            @NonNull PreferenceCategory groupPrefsList, @Nullable String key, int expectedIndex) {
+        if (key == null) {
+            key = KEY_GENERAL_CATEGORY;
+        }
+        int preferenceCount = groupPrefsList.getPreferenceCount();
+        if (expectedIndex < preferenceCount) {
+            Preference preference = groupPrefsList.getPreference(expectedIndex);
+            if (key.equals(preference.getKey())) {
+                return (PreferenceCategory) preference;
+            }
+        }
+        for (int i = 0; i < preferenceCount; i++) {
+            Preference preference = groupPrefsList.getPreference(i);
+            if (key.equals(preference.getKey())) {
+                preference.setOrder(expectedIndex);
+                return (PreferenceCategory) preference;
+            }
+        }
+        PreferenceCategory groupCategory = new PreferenceCategory(mContext);
+        groupCategory.setOrder(expectedIndex);
+        groupCategory.setKey(key);
+        groupPrefsList.addPreference(groupCategory);
+        return groupCategory;
+    }
+
+    private void updateGroupList(@NonNull PreferenceCategory groupPrefsList,
+            @NonNull List<NotificationChannelGroup> channelGroups) {
+        // Update the list, but optimize for the most common case where the list hasn't changed.
+        int numFinalGroups = channelGroups.size();
+        int initialPrefCount = groupPrefsList.getPreferenceCount();
+        List<PreferenceCategory> finalOrderedGroups = new ArrayList<>(numFinalGroups);
+        for (int i = 0; i < numFinalGroups; i++) {
+            NotificationChannelGroup group = channelGroups.get(i);
+            PreferenceCategory groupCategory =
+                    findOrCreateGroupCategoryForKey(groupPrefsList, group.getId(), i);
+            finalOrderedGroups.add(groupCategory);
+            updateGroupPreferences(group, groupCategory);
+        }
+        int postAddPrefCount = groupPrefsList.getPreferenceCount();
+        // If any groups were inserted (into a non-empty list) or need to be removed, we need to
+        // remove all groups and re-add them all.
+        // This is required to ensure proper ordering of inserted groups, and it simplifies logic
+        // at the cost of computation in the rare case that the list is changing.
+        boolean hasInsertions = initialPrefCount != 0 && initialPrefCount != numFinalGroups;
+        boolean requiresRemoval = postAddPrefCount != numFinalGroups;
+        if (hasInsertions || requiresRemoval) {
+            groupPrefsList.removeAll();
+            for (PreferenceCategory group : finalOrderedGroups) {
+                groupPrefsList.addPreference(group);
             }
         }
     }
 
-    protected void populateGroupToggle(final PreferenceGroup parent,
-            NotificationChannelGroup group) {
-        RestrictedSwitchPreference preference =
-                new RestrictedSwitchPreference(mContext);
+    /**
+     * Looks for the channel preference for the given channel's key at the expected index, if that
+     * doesn't match, it checks all rows, and if it can't find that channel anywhere, it creates
+     * the preference.
+     */
+    @NonNull
+    private MasterSwitchPreference findOrCreateChannelPrefForKey(
+            @NonNull PreferenceGroup groupPrefGroup, @NonNull String key, int expectedIndex) {
+        int preferenceCount = groupPrefGroup.getPreferenceCount();
+        if (expectedIndex < preferenceCount) {
+            Preference preference = groupPrefGroup.getPreference(expectedIndex);
+            if (key.equals(preference.getKey())) {
+                return (MasterSwitchPreference) preference;
+            }
+        }
+        for (int i = 0; i < preferenceCount; i++) {
+            Preference preference = groupPrefGroup.getPreference(i);
+            if (key.equals(preference.getKey())) {
+                preference.setOrder(expectedIndex);
+                return (MasterSwitchPreference) preference;
+            }
+        }
+        MasterSwitchPreference channelPref = new MasterSwitchPreference(mContext);
+        channelPref.setOrder(expectedIndex);
+        channelPref.setKey(key);
+        groupPrefGroup.addPreference(channelPref);
+        return channelPref;
+    }
+
+    private void updateGroupPreferences(@NonNull NotificationChannelGroup group,
+            @NonNull PreferenceGroup groupPrefGroup) {
+        int initialPrefCount = groupPrefGroup.getPreferenceCount();
+        List<Preference> finalOrderedPrefs = new ArrayList<>();
+        if (group.getId() == null) {
+            // For the 'null' group, set the "Other" title.
+            groupPrefGroup.setTitle(R.string.notification_channels_other);
+        } else {
+            // For an app-defined group, set their name and create a row to toggle 'isBlocked'.
+            groupPrefGroup.setTitle(group.getName());
+            finalOrderedPrefs.add(addOrUpdateGroupToggle(groupPrefGroup, group));
+        }
+        // Here "empty" means having no channel rows; the group toggle is ignored for this purpose.
+        boolean initiallyEmpty = groupPrefGroup.getPreferenceCount() == finalOrderedPrefs.size();
+
+        // For each channel, add or update the preference object.
+        final List<NotificationChannel> channels =
+                group.isBlocked() ? Collections.emptyList() : group.getChannels();
+        Collections.sort(channels, CHANNEL_COMPARATOR);
+        for (NotificationChannel channel : channels) {
+            if (!TextUtils.isEmpty(channel.getConversationId()) && !channel.isDemoted()) {
+                // conversations get their own section
+                continue;
+            }
+            // Get or create the row, and populate its current state.
+            MasterSwitchPreference channelPref = findOrCreateChannelPrefForKey(groupPrefGroup,
+                    channel.getId(), /* expectedIndex */ finalOrderedPrefs.size());
+            updateSingleChannelPrefs(channelPref, channel, group.isBlocked());
+            finalOrderedPrefs.add(channelPref);
+        }
+        int postAddPrefCount = groupPrefGroup.getPreferenceCount();
+
+        // If any channels were inserted (into a non-empty list) or need to be removed, we need to
+        // remove all preferences and re-add them all.
+        // This is required to ensure proper ordering of inserted channels, and it simplifies logic
+        // at the cost of computation in the rare case that the list is changing.
+        int numFinalGroups = finalOrderedPrefs.size();
+        boolean hasInsertions = !initiallyEmpty && initialPrefCount != numFinalGroups;
+        boolean requiresRemoval = postAddPrefCount != numFinalGroups;
+        if (hasInsertions || requiresRemoval) {
+            groupPrefGroup.removeAll();
+            for (Preference preference : finalOrderedPrefs) {
+                groupPrefGroup.addPreference(preference);
+            }
+        }
+    }
+
+    /** Add or find and update the toggle for disabling the entire notification channel group. */
+    private Preference addOrUpdateGroupToggle(@NonNull final PreferenceGroup parent,
+            @NonNull final NotificationChannelGroup group) {
+        boolean shouldAdd = false;
+        final RestrictedSwitchPreference preference;
+        if (parent.getPreferenceCount() > 0
+                && parent.getPreference(0) instanceof RestrictedSwitchPreference) {
+            preference = (RestrictedSwitchPreference) parent.getPreference(0);
+        } else {
+            shouldAdd = true;
+            preference = new RestrictedSwitchPreference(mContext);
+        }
+        preference.setOrder(-1);
         preference.setTitle(mContext.getString(
                 R.string.notification_switch_label, group.getName()));
         preference.setEnabled(mAdmin == null
@@ -171,23 +300,26 @@
             onGroupBlockStateChanged(group);
             return true;
         });
-
-        parent.addPreference(preference);
+        if (shouldAdd) {
+            parent.addPreference(preference);
+        }
+        return preference;
     }
 
-    protected Preference populateSingleChannelPrefs(PreferenceGroup parent,
-            final NotificationChannel channel, final boolean groupBlocked) {
-        MasterSwitchPreference channelPref = new MasterSwitchPreference(mContext);
+    /** Update the properties of the channel preference with the values from the channel object. */
+    private void updateSingleChannelPrefs(@NonNull final MasterSwitchPreference channelPref,
+            @NonNull final NotificationChannel channel,
+            final boolean groupBlocked) {
         channelPref.setSwitchEnabled(mAdmin == null
                 && isChannelBlockable(channel)
                 && isChannelConfigurable(channel)
                 && !groupBlocked);
-        channelPref.setIcon(null);
         if (channel.getImportance() > IMPORTANCE_LOW) {
             channelPref.setIcon(getAlertingIcon());
+        } else {
+            channelPref.setIcon(null);
         }
         channelPref.setIconSize(MasterSwitchPreference.ICON_SIZE_SMALL);
-        channelPref.setKey(channel.getId());
         channelPref.setTitle(channel.getName());
         channelPref.setSummary(NotificationBackend.getSentSummary(
                 mContext, mAppRow.sentByChannel.get(channel.getId()), false));
@@ -219,10 +351,6 @@
 
                     return true;
                 });
-        if (parent.findPreference(channelPref.getKey()) == null) {
-            parent.addPreference(channelPref);
-        }
-        return channelPref;
     }
 
     private Drawable getAlertingIcon() {
@@ -235,30 +363,9 @@
         if (group == null) {
             return;
         }
-        PreferenceGroup groupGroup = mPreference.findPreference(group.getId());
-
-        if (groupGroup != null) {
-            if (group.isBlocked()) {
-                List<Preference> toRemove = new ArrayList<>();
-                int childCount = groupGroup.getPreferenceCount();
-                for (int i = 0; i < childCount; i++) {
-                    Preference pref = groupGroup.getPreference(i);
-                    if (pref instanceof MasterSwitchPreference) {
-                        toRemove.add(pref);
-                    }
-                }
-                for (Preference pref : toRemove) {
-                    groupGroup.removePreference(pref);
-                }
-            } else {
-                final List<NotificationChannel> channels = group.getChannels();
-                Collections.sort(channels, CHANNEL_COMPARATOR);
-                int N = channels.size();
-                for (int i = 0; i < N; i++) {
-                    final NotificationChannel channel = channels.get(i);
-                    populateSingleChannelPrefs(groupGroup, channel, group.isBlocked());
-                }
-            }
+        PreferenceGroup groupPrefGroup = mPreference.findPreference(group.getId());
+        if (groupPrefGroup != null) {
+            updateGroupPreferences(group, groupPrefGroup);
         }
     }
 }
diff --git a/src/com/android/settings/widget/MasterSwitchPreference.java b/src/com/android/settings/widget/MasterSwitchPreference.java
index 9fe077e..7221035 100644
--- a/src/com/android/settings/widget/MasterSwitchPreference.java
+++ b/src/com/android/settings/widget/MasterSwitchPreference.java
@@ -23,6 +23,8 @@
 import android.view.View.OnClickListener;
 import android.widget.Switch;
 
+import androidx.annotation.Keep;
+import androidx.annotation.Nullable;
 import androidx.preference.PreferenceViewHolder;
 
 import com.android.settings.R;
@@ -101,6 +103,16 @@
         return mSwitch != null && mChecked;
     }
 
+    /**
+     * Used to validate the state of mChecked and mCheckedSet when testing, without requiring
+     * that a ViewHolder be bound to the object.
+     */
+    @Keep
+    @Nullable
+    public Boolean getCheckedState() {
+        return mCheckedSet ? mChecked : null;
+    }
+
     public void setChecked(boolean checked) {
         // Always set checked the first time; don't assume the field's default of false.
         final boolean changed = mChecked != checked;
diff --git a/tests/unit/src/com/android/settings/notification/app/ChannelListPreferenceControllerTest.java b/tests/unit/src/com/android/settings/notification/app/ChannelListPreferenceControllerTest.java
new file mode 100644
index 0000000..f9c8132
--- /dev/null
+++ b/tests/unit/src/com/android/settings/notification/app/ChannelListPreferenceControllerTest.java
@@ -0,0 +1,395 @@
+/*
+ * Copyright (C) 2020 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.notification.app;
+
+import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
+import static android.app.NotificationManager.IMPORTANCE_HIGH;
+import static android.app.NotificationManager.IMPORTANCE_NONE;
+
+import static junit.framework.TestCase.assertEquals;
+import static junit.framework.TestCase.assertFalse;
+import static junit.framework.TestCase.assertNotNull;
+import static junit.framework.TestCase.assertNull;
+import static junit.framework.TestCase.assertTrue;
+
+import android.app.Instrumentation;
+import android.app.NotificationChannel;
+import android.app.NotificationChannelGroup;
+import android.content.Context;
+
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceManager;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.SwitchPreference;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.settings.notification.NotificationBackend;
+import com.android.settings.notification.NotificationBackend.NotificationsSentState;
+import com.android.settings.widget.MasterSwitchPreference;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ChannelListPreferenceControllerTest {
+    private Context mContext;
+    private NotificationBackend mBackend;
+    private NotificationBackend.AppRow mAppRow;
+    private ChannelListPreferenceController mController;
+    private PreferenceManager mPreferenceManager;
+    private PreferenceScreen mPreferenceScreen;
+    private PreferenceCategory mGroupList;
+
+    @Before
+    public void setUp() throws Exception {
+        Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+        mContext = instrumentation.getTargetContext();
+
+        instrumentation.runOnMainSync(() -> {
+            mBackend = new NotificationBackend();
+            mAppRow = mBackend.loadAppRow(mContext,
+                    mContext.getPackageManager(), mContext.getApplicationInfo());
+            mController = new ChannelListPreferenceController(mContext, mBackend);
+            mController.onResume(mAppRow, null, null, null, null, null);
+            mPreferenceManager = new PreferenceManager(mContext);
+            mPreferenceScreen = mPreferenceManager.createPreferenceScreen(mContext);
+            mGroupList = new PreferenceCategory(mContext);
+            mPreferenceScreen.addPreference(mGroupList);
+        });
+    }
+
+    @Test
+    @UiThreadTest
+    public void testUpdateFullList_incrementalUpdates() {
+        // Start by testing the case with no groups or channels
+        List<NotificationChannelGroup> inGroups = new ArrayList<>();
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            assertEquals("zeroCategories", mGroupList.getPreference(0).getKey());
+        }
+
+        // Test that adding a group clears the zero category and adds everything
+        NotificationChannelGroup inGroup1 = new NotificationChannelGroup("group1", "Group 1");
+        inGroup1.addChannel(new NotificationChannel("ch1a", "Channel 1A", IMPORTANCE_DEFAULT));
+        inGroups.add(inGroup1);
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group1", group1.getKey());
+            assertEquals(2, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+        }
+
+        // Test that adding a channel works -- no dupes or omissions
+        inGroup1.addChannel(new NotificationChannel("ch1b", "Channel 1B", IMPORTANCE_DEFAULT));
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group1", group1.getKey());
+            assertEquals(3, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+            assertEquals("ch1b", group1.getPreference(2).getKey());
+            assertEquals("Channel 1B", group1.getPreference(2).getTitle());
+        }
+
+        // Test that renaming a channel does in fact rename the preferences
+        inGroup1.getChannels().get(1).setName("Channel 1B - Renamed");
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group1", group1.getKey());
+            assertEquals(3, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+            assertEquals("ch1b", group1.getPreference(2).getKey());
+            assertEquals("Channel 1B - Renamed", group1.getPreference(2).getTitle());
+        }
+
+        // Test that adding a group works and results in the correct sorting.
+        NotificationChannelGroup inGroup0 = new NotificationChannelGroup("group0", "Group 0");
+        inGroup0.addChannel(new NotificationChannel("ch0b", "Channel 0B", IMPORTANCE_DEFAULT));
+        // NOTE: updateFullList takes a List which has been sorted, so we insert at 0 for this check
+        inGroups.add(0, inGroup0);
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(2, mGroupList.getPreferenceCount());
+            PreferenceGroup group0 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group0", group0.getKey());
+            assertEquals(2, group0.getPreferenceCount());
+            assertNull(group0.getPreference(0).getKey());
+            assertEquals("All \"Group 0\" notifications", group0.getPreference(0).getTitle());
+            assertEquals("ch0b", group0.getPreference(1).getKey());
+            assertEquals("Channel 0B", group0.getPreference(1).getTitle());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(1);
+            assertEquals("group1", group1.getKey());
+            assertEquals(3, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+            assertEquals("ch1b", group1.getPreference(2).getKey());
+            assertEquals("Channel 1B - Renamed", group1.getPreference(2).getTitle());
+        }
+
+        // Test that adding a channel that comes before another works and has correct ordering.
+        // NOTE: the channels within a group are sorted inside updateFullList.
+        inGroup0.addChannel(new NotificationChannel("ch0a", "Channel 0A", IMPORTANCE_DEFAULT));
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(2, mGroupList.getPreferenceCount());
+            PreferenceGroup group0 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group0", group0.getKey());
+            assertEquals(3, group0.getPreferenceCount());
+            assertNull(group0.getPreference(0).getKey());
+            assertEquals("All \"Group 0\" notifications", group0.getPreference(0).getTitle());
+            assertEquals("ch0a", group0.getPreference(1).getKey());
+            assertEquals("Channel 0A", group0.getPreference(1).getTitle());
+            assertEquals("ch0b", group0.getPreference(2).getKey());
+            assertEquals("Channel 0B", group0.getPreference(2).getTitle());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(1);
+            assertEquals("group1", group1.getKey());
+            assertEquals(3, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+            assertEquals("ch1b", group1.getPreference(2).getKey());
+            assertEquals("Channel 1B - Renamed", group1.getPreference(2).getTitle());
+        }
+
+        // Test that the "Other" group works.
+        // Also test a simultaneous addition and deletion.
+        inGroups.remove(inGroup0);
+        NotificationChannelGroup inGroupOther = new NotificationChannelGroup(null, null);
+        inGroupOther.addChannel(new NotificationChannel("chXa", "Other A", IMPORTANCE_DEFAULT));
+        inGroupOther.addChannel(new NotificationChannel("chXb", "Other B", IMPORTANCE_DEFAULT));
+        inGroups.add(inGroupOther);
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(2, mGroupList.getPreferenceCount());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group1", group1.getKey());
+            assertEquals(3, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+            assertEquals("ch1b", group1.getPreference(2).getKey());
+            assertEquals("Channel 1B - Renamed", group1.getPreference(2).getTitle());
+            PreferenceGroup groupOther = (PreferenceGroup) mGroupList.getPreference(1);
+            assertEquals("categories", groupOther.getKey());
+            assertEquals(2, groupOther.getPreferenceCount());
+            assertEquals("chXa", groupOther.getPreference(0).getKey());
+            assertEquals("Other A", groupOther.getPreference(0).getTitle());
+            assertEquals("chXb", groupOther.getPreference(1).getKey());
+            assertEquals("Other B", groupOther.getPreference(1).getTitle());
+        }
+
+        // Test that the removal of a channel works.
+        inGroupOther.getChannels().remove(0);
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(2, mGroupList.getPreferenceCount());
+            PreferenceGroup group1 = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group1", group1.getKey());
+            assertEquals(3, group1.getPreferenceCount());
+            assertNull(group1.getPreference(0).getKey());
+            assertEquals("All \"Group 1\" notifications", group1.getPreference(0).getTitle());
+            assertEquals("ch1a", group1.getPreference(1).getKey());
+            assertEquals("Channel 1A", group1.getPreference(1).getTitle());
+            assertEquals("ch1b", group1.getPreference(2).getKey());
+            assertEquals("Channel 1B - Renamed", group1.getPreference(2).getTitle());
+            PreferenceGroup groupOther = (PreferenceGroup) mGroupList.getPreference(1);
+            assertEquals("categories", groupOther.getKey());
+            assertEquals(1, groupOther.getPreferenceCount());
+            assertEquals("chXb", groupOther.getPreference(0).getKey());
+            assertEquals("Other B", groupOther.getPreference(0).getTitle());
+        }
+
+        // Test that we go back to the empty state when clearing all groups and channels.
+        inGroups.clear();
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            assertEquals("zeroCategories", mGroupList.getPreference(0).getKey());
+        }
+    }
+
+
+    @Test
+    @UiThreadTest
+    public void testUpdateFullList_groupBlockedChange() {
+        List<NotificationChannelGroup> inGroups = new ArrayList<>();
+        NotificationChannelGroup inGroup = new NotificationChannelGroup("group", "My Group");
+        inGroup.addChannel(new NotificationChannel("channelA", "Channel A", IMPORTANCE_DEFAULT));
+        inGroup.addChannel(new NotificationChannel("channelB", "Channel B", IMPORTANCE_NONE));
+        inGroups.add(inGroup);
+
+        // Test that the group is initially showing all preferences
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group", group.getKey());
+            assertEquals(3, group.getPreferenceCount());
+            SwitchPreference groupBlockPref = (SwitchPreference) group.getPreference(0);
+            assertNull(groupBlockPref.getKey());
+            assertEquals("All \"My Group\" notifications", groupBlockPref.getTitle());
+            assertTrue(groupBlockPref.isChecked());
+            MasterSwitchPreference channelAPref = (MasterSwitchPreference) group.getPreference(1);
+            assertEquals("channelA", channelAPref.getKey());
+            assertEquals("Channel A", channelAPref.getTitle());
+            assertEquals(Boolean.TRUE, channelAPref.getCheckedState());
+            MasterSwitchPreference channelBPref = (MasterSwitchPreference) group.getPreference(2);
+            assertEquals("channelB", channelBPref.getKey());
+            assertEquals("Channel B", channelBPref.getTitle());
+            assertEquals(Boolean.FALSE, channelBPref.getCheckedState());
+        }
+
+        // Test that when a group is blocked, the list removes its individual channel preferences
+        inGroup.setBlocked(true);
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group", group.getKey());
+            assertEquals(1, group.getPreferenceCount());
+            SwitchPreference groupBlockPref = (SwitchPreference) group.getPreference(0);
+            assertNull(groupBlockPref.getKey());
+            assertEquals("All \"My Group\" notifications", groupBlockPref.getTitle());
+            assertFalse(groupBlockPref.isChecked());
+        }
+
+        // Test that when a group is unblocked, the list adds its individual channel preferences
+        inGroup.setBlocked(false);
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group", group.getKey());
+            assertEquals(3, group.getPreferenceCount());
+            SwitchPreference groupBlockPref = (SwitchPreference) group.getPreference(0);
+            assertNull(groupBlockPref.getKey());
+            assertEquals("All \"My Group\" notifications", groupBlockPref.getTitle());
+            assertTrue(groupBlockPref.isChecked());
+            MasterSwitchPreference channelAPref = (MasterSwitchPreference) group.getPreference(1);
+            assertEquals("channelA", channelAPref.getKey());
+            assertEquals("Channel A", channelAPref.getTitle());
+            assertEquals(Boolean.TRUE, channelAPref.getCheckedState());
+            MasterSwitchPreference channelBPref = (MasterSwitchPreference) group.getPreference(2);
+            assertEquals("channelB", channelBPref.getKey());
+            assertEquals("Channel B", channelBPref.getTitle());
+            assertEquals(Boolean.FALSE, channelBPref.getCheckedState());
+        }
+    }
+
+    @Test
+    @UiThreadTest
+    public void testUpdateFullList_channelUpdates() {
+        List<NotificationChannelGroup> inGroups = new ArrayList<>();
+        NotificationChannelGroup inGroup = new NotificationChannelGroup("group", "Group");
+        NotificationChannel channelA =
+                new NotificationChannel("channelA", "Channel A", IMPORTANCE_HIGH);
+        NotificationChannel channelB =
+                new NotificationChannel("channelB", "Channel B", IMPORTANCE_NONE);
+        inGroup.addChannel(channelA);
+        inGroup.addChannel(channelB);
+        inGroups.add(inGroup);
+
+        NotificationsSentState sentA = new NotificationsSentState();
+        sentA.avgSentDaily = 2;
+        sentA.avgSentWeekly = 10;
+        NotificationsSentState sentB = new NotificationsSentState();
+        sentB.avgSentDaily = 0;
+        sentB.avgSentWeekly = 2;
+        mAppRow.sentByChannel.put("channelA", sentA);
+
+        // Test that the channels' properties are reflected in the preference
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group", group.getKey());
+            assertEquals(3, group.getPreferenceCount());
+            assertNull(group.getPreference(0).getKey());
+            assertEquals("All \"Group\" notifications", group.getPreference(0).getTitle());
+            MasterSwitchPreference channelAPref = (MasterSwitchPreference) group.getPreference(1);
+            assertEquals("channelA", channelAPref.getKey());
+            assertEquals("Channel A", channelAPref.getTitle());
+            assertEquals(Boolean.TRUE, channelAPref.getCheckedState());
+            assertEquals("~2 notifications per day", channelAPref.getSummary());
+            assertNotNull(channelAPref.getIcon());
+            MasterSwitchPreference channelBPref = (MasterSwitchPreference) group.getPreference(2);
+            assertEquals("channelB", channelBPref.getKey());
+            assertEquals("Channel B", channelBPref.getTitle());
+            assertEquals(Boolean.FALSE, channelBPref.getCheckedState());
+            assertNull(channelBPref.getSummary());
+            assertNull(channelBPref.getIcon());
+        }
+
+        channelA.setImportance(IMPORTANCE_NONE);
+        channelB.setImportance(IMPORTANCE_DEFAULT);
+
+        mAppRow.sentByChannel.remove("channelA");
+        mAppRow.sentByChannel.put("channelB", sentB);
+
+        // Test that changing the channels' properties correctly updates the preference
+        mController.updateFullList(mGroupList, inGroups);
+        {
+            assertEquals(1, mGroupList.getPreferenceCount());
+            PreferenceGroup group = (PreferenceGroup) mGroupList.getPreference(0);
+            assertEquals("group", group.getKey());
+            assertEquals(3, group.getPreferenceCount());
+            assertNull(group.getPreference(0).getKey());
+            assertEquals("All \"Group\" notifications", group.getPreference(0).getTitle());
+            MasterSwitchPreference channelAPref = (MasterSwitchPreference) group.getPreference(1);
+            assertEquals("channelA", channelAPref.getKey());
+            assertEquals("Channel A", channelAPref.getTitle());
+            assertEquals(Boolean.FALSE, channelAPref.getCheckedState());
+            assertNull(channelAPref.getSummary());
+            assertNull(channelAPref.getIcon());
+            MasterSwitchPreference channelBPref = (MasterSwitchPreference) group.getPreference(2);
+            assertEquals("channelB", channelBPref.getKey());
+            assertEquals("Channel B", channelBPref.getTitle());
+            assertEquals(Boolean.TRUE, channelBPref.getCheckedState());
+            assertEquals("~2 notifications per week", channelBPref.getSummary());
+            assertNotNull(channelBPref.getIcon());
+        }
+    }
+
+}