Merge "Fix a11y issues in the schedule editor" into main
diff --git a/src/com/android/settings/notification/modes/AbstractZenModePreferenceController.java b/src/com/android/settings/notification/modes/AbstractZenModePreferenceController.java
index c740847..c473456 100644
--- a/src/com/android/settings/notification/modes/AbstractZenModePreferenceController.java
+++ b/src/com/android/settings/notification/modes/AbstractZenModePreferenceController.java
@@ -25,8 +25,8 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 import androidx.preference.Preference;
-import androidx.preference.PreferenceScreen;
 
 import com.android.settingslib.core.AbstractPreferenceController;
 import com.android.settingslib.notification.modes.ZenMode;
@@ -92,29 +92,14 @@
         return true;
     }
 
-    // Called by parent Fragment onAttach, for any methods (such as isAvailable()) that need
-    // zen mode info before onStart. Most callers should use updateZenMode instead, which will
-    // do any further necessary propagation.
-    protected final void setZenMode(@NonNull ZenMode zenMode) {
+    /**
+     * Assigns the {@link ZenMode} of this controller, so that it can be used later from
+     * {@link #isAvailable()} and {@link #updateState(Preference)}.
+     */
+    final void setZenMode(@NonNull ZenMode zenMode) {
         mZenMode = zenMode;
     }
 
-    // Called by the parent Fragment onStart, which means it will happen before resume.
-    public void updateZenMode(@NonNull Preference preference, @NonNull ZenMode zenMode) {
-        mZenMode = zenMode;
-        updateState(preference);
-    }
-
-    @Override
-    public void displayPreference(PreferenceScreen screen) {
-        super.displayPreference(screen);
-        if (mZenMode != null) {
-            displayPreference(screen, mZenMode);
-        }
-    }
-
-    public void displayPreference(PreferenceScreen screen, @NonNull ZenMode zenMode) {}
-
     @Override
     public final void updateState(Preference preference) {
         super.updateState(preference);
@@ -167,4 +152,20 @@
             return mode;
         });
     }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    @Nullable
+    ZenMode getZenMode() {
+        return mZenMode;
+    }
+
+    /**
+     * Convenience method for tests. Assigns the {@link ZenMode} of this controller, and calls
+     * {@link #updateState(Preference)} immediately.
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+    final void updateZenMode(@NonNull Preference preference, @NonNull ZenMode zenMode) {
+        mZenMode = zenMode;
+        updateState(preference);
+    }
 }
diff --git a/src/com/android/settings/notification/modes/ManualDurationPreferenceController.java b/src/com/android/settings/notification/modes/ManualDurationPreferenceController.java
index 073f8ab..28aac63 100644
--- a/src/com/android/settings/notification/modes/ManualDurationPreferenceController.java
+++ b/src/com/android/settings/notification/modes/ManualDurationPreferenceController.java
@@ -18,6 +18,7 @@
 
 import android.content.Context;
 
+import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
 import androidx.preference.Preference;
 import androidx.preference.PreferenceScreen;
@@ -49,12 +50,12 @@
         return zenMode.isManualDnd();
     }
 
-    // Called by parent fragment onAttach().
+    // Called by parent fragment onStart().
     void registerSettingsObserver() {
         mSettingsObserver.register();
     }
 
-    // Called by parent fragment onDetach().
+    // Called by parent fragment onStop().
     void unregisterSettingsObserver() {
         mSettingsObserver.unregister();
     }
@@ -69,7 +70,7 @@
     }
 
     @Override
-    public void updateState(Preference preference, ZenMode unusedZenMode) {
+    public void updateState(Preference preference, @NonNull ZenMode unusedZenMode) {
         // This controller is a link between a Settings value (ZEN_DURATION) and the manual DND
         // mode. The status of the zen mode object itself doesn't affect the preference
         // value, as that comes from settings; that value from settings will determine the
diff --git a/src/com/android/settings/notification/modes/ZenModeEditNameIconFragmentBase.java b/src/com/android/settings/notification/modes/ZenModeEditNameIconFragmentBase.java
index d666254..96cbf91 100644
--- a/src/com/android/settings/notification/modes/ZenModeEditNameIconFragmentBase.java
+++ b/src/com/android/settings/notification/modes/ZenModeEditNameIconFragmentBase.java
@@ -21,14 +21,11 @@
 
 import android.content.Context;
 import android.os.Bundle;
-import android.util.Log;
 
 import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
-import androidx.preference.Preference;
-import androidx.preference.PreferenceScreen;
 
 import com.android.settings.R;
 import com.android.settings.dashboard.DashboardFragment;
@@ -39,7 +36,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 
-import java.util.Collection;
 import java.util.List;
 
 /**
@@ -79,7 +75,11 @@
                 ? icicle.getParcelable(MODE_KEY, ZenMode.class)
                 : onCreateInstantiateZenMode();
 
-        if (mZenMode == null) {
+        if (mZenMode != null) {
+            for (var controller : getZenPreferenceControllers()) {
+                controller.setZenMode(mZenMode);
+            }
+        } else {
             finish();
         }
     }
@@ -110,58 +110,32 @@
         );
     }
 
+    private Iterable<AbstractZenModePreferenceController> getZenPreferenceControllers() {
+        return getPreferenceControllers().stream()
+                .flatMap(List::stream)
+                .filter(AbstractZenModePreferenceController.class::isInstance)
+                .map(AbstractZenModePreferenceController.class::cast)
+                .toList();
+    }
+
     @VisibleForTesting(otherwise = VisibleForTesting.NONE)
     @Nullable
     ZenMode getZenMode() {
         return mZenMode;
     }
 
-    @Override
-    public void onStart() {
-        super.onStart();
-        updateControllers();
-    }
-
     @VisibleForTesting
     final void setModeName(String name) {
         checkNotNull(mZenMode).getRule().setName(Strings.nullToEmpty(name));
-        updateControllers(); // Updates confirmation button.
+        forceUpdatePreferences(); // Updates confirmation button.
     }
 
     @VisibleForTesting
     final void setModeIcon(@DrawableRes int iconResId) {
         checkNotNull(mZenMode).getRule().setIconResId(iconResId);
-        updateControllers(); // Updates icon at the top.
+        forceUpdatePreferences();  // Updates icon at the top.
     }
 
-    protected void updateControllers() {
-        PreferenceScreen screen = getPreferenceScreen();
-        Collection<List<AbstractPreferenceController>> controllers = getPreferenceControllers();
-        if (mZenMode == null || screen == null || controllers == null) {
-            return;
-        }
-        for (List<AbstractPreferenceController> list : controllers) {
-            for (AbstractPreferenceController controller : list) {
-                try {
-                    final String key = controller.getPreferenceKey();
-                    final Preference preference = screen.findPreference(key);
-                    if (preference != null) {
-                        AbstractZenModePreferenceController zenController =
-                                (AbstractZenModePreferenceController) controller;
-                        zenController.updateZenMode(preference, mZenMode);
-                    } else {
-                        Log.d(getLogTag(),
-                                String.format("Cannot find preference with key %s in Controller %s",
-                                        key, controller.getClass().getSimpleName()));
-                    }
-                    controller.displayPreference(screen);
-                } catch (ClassCastException e) {
-                    // Skip any controllers that aren't AbstractZenModePreferenceController.
-                    Log.d(getLogTag(), "Could not cast: " + controller.getClass().getSimpleName());
-                }
-            }
-        }
-    }
 
     @VisibleForTesting
     final void saveMode() {
diff --git a/src/com/android/settings/notification/modes/ZenModeFragment.java b/src/com/android/settings/notification/modes/ZenModeFragment.java
index 5aeb34d..1b7e344 100644
--- a/src/com/android/settings/notification/modes/ZenModeFragment.java
+++ b/src/com/android/settings/notification/modes/ZenModeFragment.java
@@ -80,14 +80,6 @@
     }
 
     @Override
-    public void onAttach(@NonNull Context context) {
-        super.onAttach(context);
-
-        // allow duration preference controller to listen for settings changes
-        use(ManualDurationPreferenceController.class).registerSettingsObserver();
-    }
-
-    @Override
     public void onStart() {
         super.onStart();
 
@@ -99,6 +91,9 @@
             mModeMenuProvider = new ModeMenuProvider(mode);
             activity.addMenuProvider(mModeMenuProvider);
         }
+
+        // allow duration preference controller to listen for settings changes
+        use(ManualDurationPreferenceController.class).registerSettingsObserver();
     }
 
     @Override
@@ -106,13 +101,8 @@
         if (getActivity() != null) {
             getActivity().removeMenuProvider(mModeMenuProvider);
         }
-        super.onStop();
-    }
-
-    @Override
-    public void onDetach() {
         use(ManualDurationPreferenceController.class).unregisterSettingsObserver();
-        super.onDetach();
+        super.onStop();
     }
 
     @Override
@@ -122,13 +112,13 @@
     }
 
     @Override
-    protected void updateZenModeState() {
+    protected void onUpdatedZenModeState() {
         // Because this fragment may be asked to finish by the delete menu but not be done doing
         // so yet, ignore any attempts to update info in that case.
         if (getActivity() != null && getActivity().isFinishing()) {
             return;
         }
-        super.updateZenModeState();
+        super.onUpdatedZenModeState();
     }
 
     private class ModeMenuProvider implements MenuProvider {
diff --git a/src/com/android/settings/notification/modes/ZenModeFragmentBase.java b/src/com/android/settings/notification/modes/ZenModeFragmentBase.java
index f461fc3..c63b3a8 100644
--- a/src/com/android/settings/notification/modes/ZenModeFragmentBase.java
+++ b/src/com/android/settings/notification/modes/ZenModeFragmentBase.java
@@ -18,24 +18,18 @@
 
 import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID;
 
-import android.content.Context;
 import android.os.Bundle;
 import android.util.Log;
 import android.widget.Toast;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.preference.Preference;
-import androidx.preference.PreferenceScreen;
+import androidx.lifecycle.Lifecycle;
 
 import com.android.settings.R;
-import com.android.settingslib.core.AbstractPreferenceController;
 import com.android.settingslib.notification.modes.ZenMode;
 
-import com.google.common.base.Preconditions;
-
 import java.util.List;
-import java.util.function.Consumer;
 
 /**
  * Base class for Settings pages used to configure individual modes.
@@ -43,13 +37,27 @@
 abstract class ZenModeFragmentBase extends ZenModesFragmentBase {
     static final String TAG = "ZenModeSettings";
 
-    @Nullable  // only until reloadMode() is called
-    private ZenMode mZenMode;
+    @Nullable private ZenMode mZenMode;
+    @Nullable private ZenMode mModeOnLastControllerUpdate;
 
     @Override
-    public void onAttach(@NonNull Context context) {
-        super.onAttach(context);
+    public void onCreate(Bundle icicle) {
+        mZenMode = loadModeFromArguments();
+        if (mZenMode != null) {
+            // Propagate mode info through to controllers. Must be done before super.onCreate(),
+            // because that one calls AbstractPreferenceController.isAvailable().
+            for (var controller : getZenPreferenceControllers()) {
+                controller.setZenMode(mZenMode);
+            }
+        } else {
+            toastAndFinish();
+        }
 
+        super.onCreate(icicle);
+    }
+
+    @Nullable
+    private ZenMode loadModeFromArguments() {
         String id = null;
         if (getActivity() != null && getActivity().getIntent() != null) {
             id = getActivity().getIntent().getStringExtra(EXTRA_AUTOMATIC_ZEN_RULE_ID);
@@ -60,93 +68,65 @@
         }
         if (id == null) {
             Log.d(TAG, "No id provided");
-            toastAndFinish();
-            return;
+            return null;
         }
-        if (!reloadMode(id)) {
-            Log.d(TAG, "Mode id " + id + " not found");
-            toastAndFinish();
-            return;
+
+        ZenMode mode = mBackend.getMode(id);
+        if (mode == null) {
+            Log.d(TAG, "Mode with id " + id + " not found");
+            return null;
         }
-        if (mZenMode != null) {
-            // Propagate mode info through to controllers.
-            for (List<AbstractPreferenceController> list : getPreferenceControllers()) {
-                try {
-                    for (AbstractPreferenceController controller : list) {
-                        // mZenMode guaranteed non-null from reloadMode() above
-                        ((AbstractZenModePreferenceController) controller).setZenMode(mZenMode);
-                    }
-                } catch (ClassCastException e) {
-                    // ignore controllers that aren't AbstractZenModePreferenceController
-                }
-            }
-        }
+        return mode;
     }
 
-    /**
-     * Refresh stored ZenMode data.
-     * @param id the mode ID
-     * @return whether we successfully got mode data from the backend.
-     */
-    private boolean reloadMode(String id) {
-        mZenMode = mBackend.getMode(id);
-        if (mZenMode == null) {
-            return false;
-        }
-        return true;
+    private Iterable<AbstractZenModePreferenceController> getZenPreferenceControllers() {
+        return getPreferenceControllers().stream()
+                .flatMap(List::stream)
+                .filter(AbstractZenModePreferenceController.class::isInstance)
+                .map(AbstractZenModePreferenceController.class::cast)
+                .toList();
     }
 
-    /**
-     * Refresh ZenMode data any time the system's zen mode state changes (either the zen mode value
-     * itself, or the config), and also (once updated) update the info for all controllers.
-     */
     @Override
-    protected void updateZenModeState() {
+    protected void onUpdatedZenModeState() {
         if (mZenMode == null) {
-            // This shouldn't happen, but guard against it in case
+            Log.wtf(TAG, "mZenMode is null in onUpdatedZenModeState");
             toastAndFinish();
             return;
         }
+
         String id = mZenMode.getId();
-        if (!reloadMode(id)) {
+        ZenMode mode = mBackend.getMode(id);
+        if (mode == null) {
             Log.d(TAG, "Mode id=" + id + " not found");
             toastAndFinish();
             return;
         }
-        updateControllers();
+
+        mZenMode = mode;
+        maybeUpdateControllersState(mode);
     }
 
-    private void updateControllers() {
-        if (getPreferenceControllers() == null || mZenMode == null) {
-            return;
+    /**
+     * Updates all {@link AbstractZenModePreferenceController} based on the loaded mode info.
+     * For each controller, {@link AbstractZenModePreferenceController#setZenMode} will be called.
+     * Then, {@link AbstractZenModePreferenceController#updateState} will be called as well, unless
+     * we determine it's not necessary (for example, if we know that {@code DashboardFragment} will
+     * do it soon).
+     */
+    private void maybeUpdateControllersState(@NonNull ZenMode zenMode) {
+        boolean needsFullUpdate =
+                getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)
+                && (mModeOnLastControllerUpdate == null
+                        || !mModeOnLastControllerUpdate.equals(zenMode));
+        mModeOnLastControllerUpdate = zenMode.copy();
+
+        for (var controller : getZenPreferenceControllers()) {
+            controller.setZenMode(zenMode);
         }
 
-        final PreferenceScreen screen = getPreferenceScreen();
-        if (screen == null) {
-            Log.d(TAG, "PreferenceScreen not found");
-            return;
-        }
-        for (List<AbstractPreferenceController> list : getPreferenceControllers()) {
-            for (AbstractPreferenceController controller : list) {
-                try {
-                    // Find preference associated with controller
-                    final String key = controller.getPreferenceKey();
-                    final Preference preference = screen.findPreference(key);
-                    if (preference != null) {
-                        AbstractZenModePreferenceController zenController =
-                                (AbstractZenModePreferenceController) controller;
-                        zenController.updateZenMode(preference, mZenMode);
-                    } else {
-                        Log.d(TAG,
-                                String.format("Cannot find preference with key %s in Controller %s",
-                                        key, controller.getClass().getSimpleName()));
-                    }
-                    controller.displayPreference(screen);
-                } catch (ClassCastException e) {
-                    // Skip any controllers that aren't AbstractZenModePreferenceController.
-                    Log.d(TAG, "Could not cast: " + controller.getClass().getSimpleName());
-                }
-            }
+        if (needsFullUpdate) {
+            forceUpdatePreferences();
         }
     }
 
@@ -163,16 +143,4 @@
     public ZenMode getMode() {
         return mZenMode;
     }
-
-    protected final boolean saveMode(Consumer<ZenMode> updater) {
-        Preconditions.checkState(mBackend != null);
-        ZenMode mode = mZenMode;
-        if (mode == null) {
-            Log.wtf(TAG, "Cannot save mode, it hasn't been loaded (" + getClass() + ")");
-            return false;
-        }
-        updater.accept(mode);
-        mBackend.updateMode(mode);
-        return true;
-    }
 }
diff --git a/src/com/android/settings/notification/modes/ZenModeTriggerUpdatePreferenceController.java b/src/com/android/settings/notification/modes/ZenModeTriggerUpdatePreferenceController.java
index 043a38c..3ee6d94 100644
--- a/src/com/android/settings/notification/modes/ZenModeTriggerUpdatePreferenceController.java
+++ b/src/com/android/settings/notification/modes/ZenModeTriggerUpdatePreferenceController.java
@@ -37,7 +37,6 @@
 import androidx.annotation.StringRes;
 import androidx.annotation.VisibleForTesting;
 import androidx.preference.Preference;
-import androidx.preference.PreferenceScreen;
 
 import com.android.settings.R;
 import com.android.settingslib.PrimarySwitchPreference;
@@ -78,13 +77,6 @@
     }
 
     @Override
-    public void displayPreference(PreferenceScreen screen, @NonNull ZenMode zenMode) {
-        // Preload approved components, but only for the package that owns the rule (since it's the
-        // only package that can have a valid configurationActivity).
-        mServiceListing.loadApprovedComponents(zenMode.getRule().getPackageName());
-    }
-
-    @Override
     void updateState(Preference preference, @NonNull ZenMode zenMode) {
         if (!isAvailable(zenMode)) {
             return;
@@ -137,6 +129,7 @@
     @SuppressLint("SwitchIntDef")
     private void setUpForAppTrigger(Preference preference, ZenMode mode) {
         // App-owned mode may have triggerDescription, configurationActivity, or both/neither.
+        mServiceListing.loadApprovedComponents(mode.getRule().getPackageName());
         Intent configurationIntent =
                 mConfigurationActivityHelper.getConfigurationActivityIntentForMode(
                         mode, mServiceListing::findService);
diff --git a/src/com/android/settings/notification/modes/ZenModesFragmentBase.java b/src/com/android/settings/notification/modes/ZenModesFragmentBase.java
index 0bc0617..652415b 100644
--- a/src/com/android/settings/notification/modes/ZenModesFragmentBase.java
+++ b/src/com/android/settings/notification/modes/ZenModesFragmentBase.java
@@ -16,14 +16,11 @@
 
 package com.android.settings.notification.modes;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import android.annotation.NonNull;
-import android.annotation.Nullable;
 import android.content.Context;
-import android.database.ContentObserver;
-import android.net.Uri;
-import android.os.Handler;
 import android.os.UserManager;
-import android.provider.Settings.Global;
 import android.util.Log;
 
 import androidx.annotation.VisibleForTesting;
@@ -38,17 +35,10 @@
     protected static final String TAG = "ZenModesSettings";
     protected static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
 
-    private final Handler mHandler = new Handler();
-    private final SettingsObserver mSettingsObserver = new SettingsObserver();
-
     protected Context mContext;
-
     protected ZenModesBackend mBackend;
     protected ZenHelperBackend mHelperBackend;
-
-    // Individual pages must implement this method based on what they should do when
-    // the device's zen mode state changes.
-    protected abstract void updateZenModeState();
+    private ZenSettingsObserver mSettingsObserver;
 
     ZenModesFragmentBase() {
         super(UserManager.DISALLOW_ADJUST_VOLUME);
@@ -69,8 +59,8 @@
         mContext = context;
         mBackend = ZenModesBackend.getInstance(context);
         mHelperBackend = ZenHelperBackend.getInstance(context);
+        mSettingsObserver = new ZenSettingsObserver(context, this::onUpdatedZenModeState);
         super.onAttach(context);
-        mSettingsObserver.register();
     }
 
     @Override
@@ -83,45 +73,20 @@
                 finish();
             }
         }
+
+        onUpdatedZenModeState(); // Maybe, while we weren't observing.
+        checkNotNull(mSettingsObserver).register();
     }
 
+    /**
+     * Called by this fragment when we know or suspect that Zen Modes data or state has changed.
+     * Individual pages must implement this method to refresh whatever they're displaying.
+     */
+    protected abstract void onUpdatedZenModeState();
+
     @Override
-    public void onResume() {
-        super.onResume();
-        updateZenModeState();
-    }
-
-    @Override
-    public void onDetach() {
-        super.onDetach();
-        mSettingsObserver.unregister();
-    }
-
-    private final class SettingsObserver extends ContentObserver {
-        private static final Uri ZEN_MODE_URI = Global.getUriFor(Global.ZEN_MODE);
-        private static final Uri ZEN_MODE_CONFIG_ETAG_URI = Global.getUriFor(
-                Global.ZEN_MODE_CONFIG_ETAG);
-
-        private SettingsObserver() {
-            super(mHandler);
-        }
-
-        public void register() {
-            getContentResolver().registerContentObserver(ZEN_MODE_URI, false, this);
-            getContentResolver().registerContentObserver(ZEN_MODE_CONFIG_ETAG_URI, false, this);
-        }
-
-        public void unregister() {
-            getContentResolver().unregisterContentObserver(this);
-        }
-
-        @Override
-        public void onChange(boolean selfChange, @Nullable Uri uri) {
-            super.onChange(selfChange, uri);
-            // Shouldn't have any other URIs trigger this method, but check just in case.
-            if (ZEN_MODE_URI.equals(uri) || ZEN_MODE_CONFIG_ETAG_URI.equals(uri)) {
-                updateZenModeState();
-            }
-        }
+    public void onStop() {
+        checkNotNull(mSettingsObserver).unregister();
+        super.onStop();
     }
 }
diff --git a/src/com/android/settings/notification/modes/ZenModesListFragment.java b/src/com/android/settings/notification/modes/ZenModesListFragment.java
index be458b3..a45ca17 100644
--- a/src/com/android/settings/notification/modes/ZenModesListFragment.java
+++ b/src/com/android/settings/notification/modes/ZenModesListFragment.java
@@ -65,7 +65,7 @@
     }
 
     @Override
-    protected void updateZenModeState() {
+    protected void onUpdatedZenModeState() {
         // TODO: b/322373473 -- update any overall description of modes state here if necessary.
         // Note the preferences linking to individual rules do not need to be updated, as
         // updateState() is called on all preference controllers whenever the page is resumed.
diff --git a/src/com/android/settings/notification/modes/ZenModesListPreferenceController.java b/src/com/android/settings/notification/modes/ZenModesListPreferenceController.java
index ba12b9a..12b7278 100644
--- a/src/com/android/settings/notification/modes/ZenModesListPreferenceController.java
+++ b/src/com/android/settings/notification/modes/ZenModesListPreferenceController.java
@@ -38,7 +38,8 @@
  * containing links to each individual mode. This is a central controller that populates and updates
  * all the preferences that then lead to a mode configuration page.
  */
-class ZenModesListPreferenceController extends BasePreferenceController {
+class ZenModesListPreferenceController extends BasePreferenceController
+        implements BasePreferenceController.UiBlocker {
     protected static final String KEY = "zen_modes_list";
 
     protected ZenModesBackend mBackend;
@@ -49,11 +50,6 @@
     }
 
     @Override
-    public String getPreferenceKey() {
-        return KEY;
-    }
-
-    @Override
     @AvailabilityStatus
     public int getAvailabilityStatus() {
         return Flags.modesUi() ? AVAILABLE_UNSEARCHABLE : UNSUPPORTED_ON_DEVICE;
@@ -97,6 +93,8 @@
         for (String key : originalPreferences.keySet()) {
             category.removePreferenceRecursively(key);
         }
+
+        setUiBlockerFinished(true);
     }
 
     // Provide search data for the modes, which will allow users to reach the modes page if they
diff --git a/src/com/android/settings/notification/modes/ZenSettingsObserver.java b/src/com/android/settings/notification/modes/ZenSettingsObserver.java
new file mode 100644
index 0000000..a853646
--- /dev/null
+++ b/src/com/android/settings/notification/modes/ZenSettingsObserver.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 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.modes;
+
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.provider.Settings;
+
+import androidx.annotation.Nullable;
+
+class ZenSettingsObserver extends ContentObserver {
+    private static final Uri ZEN_MODE_URI = Settings.Global.getUriFor(Settings.Global.ZEN_MODE);
+    private static final Uri ZEN_MODE_CONFIG_ETAG_URI = Settings.Global.getUriFor(
+            Settings.Global.ZEN_MODE_CONFIG_ETAG);
+
+    private final Context mContext;
+    @Nullable private Runnable mCallback;
+
+    ZenSettingsObserver(Context context) {
+        this(context, null);
+    }
+
+    ZenSettingsObserver(Context context, @Nullable Runnable callback) {
+        super(context.getMainExecutor(), 0);
+        mContext = context;
+        setOnChangeListener(callback);
+    }
+
+    void register() {
+        mContext.getContentResolver().registerContentObserver(ZEN_MODE_URI, false, this);
+        mContext.getContentResolver().registerContentObserver(ZEN_MODE_CONFIG_ETAG_URI, false,
+                this);
+    }
+
+    void unregister() {
+        mContext.getContentResolver().unregisterContentObserver(this);
+    }
+
+    void setOnChangeListener(@Nullable Runnable callback) {
+        mCallback = callback;
+    }
+
+    @Override
+    public void onChange(boolean selfChange, @Nullable Uri uri) {
+        super.onChange(selfChange, uri);
+        // Shouldn't have any other URIs trigger this method, but check just in case.
+        if (ZEN_MODE_URI.equals(uri) || ZEN_MODE_CONFIG_ETAG_URI.equals(uri)) {
+            if (mCallback != null) {
+                mCallback.run();
+            }
+        }
+    }
+}
diff --git a/tests/robotests/res/xml/modes_fake_settings.xml b/tests/robotests/res/xml/modes_fake_settings.xml
new file mode 100644
index 0000000..a5602dc
--- /dev/null
+++ b/tests/robotests/res/xml/modes_fake_settings.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2024 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.
+  -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
+    <Preference android:key="pref_id" />
+    <Preference android:key="pref_name" />
+    <Preference android:key="pref_enabled" />
+</PreferenceScreen>
\ No newline at end of file
diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeFragmentBaseTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeFragmentBaseTest.java
new file mode 100644
index 0000000..21f19ff
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeFragmentBaseTest.java
@@ -0,0 +1,364 @@
+/*
+ * Copyright (C) 2024 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.modes;
+
+import static android.provider.Settings.EXTRA_AUTOMATIC_ZEN_RULE_ID;
+
+import static com.android.settings.notification.modes.CharSequenceTruth.assertThat;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Flags;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Bundle;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.provider.Settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.testing.FragmentScenario;
+import androidx.lifecycle.Lifecycle.State;
+import androidx.preference.Preference;
+
+import com.android.settings.R;
+import com.android.settingslib.core.AbstractPreferenceController;
+import com.android.settingslib.notification.modes.TestModeBuilder;
+import com.android.settingslib.notification.modes.ZenMode;
+import com.android.settingslib.notification.modes.ZenModesBackend;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.shadows.ShadowContentResolver;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@EnableFlags(Flags.FLAG_MODES_UI)
+public class ZenModeFragmentBaseTest {
+
+    private static final Uri SETTINGS_URI = Settings.Global.getUriFor(
+            Settings.Global.ZEN_MODE_CONFIG_ETAG);
+
+    @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+    @Mock ZenModesBackend mBackend;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void fragment_noArguments_finishes() {
+        when(mBackend.getMode(any())).thenReturn(TestModeBuilder.EXAMPLE);
+
+        FragmentScenario<TestableFragment> scenario = createScenario(null);
+
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            assertThat(fragment.requireActivity().isFinishing()).isTrue();
+        });
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_modeDoesNotExist_finishes() {
+        when(mBackend.getMode(any())).thenReturn(null);
+
+        FragmentScenario<TestableFragment> scenario = createScenario("mode_id");
+
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            assertThat(fragment.requireActivity().isFinishing()).isTrue();
+        });
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_validMode_updatesControllersOnce() {
+        ZenMode mode = new TestModeBuilder().setId("mode_id").build();
+        when(mBackend.getMode("mode_id")).thenReturn(mode);
+
+        FragmentScenario<TestableFragment> scenario = createScenario("mode_id");
+
+        scenario.moveToState(State.CREATED).onFragment(fragment -> {
+            assertThat(fragment.mShowsId.getZenMode()).isEqualTo(mode);
+            assertThat(fragment.mShowsId.isAvailable()).isTrue();
+            assertThat(fragment.mAvailableIfEnabled.getZenMode()).isEqualTo(mode);
+            assertThat(fragment.mAvailableIfEnabled.isAvailable()).isTrue();
+
+            verify(fragment.mShowsId, never()).updateState(any(), any());
+            verify(fragment.mAvailableIfEnabled, never()).updateState(any(), any());
+        });
+
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            Preference preferenceOne = fragment.requirePreference("pref_id");
+            assertThat(preferenceOne.getSummary()).isEqualTo("Id is mode_id");
+
+            verify(fragment.mShowsId).updateState(any(), eq(mode));
+            verify(fragment.mAvailableIfEnabled).updateState(any(), eq(mode));
+        });
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_onStartToOnStop_hasRegisteredContentObserver() {
+        when(mBackend.getMode(any())).thenReturn(TestModeBuilder.EXAMPLE);
+        FragmentScenario<TestableFragment> scenario = createScenario("id");
+
+        scenario.moveToState(State.CREATED).onFragment(fragment ->
+                assertThat(getSettingsContentObservers(fragment)).isEmpty());
+
+        scenario.moveToState(State.STARTED).onFragment(fragment ->
+                assertThat(getSettingsContentObservers(fragment)).hasSize(1));
+
+        scenario.moveToState(State.RESUMED).onFragment(fragment ->
+                assertThat(getSettingsContentObservers(fragment)).hasSize(1));
+
+        scenario.moveToState(State.STARTED).onFragment(fragment ->
+                assertThat(getSettingsContentObservers(fragment)).hasSize(1));
+
+        scenario.moveToState(State.CREATED).onFragment(fragment ->
+                assertThat(getSettingsContentObservers(fragment)).isEmpty());
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_onModeUpdatedWithDifferences_updatesControllers() {
+        ZenMode originalMode = new TestModeBuilder().setId("id").setName("Original").build();
+        when(mBackend.getMode("id")).thenReturn(originalMode);
+
+        FragmentScenario<TestableFragment> scenario = createScenario("id");
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            Preference preference = fragment.requirePreference("pref_name");
+            assertThat(preference.getSummary()).isEqualTo("Original");
+            verify(fragment.mShowsName, times(1)).updateState(any(), eq(originalMode));
+
+            // Now, we get a message saying something changed.
+            ZenMode updatedMode = new TestModeBuilder().setId("id").setName("Updated").build();
+            when(mBackend.getMode("id")).thenReturn(updatedMode);
+            getSettingsContentObservers(fragment).stream().findFirst().get()
+                    .dispatchChange(false, SETTINGS_URI);
+            ShadowLooper.idleMainLooper();
+
+            // The screen was updated, and only updated once.
+            assertThat(preference.getSummary()).isEqualTo("Updated");
+            verify(fragment.mShowsName, times(1)).updateState(any(), eq(updatedMode));
+        });
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_onModeUpdatedWithoutDifferences_setsModeInControllersButNothingElse() {
+        ZenMode originalMode = new TestModeBuilder().setId("id").setName("Original").build();
+        when(mBackend.getMode("id")).thenReturn(originalMode);
+
+        FragmentScenario<TestableFragment> scenario = createScenario("id");
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            Preference preference = fragment.requirePreference("pref_name");
+            assertThat(preference.getSummary()).isEqualTo("Original");
+            verify(fragment.mShowsName, times(1)).updateState(any(), eq(originalMode));
+
+            // Now, we get a message saying something changed, but it was for a different mode.
+            ZenMode notUpdatedMode = new TestModeBuilder(originalMode).build();
+            when(mBackend.getMode("id")).thenReturn(notUpdatedMode);
+            getSettingsContentObservers(fragment).stream().findFirst().get()
+                    .dispatchChange(false, SETTINGS_URI);
+            ShadowLooper.idleMainLooper();
+
+            // The mode instance was updated, but updateState() was not called.
+            assertThat(preference.getSummary()).isEqualTo("Original");
+            assertThat(fragment.mShowsName.getZenMode()).isSameInstanceAs(notUpdatedMode);
+            verify(fragment.mShowsName, never()).updateState(any(), same(notUpdatedMode));
+        });
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_onFragmentRestart_reloadsMode() {
+        ZenMode originalMode = new TestModeBuilder().setId("id").setName("Original").build();
+        when(mBackend.getMode("id")).thenReturn(originalMode);
+
+        FragmentScenario<TestableFragment> scenario = createScenario("id");
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            Preference preference = fragment.requirePreference("pref_name");
+            assertThat(preference.getSummary()).isEqualTo("Original");
+            verify(fragment.mShowsName, times(1)).updateState(any(), eq(originalMode));
+        });
+
+        ZenMode updatedMode = new TestModeBuilder().setId("id").setName("Updated").build();
+        when(mBackend.getMode("id")).thenReturn(updatedMode);
+
+        scenario.moveToState(State.CREATED).moveToState(State.RESUMED).onFragment(fragment -> {
+            Preference preference = fragment.requirePreference("pref_name");
+            assertThat(preference.getSummary()).isEqualTo("Updated");
+            assertThat(fragment.mShowsName.getZenMode()).isSameInstanceAs(updatedMode);
+        });
+
+        scenario.close();
+    }
+
+    @Test
+    public void fragment_onModeDeleted_finishes() {
+        ZenMode originalMode = new TestModeBuilder().setId("mode_id").build();
+        when(mBackend.getMode("mode_id")).thenReturn(originalMode);
+
+        FragmentScenario<TestableFragment> scenario = createScenario("mode_id");
+        scenario.moveToState(State.RESUMED).onFragment(fragment -> {
+            assertThat(fragment.requireActivity().isFinishing()).isFalse();
+
+            // Now it's no longer there...
+            when(mBackend.getMode(any())).thenReturn(null);
+            getSettingsContentObservers(fragment).stream().findFirst().get()
+                    .dispatchChange(false, SETTINGS_URI);
+            ShadowLooper.idleMainLooper();
+
+            assertThat(fragment.requireActivity().isFinishing()).isTrue();
+        });
+
+        scenario.close();
+    }
+
+    private FragmentScenario<TestableFragment> createScenario(@Nullable String modeId) {
+        Bundle fragmentArgs = null;
+        if (modeId != null) {
+            fragmentArgs = new Bundle();
+            fragmentArgs.putString(EXTRA_AUTOMATIC_ZEN_RULE_ID, modeId);
+        }
+
+        FragmentScenario<TestableFragment> scenario = FragmentScenario.launch(
+                TestableFragment.class, fragmentArgs, 0, State.INITIALIZED);
+
+        scenario.onFragment(fragment -> {
+            fragment.setBackend(mBackend); // Before onCreate().
+        });
+
+        return scenario;
+    }
+
+    public static class TestableFragment extends ZenModeFragmentBase {
+
+        private ShowsIdPreferenceController mShowsId;
+        private ShowsNamePreferenceController mShowsName;
+        private AvailableIfEnabledPreferenceController mAvailableIfEnabled;
+
+        @Override
+        protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
+            mShowsId = spy(new ShowsIdPreferenceController(context, "pref_id"));
+            mShowsName = spy(new ShowsNamePreferenceController(context, "pref_name"));
+            mAvailableIfEnabled = spy(
+                    new AvailableIfEnabledPreferenceController(context, "pref_enabled"));
+            return ImmutableList.of(mShowsId, mShowsName, mAvailableIfEnabled);
+        }
+
+        @NonNull
+        Preference requirePreference(String key) {
+            Preference preference = getPreferenceScreen().findPreference(key);
+            checkNotNull(preference, "Didn't find preference with key " + key);
+            return preference;
+        }
+
+        ShadowContentResolver getShadowContentResolver() {
+            return shadowOf(requireActivity().getContentResolver());
+        }
+
+        @Override
+        protected int getPreferenceScreenResId() {
+            return R.xml.modes_fake_settings;
+        }
+
+        @Override
+        public int getMetricsCategory() {
+            return 0;
+        }
+    }
+
+    private static class ShowsIdPreferenceController extends AbstractZenModePreferenceController {
+
+        ShowsIdPreferenceController(@NonNull Context context, @NonNull String key) {
+            super(context, key);
+        }
+
+        @Override
+        void updateState(Preference preference, @NonNull ZenMode zenMode) {
+            preference.setSummary("Id is " + zenMode.getId());
+        }
+    }
+
+    private static class ShowsNamePreferenceController extends AbstractZenModePreferenceController {
+
+        ShowsNamePreferenceController(@NonNull Context context, @NonNull String key) {
+            super(context, key);
+        }
+
+        @Override
+        void updateState(Preference preference, @NonNull ZenMode zenMode) {
+            preference.setSummary(zenMode.getName());
+        }
+    }
+
+    private static class AvailableIfEnabledPreferenceController extends
+            AbstractZenModePreferenceController {
+
+        AvailableIfEnabledPreferenceController(@NonNull Context context, @NonNull String key) {
+            super(context, key);
+        }
+
+        @Override
+        public boolean isAvailable(@NonNull ZenMode zenMode) {
+            return zenMode.isEnabled();
+        }
+
+        @Override
+        void updateState(Preference preference, @NonNull ZenMode zenMode) {
+            preference.setSummary("Enabled is " + zenMode.isEnabled());
+        }
+    }
+
+    private ImmutableList<ContentObserver> getSettingsContentObservers(Fragment fragment) {
+        return ImmutableList.copyOf(
+                shadowOf(fragment.requireActivity().getContentResolver())
+                        .getContentObservers(SETTINGS_URI));
+    }
+}