Merge "If bubble bar is collapsing, hide the IME" into main
diff --git a/apct-tests/perftests/core/src/android/libcore/regression/CipherPerfTest.java b/apct-tests/perftests/core/src/android/libcore/regression/CipherPerfTest.java
index 238c028..9eac108 100644
--- a/apct-tests/perftests/core/src/android/libcore/regression/CipherPerfTest.java
+++ b/apct-tests/perftests/core/src/android/libcore/regression/CipherPerfTest.java
@@ -18,6 +18,7 @@
 
 import android.perftests.utils.BenchmarkState;
 import android.perftests.utils.PerfStatusReporter;
+import android.util.Log;
 
 import androidx.test.filters.LargeTest;
 
@@ -47,6 +48,8 @@
 @RunWith(JUnitParamsRunner.class)
 @LargeTest
 public class CipherPerfTest {
+    private static final String TAG = "android.libcore.regression.CipherPerfTest";
+
     @Rule public PerfStatusReporter mPerfStatusReporter = new PerfStatusReporter();
 
     public static Collection getCases() {
@@ -71,6 +74,10 @@
                     }
                     for (int keySize : keySizes) {
                         for (int inputSize : inputSizes) {
+                            Log.i(TAG,
+                                    "param[" + params.size() + "] = " + mode.name() + ", "
+                                            + padding.name() + ", " + keySize + ", " + inputSize
+                                            + ", " + implementation.name());
                             params.add(
                                     new Object[] {
                                         mode, padding, keySize, inputSize, implementation
diff --git a/apex/jobscheduler/framework/java/android/app/job/JobParameters.java b/apex/jobscheduler/framework/java/android/app/job/JobParameters.java
index 52a761f..31d2ecd 100644
--- a/apex/jobscheduler/framework/java/android/app/job/JobParameters.java
+++ b/apex/jobscheduler/framework/java/android/app/job/JobParameters.java
@@ -34,6 +34,7 @@
 import android.os.Parcelable;
 import android.os.PersistableBundle;
 import android.os.RemoteException;
+import android.os.Process;
 import android.system.SystemCleaner;
 import android.util.Log;
 
@@ -638,6 +639,12 @@
      * @hide
      */
     public void enableCleaner() {
+        // JobParameters objects are passed by reference in local Binder
+        // transactions for clients running as SYSTEM. The life cycle of the
+        // JobParameters objects are no longer controlled by the client.
+        if (Process.myUid() == Process.SYSTEM_UID) {
+            return;
+        }
         if (mJobCleanupCallback == null) {
             initCleaner(new JobCleanupCallback(IJobCallback.Stub.asInterface(callback), jobId));
         }
diff --git a/cmds/uinput/README.md b/cmds/uinput/README.md
index 5d3f12e..6138388 100644
--- a/cmds/uinput/README.md
+++ b/cmds/uinput/README.md
@@ -83,6 +83,11 @@
 Due to the sequential nature in which this is parsed, the `type` field must be specified before
 the `data` field in this JSON Object.
 
+Every `register` command will need a `"UI_SET_EVBIT"` configuration entry that lists what types of
+axes it declares. This entry should be the first in the list. For example, if the uinput device has
+`"UI_SET_KEYBIT"` and `"UI_SET_RELBIT"` configuration entries, it will also need a `"UI_SET_EVBIT"`
+entry with data of `["EV_KEY", "EV_REL"]` or the other configuration entries will be ignored.
+
 `ff_effects_max` must be provided if `UI_SET_FFBIT` is used in `configuration`.
 
 `abs_info` fields are provided to set the device axes information. It is an array of below objects:
diff --git a/core/api/current.txt b/core/api/current.txt
index 67b3280..2bb08fc 100644
--- a/core/api/current.txt
+++ b/core/api/current.txt
@@ -6882,19 +6882,19 @@
   public static final class Notification.ProgressStyle.Segment {
     ctor public Notification.ProgressStyle.Segment(int);
     method @ColorInt public int getColor();
+    method public int getId();
     method public int getLength();
-    method public int getStableId();
     method @NonNull public android.app.Notification.ProgressStyle.Segment setColor(@ColorInt int);
-    method @NonNull public android.app.Notification.ProgressStyle.Segment setStableId(int);
+    method @NonNull public android.app.Notification.ProgressStyle.Segment setId(int);
   }
 
   public static final class Notification.ProgressStyle.Step {
     ctor public Notification.ProgressStyle.Step(int);
     method @ColorInt public int getColor();
+    method public int getId();
     method public int getPosition();
-    method public int getStableId();
     method @NonNull public android.app.Notification.ProgressStyle.Step setColor(@ColorInt int);
-    method @NonNull public android.app.Notification.ProgressStyle.Step setStableId(int);
+    method @NonNull public android.app.Notification.ProgressStyle.Step setId(int);
   }
 
   public abstract static class Notification.Style {
@@ -8787,7 +8787,7 @@
   @FlaggedApi("android.app.appfunctions.flags.enable_app_function_manager") public abstract class AppFunctionService extends android.app.Service {
     ctor public AppFunctionService();
     method @NonNull public final android.os.IBinder onBind(@Nullable android.content.Intent);
-    method @Deprecated @MainThread public abstract void onExecuteFunction(@NonNull android.app.appfunctions.ExecuteAppFunctionRequest, @NonNull java.util.function.Consumer<android.app.appfunctions.ExecuteAppFunctionResponse>);
+    method @Deprecated @MainThread public void onExecuteFunction(@NonNull android.app.appfunctions.ExecuteAppFunctionRequest, @NonNull java.util.function.Consumer<android.app.appfunctions.ExecuteAppFunctionResponse>);
     method @MainThread public void onExecuteFunction(@NonNull android.app.appfunctions.ExecuteAppFunctionRequest, @NonNull android.os.CancellationSignal, @NonNull java.util.function.Consumer<android.app.appfunctions.ExecuteAppFunctionResponse>);
     field @NonNull public static final String SERVICE_INTERFACE = "android.app.appfunctions.AppFunctionService";
   }
@@ -8822,13 +8822,12 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.app.appfunctions.ExecuteAppFunctionResponse> CREATOR;
     field public static final String PROPERTY_RETURN_VALUE = "returnValue";
     field public static final int RESULT_APP_UNKNOWN_ERROR = 2; // 0x2
-    field public static final int RESULT_CANCELLED = 7; // 0x7
+    field public static final int RESULT_CANCELLED = 6; // 0x6
     field public static final int RESULT_DENIED = 1; // 0x1
-    field public static final int RESULT_DISABLED = 6; // 0x6
+    field public static final int RESULT_DISABLED = 5; // 0x5
     field public static final int RESULT_INTERNAL_ERROR = 3; // 0x3
     field public static final int RESULT_INVALID_ARGUMENT = 4; // 0x4
     field public static final int RESULT_OK = 0; // 0x0
-    field public static final int RESULT_TIMED_OUT = 5; // 0x5
   }
 
 }
@@ -12114,6 +12113,7 @@
     method @NonNull public void setResourceValue(@NonNull String, int, @NonNull String, @Nullable String);
     method @NonNull public void setResourceValue(@NonNull String, @NonNull android.os.ParcelFileDescriptor, @Nullable String);
     method @FlaggedApi("android.content.res.asset_file_descriptor_frro") @NonNull public void setResourceValue(@NonNull String, @NonNull android.content.res.AssetFileDescriptor, @Nullable String);
+    method @FlaggedApi("android.content.res.dimension_frro") public void setResourceValue(@NonNull String, float, int, @Nullable String);
     method public void setTargetOverlayable(@Nullable String);
   }
 
@@ -32821,7 +32821,7 @@
     field @NonNull public static final String RELEASE_OR_PREVIEW_DISPLAY;
     field @Deprecated public static final String SDK;
     field public static final int SDK_INT;
-    field @FlaggedApi("android.sdk.major_minor_versioning_scheme") public static final int SDK_MINOR_INT;
+    field @FlaggedApi("android.sdk.major_minor_versioning_scheme") public static final int SDK_INT_FULL;
     field public static final String SECURITY_PATCH;
   }
 
@@ -32865,6 +32865,9 @@
     field public static final int VANILLA_ICE_CREAM = 35; // 0x23
   }
 
+  @FlaggedApi("android.sdk.major_minor_versioning_scheme") public static class Build.VERSION_CODES_FULL {
+  }
+
   public final class Bundle extends android.os.BaseBundle implements java.lang.Cloneable android.os.Parcelable {
     ctor public Bundle();
     ctor public Bundle(ClassLoader);
@@ -54956,6 +54959,7 @@
     method public boolean addAccessibilityStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener);
     method public void addAccessibilityStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener, @Nullable android.os.Handler);
     method public void addAudioDescriptionRequestedChangeListener(@NonNull java.util.concurrent.Executor, @NonNull android.view.accessibility.AccessibilityManager.AudioDescriptionRequestedChangeListener);
+    method @FlaggedApi("com.android.graphics.hwui.flags.high_contrast_text_small_text_rect") public void addHighContrastTextStateChangeListener(@NonNull java.util.concurrent.Executor, @NonNull android.view.accessibility.AccessibilityManager.HighContrastTextStateChangeListener);
     method public boolean addTouchExplorationStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener);
     method public void addTouchExplorationStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener, @Nullable android.os.Handler);
     method @ColorInt public int getAccessibilityFocusColor();
@@ -54968,12 +54972,14 @@
     method public static boolean isAccessibilityButtonSupported();
     method public boolean isAudioDescriptionRequested();
     method public boolean isEnabled();
+    method @FlaggedApi("com.android.graphics.hwui.flags.high_contrast_text_small_text_rect") public boolean isHighContrastTextEnabled();
     method public boolean isRequestFromAccessibilityTool();
     method public boolean isTouchExplorationEnabled();
     method public void removeAccessibilityRequestPreparer(android.view.accessibility.AccessibilityRequestPreparer);
     method public boolean removeAccessibilityServicesStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.AccessibilityServicesStateChangeListener);
     method public boolean removeAccessibilityStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener);
     method public boolean removeAudioDescriptionRequestedChangeListener(@NonNull android.view.accessibility.AccessibilityManager.AudioDescriptionRequestedChangeListener);
+    method @FlaggedApi("com.android.graphics.hwui.flags.high_contrast_text_small_text_rect") public void removeHighContrastTextStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.HighContrastTextStateChangeListener);
     method public boolean removeTouchExplorationStateChangeListener(@NonNull android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener);
     method public void sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent);
     field public static final int FLAG_CONTENT_CONTROLS = 4; // 0x4
@@ -54993,6 +54999,10 @@
     method public void onAudioDescriptionRequestedChanged(boolean);
   }
 
+  @FlaggedApi("com.android.graphics.hwui.flags.high_contrast_text_small_text_rect") public static interface AccessibilityManager.HighContrastTextStateChangeListener {
+    method public void onHighContrastTextStateChanged(boolean);
+  }
+
   public static interface AccessibilityManager.TouchExplorationStateChangeListener {
     method public void onTouchExplorationStateChanged(boolean);
   }
diff --git a/core/api/module-lib-current.txt b/core/api/module-lib-current.txt
index 8447a7f..287e787 100644
--- a/core/api/module-lib-current.txt
+++ b/core/api/module-lib-current.txt
@@ -604,6 +604,7 @@
 
   public class TelephonyManager {
     method @NonNull public static int[] getAllNetworkTypes();
+    method @FlaggedApi("android.os.mainline_vcn_platform_api") @NonNull @RequiresPermission(android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE) public java.util.Set<java.lang.String> getPackagesWithCarrierPrivileges();
   }
 
 }
diff --git a/core/api/system-current.txt b/core/api/system-current.txt
index 20bcf5f..4b6c62e 100644
--- a/core/api/system-current.txt
+++ b/core/api/system-current.txt
@@ -91,6 +91,7 @@
     field public static final String BIND_TRANSLATION_SERVICE = "android.permission.BIND_TRANSLATION_SERVICE";
     field public static final String BIND_TRUST_AGENT = "android.permission.BIND_TRUST_AGENT";
     field public static final String BIND_TV_REMOTE_SERVICE = "android.permission.BIND_TV_REMOTE_SERVICE";
+    field @FlaggedApi("android.content.pm.verification_service") public static final String BIND_VERIFICATION_AGENT = "android.permission.BIND_VERIFICATION_AGENT";
     field public static final String BIND_VISUAL_QUERY_DETECTION_SERVICE = "android.permission.BIND_VISUAL_QUERY_DETECTION_SERVICE";
     field public static final String BIND_WALLPAPER_EFFECTS_GENERATION_SERVICE = "android.permission.BIND_WALLPAPER_EFFECTS_GENERATION_SERVICE";
     field public static final String BIND_WEARABLE_SENSING_SERVICE = "android.permission.BIND_WEARABLE_SENSING_SERVICE";
@@ -412,6 +413,7 @@
     field @FlaggedApi("android.app.ondeviceintelligence.flags.enable_on_device_intelligence") public static final String USE_ON_DEVICE_INTELLIGENCE = "android.permission.USE_ON_DEVICE_INTELLIGENCE";
     field public static final String USE_RESERVED_DISK = "android.permission.USE_RESERVED_DISK";
     field public static final String UWB_PRIVILEGED = "android.permission.UWB_PRIVILEGED";
+    field @FlaggedApi("android.content.pm.verification_service") public static final String VERIFICATION_AGENT = "android.permission.VERIFICATION_AGENT";
     field @FlaggedApi("android.os.vibrator.vendor_vibration_effects") public static final String VIBRATE_VENDOR_EFFECTS = "android.permission.VIBRATE_VENDOR_EFFECTS";
     field public static final String WHITELIST_AUTO_REVOKE_PERMISSIONS = "android.permission.WHITELIST_AUTO_REVOKE_PERMISSIONS";
     field public static final String WHITELIST_RESTRICTED_PERMISSIONS = "android.permission.WHITELIST_RESTRICTED_PERMISSIONS";
@@ -4303,6 +4305,7 @@
     method @Deprecated @RequiresPermission(android.Manifest.permission.INTENT_FILTER_VERIFICATION_AGENT) public abstract void verifyIntentFilter(int, int, @NonNull java.util.List<java.lang.String>);
     field public static final String ACTION_REQUEST_PERMISSIONS = "android.content.pm.action.REQUEST_PERMISSIONS";
     field public static final String ACTION_REQUEST_PERMISSIONS_FOR_OTHER = "android.content.pm.action.REQUEST_PERMISSIONS_FOR_OTHER";
+    field @FlaggedApi("android.content.pm.verification_service") public static final String ACTION_VERIFY_PACKAGE = "android.content.pm.action.VERIFY_PACKAGE";
     field @FlaggedApi("android.content.pm.asl_in_apk_app_metadata_source") public static final int APP_METADATA_SOURCE_APK = 1; // 0x1
     field @FlaggedApi("android.content.pm.asl_in_apk_app_metadata_source") public static final int APP_METADATA_SOURCE_INSTALLER = 2; // 0x2
     field @FlaggedApi("android.content.pm.asl_in_apk_app_metadata_source") public static final int APP_METADATA_SOURCE_SYSTEM_IMAGE = 3; // 0x3
@@ -4616,6 +4619,61 @@
 
 }
 
+package android.content.pm.verify.pkg {
+
+  @FlaggedApi("android.content.pm.verification_service") public final class VerificationSession implements android.os.Parcelable {
+    method public int describeContents();
+    method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public long extendTimeRemaining(long);
+    method @NonNull public java.util.List<android.content.pm.SharedLibraryInfo> getDeclaredLibraries();
+    method @NonNull public android.os.PersistableBundle getExtensionParams();
+    method public int getId();
+    method public int getInstallSessionId();
+    method @NonNull public String getPackageName();
+    method @NonNull public android.content.pm.SigningInfo getSigningInfo();
+    method @NonNull public android.net.Uri getStagedPackageUri();
+    method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public long getTimeoutTime();
+    method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public void reportVerificationComplete(@NonNull android.content.pm.verify.pkg.VerificationStatus);
+    method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public void reportVerificationComplete(@NonNull android.content.pm.verify.pkg.VerificationStatus, @NonNull android.os.PersistableBundle);
+    method @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT) public void reportVerificationIncomplete(int);
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.content.pm.verify.pkg.VerificationSession> CREATOR;
+    field public static final int VERIFICATION_INCOMPLETE_NETWORK_LIMITED = 2; // 0x2
+    field public static final int VERIFICATION_INCOMPLETE_NETWORK_UNAVAILABLE = 1; // 0x1
+    field public static final int VERIFICATION_INCOMPLETE_UNKNOWN = 0; // 0x0
+  }
+
+  @FlaggedApi("android.content.pm.verification_service") public final class VerificationStatus implements android.os.Parcelable {
+    method public int describeContents();
+    method public int getAslStatus();
+    method @NonNull public String getFailureMessage();
+    method public boolean isVerified();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.content.pm.verify.pkg.VerificationStatus> CREATOR;
+    field public static final int VERIFIER_STATUS_ASL_BAD = 2; // 0x2
+    field public static final int VERIFIER_STATUS_ASL_GOOD = 1; // 0x1
+    field public static final int VERIFIER_STATUS_ASL_UNDEFINED = 0; // 0x0
+  }
+
+  public static final class VerificationStatus.Builder {
+    ctor public VerificationStatus.Builder();
+    method @NonNull public android.content.pm.verify.pkg.VerificationStatus build();
+    method @NonNull public android.content.pm.verify.pkg.VerificationStatus.Builder setAslStatus(int);
+    method @NonNull public android.content.pm.verify.pkg.VerificationStatus.Builder setFailureMessage(@NonNull String);
+    method @NonNull public android.content.pm.verify.pkg.VerificationStatus.Builder setVerified(boolean);
+  }
+
+  @FlaggedApi("android.content.pm.verification_service") public abstract class VerifierService extends android.app.Service {
+    ctor public VerifierService();
+    method @Nullable public android.os.IBinder onBind(@Nullable android.content.Intent);
+    method public abstract void onPackageNameAvailable(@NonNull String);
+    method public abstract void onVerificationCancelled(@NonNull String);
+    method public abstract void onVerificationRequired(@NonNull android.content.pm.verify.pkg.VerificationSession);
+    method public abstract void onVerificationRetry(@NonNull android.content.pm.verify.pkg.VerificationSession);
+    method public abstract void onVerificationTimeout(int);
+  }
+
+}
+
 package android.content.rollback {
 
   public final class PackageRollbackInfo implements android.os.Parcelable {
@@ -13046,6 +13104,7 @@
     method public void onPanelHidden();
     method public void onPanelRevealed(int);
     method public void onSuggestedReplySent(@NonNull String, @NonNull CharSequence, int);
+    method @FlaggedApi("android.service.notification.notification_classification") public final void setAdjustmentTypeSupportedState(@NonNull String, boolean);
     method public final void unsnoozeNotification(@NonNull String);
     field public static final String ACTION_NOTIFICATION_ASSISTANT_DETAIL_SETTINGS = "android.service.notification.action.NOTIFICATION_ASSISTANT_DETAIL_SETTINGS";
     field @FlaggedApi("android.service.notification.notification_classification") public static final String ACTION_NOTIFICATION_ASSISTANT_FEEDBACK_SETTINGS = "android.service.notification.action.NOTIFICATION_ASSISTANT_FEEDBACK_SETTINGS";
diff --git a/core/api/test-current.txt b/core/api/test-current.txt
index f32d805..9bcdf95 100644
--- a/core/api/test-current.txt
+++ b/core/api/test-current.txt
@@ -396,6 +396,7 @@
     method public void cleanUpCallersAfter(long);
     method @FlaggedApi("android.app.modes_api") @NonNull public android.service.notification.ZenPolicy getDefaultZenPolicy();
     method public android.content.ComponentName getEffectsSuppressor();
+    method @FlaggedApi("android.service.notification.notification_classification") @NonNull public java.util.Set<java.lang.String> getUnsupportedAdjustmentTypes();
     method public boolean isNotificationPolicyAccessGrantedForPackage(@NonNull String);
     method @FlaggedApi("android.app.modes_api") public boolean removeAutomaticZenRule(@NonNull String, boolean);
     method @FlaggedApi("android.app.api_rich_ongoing") public void setCanPostPromotedNotifications(@NonNull String, int, boolean);
diff --git a/core/java/android/app/ApplicationStartInfo.java b/core/java/android/app/ApplicationStartInfo.java
index edcdb6c..f34341f 100644
--- a/core/java/android/app/ApplicationStartInfo.java
+++ b/core/java/android/app/ApplicationStartInfo.java
@@ -34,6 +34,7 @@
 import android.util.proto.ProtoOutputStream;
 import android.util.proto.WireTypeMismatchException;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.XmlUtils;
 import com.android.modules.utils.TypedXmlPullParser;
 import com.android.modules.utils.TypedXmlSerializer;
@@ -777,7 +778,9 @@
         mStartComponent = other.mStartComponent;
     }
 
-    private ApplicationStartInfo(@NonNull Parcel in) {
+    /** @hide */
+    @VisibleForTesting
+    public ApplicationStartInfo(@NonNull Parcel in) {
         mStartupState = in.readInt();
         mPid = in.readInt();
         mRealUid = in.readInt();
@@ -1061,12 +1064,21 @@
         if (other == null || !(other instanceof ApplicationStartInfo)) {
             return false;
         }
+
         final ApplicationStartInfo o = (ApplicationStartInfo) other;
-        return mPid == o.mPid && mRealUid == o.mRealUid && mPackageUid == o.mPackageUid
-                && mDefiningUid == o.mDefiningUid && mReason == o.mReason
-                && mStartupState == o.mStartupState && mStartType == o.mStartType
-                && mLaunchMode == o.mLaunchMode && TextUtils.equals(mProcessName, o.mProcessName)
-                && timestampsEquals(o) && mWasForceStopped == o.mWasForceStopped
+
+        return mPid == o.mPid
+                && mRealUid == o.mRealUid
+                && mPackageUid == o.mPackageUid
+                && mDefiningUid == o.mDefiningUid
+                && mReason == o.mReason
+                && mStartupState == o.mStartupState
+                && mStartType == o.mStartType
+                && mLaunchMode == o.mLaunchMode
+                && TextUtils.equals(mPackageName, o.mPackageName)
+                && TextUtils.equals(mProcessName, o.mProcessName)
+                && timestampsEquals(o)
+                && mWasForceStopped == o.mWasForceStopped
                 && mMonoticCreationTimeMs == o.mMonoticCreationTimeMs
                 && mStartComponent == o.mStartComponent;
     }
@@ -1074,7 +1086,7 @@
     @Override
     public int hashCode() {
         return Objects.hash(mPid, mRealUid, mPackageUid, mDefiningUid, mReason, mStartupState,
-                mStartType, mLaunchMode, mProcessName, mStartupTimestampsNs,
+                mStartType, mLaunchMode, mPackageName, mProcessName, mStartupTimestampsNs,
                 mMonoticCreationTimeMs, mStartComponent);
     }
 
diff --git a/core/java/android/app/INotificationManager.aidl b/core/java/android/app/INotificationManager.aidl
index 8a54b5d..3b2aab4 100644
--- a/core/java/android/app/INotificationManager.aidl
+++ b/core/java/android/app/INotificationManager.aidl
@@ -160,8 +160,8 @@
     void requestBindProvider(in ComponentName component);
     void requestUnbindProvider(in IConditionProvider token);
 
-    void setNotificationsShownFromListener(in INotificationListener token, in String[] keys);
 
+    void setNotificationsShownFromListener(in INotificationListener token, in String[] keys);
     ParceledListSlice getActiveNotificationsFromListener(in INotificationListener token, in String[] keys, int trim);
     ParceledListSlice getSnoozedNotificationsFromListener(in INotificationListener token, int trim);
     void clearRequestedListenerHints(in INotificationListener token);
@@ -261,4 +261,7 @@
     void setCanBePromoted(String pkg, int uid, boolean promote, boolean fromUser);
     boolean appCanBePromoted(String pkg, int uid);
     boolean canBePromoted(String pkg);
+
+    void setAdjustmentTypeSupportedState(in INotificationListener token, String key, boolean supported);
+    List<String> getUnsupportedAdjustmentTypes();
 }
diff --git a/core/java/android/app/IUserSwitchObserver.aidl b/core/java/android/app/IUserSwitchObserver.aidl
index cfdb426..1ff7a17 100644
--- a/core/java/android/app/IUserSwitchObserver.aidl
+++ b/core/java/android/app/IUserSwitchObserver.aidl
@@ -19,10 +19,10 @@
 import android.os.IRemoteCallback;
 
 /** {@hide} */
-oneway interface IUserSwitchObserver {
+interface IUserSwitchObserver {
     void onBeforeUserSwitching(int newUserId);
-    void onUserSwitching(int newUserId, IRemoteCallback reply);
-    void onUserSwitchComplete(int newUserId);
-    void onForegroundProfileSwitch(int newProfileId);
-    void onLockedBootComplete(int newUserId);
+    oneway void onUserSwitching(int newUserId, IRemoteCallback reply);
+    oneway void onUserSwitchComplete(int newUserId);
+    oneway void onForegroundProfileSwitch(int newProfileId);
+    oneway void onLockedBootComplete(int newUserId);
 }
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java
index e8b0a36f..34d0f3b 100644
--- a/core/java/android/app/Notification.java
+++ b/core/java/android/app/Notification.java
@@ -11199,7 +11199,7 @@
      */
     @FlaggedApi(Flags.FLAG_API_RICH_ONGOING)
     public static class ProgressStyle extends Notification.Style {
-        private static final String KEY_ELEMENT_STABLE_ID = "stableId";
+        private static final String KEY_ELEMENT_ID = "id";
         private static final String KEY_ELEMENT_COLOR = "colorInt";
         private static final String KEY_SEGMENT_LENGTH = "length";
         private static final String KEY_STEP_POSITION = "position";
@@ -11626,7 +11626,7 @@
 
                     final Bundle bundle = new Bundle();
                     bundle.putInt(KEY_SEGMENT_LENGTH, segment.getLength());
-                    bundle.putInt(KEY_ELEMENT_STABLE_ID, segment.getStableId());
+                    bundle.putInt(KEY_ELEMENT_ID, segment.getId());
                     bundle.putInt(KEY_ELEMENT_COLOR, segment.getColor());
 
                     segments.add(bundle);
@@ -11647,11 +11647,11 @@
                         continue;
                     }
 
-                    final int stableId = segmentBundle.getInt(KEY_ELEMENT_STABLE_ID);
+                    final int id = segmentBundle.getInt(KEY_ELEMENT_ID);
                     final int color = segmentBundle.getInt(KEY_ELEMENT_COLOR,
                             Notification.COLOR_DEFAULT);
                     final Segment segment = new Segment(length)
-                            .setStableId(stableId).setColor(color);
+                            .setId(id).setColor(color);
 
                     segments.add(segment);
                 }
@@ -11672,7 +11672,7 @@
 
                     final Bundle bundle = new Bundle();
                     bundle.putInt(KEY_STEP_POSITION, step.getPosition());
-                    bundle.putInt(KEY_ELEMENT_STABLE_ID, step.getStableId());
+                    bundle.putInt(KEY_ELEMENT_ID, step.getId());
                     bundle.putInt(KEY_ELEMENT_COLOR, step.getColor());
 
                     steps.add(bundle);
@@ -11693,10 +11693,10 @@
                     if (position < 0) {
                         continue;
                     }
-                    final int stableId = segmentBundle.getInt(KEY_ELEMENT_STABLE_ID);
+                    final int id = segmentBundle.getInt(KEY_ELEMENT_ID);
                     final int color = segmentBundle.getInt(KEY_ELEMENT_COLOR,
                             Notification.COLOR_DEFAULT);
-                    final Step step = new Step(position).setStableId(stableId).setColor(color);
+                    final Step step = new Step(position).setId(id).setColor(color);
                     steps.add(step);
                 }
             }
@@ -11712,7 +11712,7 @@
          */
         public static final class Segment {
             private int mLength;
-            private int mStableId = 0;
+            private int mId = 0;
             @ColorInt
             private int mColor = Notification.COLOR_DEFAULT;
 
@@ -11735,19 +11735,19 @@
             }
 
             /**
-             * Gets the stable id of this Segment.
+             * Gets the id of this Segment.
              *
-             * @see #setStableId
+             * @see #setId
              */
-            public int getStableId() {
-                return mStableId;
+            public int getId() {
+                return mId;
             }
 
             /**
              * Optional ID used to uniquely identify the element across updates.
              */
-            public @NonNull Segment setStableId(int stableId) {
-                mStableId = stableId;
+            public @NonNull Segment setId(int id) {
+                mId = id;
                 return this;
             }
 
@@ -11777,13 +11777,13 @@
                 if (this == o) return true;
                 if (o == null || getClass() != o.getClass()) return false;
                 Segment segment = (Segment) o;
-                return mLength == segment.mLength && mStableId == segment.mStableId
+                return mLength == segment.mLength && mId == segment.mId
                         && mColor == segment.mColor;
             }
 
             @Override
             public int hashCode() {
-                return Objects.hash(mLength, mStableId, mColor);
+                return Objects.hash(mLength, mId, mColor);
             }
         }
 
@@ -11797,7 +11797,7 @@
         public static final class Step {
 
             private int mPosition;
-            private int mStableId;
+            private int mId;
             @ColorInt
             private int mColor = Notification.COLOR_DEFAULT;
 
@@ -11823,17 +11823,17 @@
 
 
             /**
-             * Optional ID used to uniqurely identify the element across updates.
+             * Optional ID used to uniquely identify the element across updates.
              */
-            public int getStableId() {
-                return mStableId;
+            public int getId() {
+                return mId;
             }
 
             /**
-             * Optional ID used to uniqurely identify the element across updates.
+             * Optional ID used to uniquely identify the element across updates.
              */
-            public @NonNull Step setStableId(int stableId) {
-                mStableId = stableId;
+            public @NonNull Step setId(int id) {
+                mId = id;
                 return this;
             }
 
@@ -11863,13 +11863,13 @@
                 if (this == o) return true;
                 if (o == null || getClass() != o.getClass()) return false;
                 Step step = (Step) o;
-                return mPosition == step.mPosition && mStableId == step.mStableId
+                return mPosition == step.mPosition && mId == step.mId
                         && mColor == step.mColor;
             }
 
             @Override
             public int hashCode() {
-                return Objects.hash(mPosition, mStableId, mColor);
+                return Objects.hash(mPosition, mId, mColor);
             }
         }
     }
diff --git a/core/java/android/app/NotificationManager.java b/core/java/android/app/NotificationManager.java
index c7b84ae..dfed1f7 100644
--- a/core/java/android/app/NotificationManager.java
+++ b/core/java/android/app/NotificationManager.java
@@ -68,9 +68,11 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.Executor;
 
 /**
@@ -3094,4 +3096,19 @@
         }
     }
 
+    /**
+     * Returns the list of {@link Adjustment} keys that the current approved
+     * {@link android.service.notification.NotificationAssistantService} does not support.
+     * @hide
+     */
+    @TestApi
+    @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+    public @NonNull Set<String> getUnsupportedAdjustmentTypes() {
+        INotificationManager service = getService();
+        try {
+            return new HashSet<>(service.getUnsupportedAdjustmentTypes());
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/core/java/android/app/appfunctions/AppFunctionService.java b/core/java/android/app/appfunctions/AppFunctionService.java
index 8e41773..7a68a65 100644
--- a/core/java/android/app/appfunctions/AppFunctionService.java
+++ b/core/java/android/app/appfunctions/AppFunctionService.java
@@ -35,6 +35,7 @@
 import android.os.CancellationSignal;
 import android.os.RemoteCallback;
 import android.os.RemoteException;
+import android.util.Log;
 
 import java.util.function.Consumer;
 
@@ -166,9 +167,13 @@
      */
     @MainThread
     @Deprecated
-    public abstract void onExecuteFunction(
+    public void onExecuteFunction(
             @NonNull ExecuteAppFunctionRequest request,
-            @NonNull Consumer<ExecuteAppFunctionResponse> callback);
+            @NonNull Consumer<ExecuteAppFunctionResponse> callback) {
+        Log.w(
+                "AppFunctionService",
+                "Calling deprecated default implementation of onExecuteFunction");
+    }
 
     /**
      * Called by the system to execute a specific app function.
diff --git a/core/java/android/app/appfunctions/ExecuteAppFunctionResponse.java b/core/java/android/app/appfunctions/ExecuteAppFunctionResponse.java
index 2851e92..a879b1b 100644
--- a/core/java/android/app/appfunctions/ExecuteAppFunctionResponse.java
+++ b/core/java/android/app/appfunctions/ExecuteAppFunctionResponse.java
@@ -96,17 +96,14 @@
      */
     public static final int RESULT_INVALID_ARGUMENT = 4;
 
-    /** The operation was timed out. */
-    public static final int RESULT_TIMED_OUT = 5;
-
     /** The caller tried to execute a disabled app function. */
-    public static final int RESULT_DISABLED = 6;
+    public static final int RESULT_DISABLED = 5;
 
     /**
      * The operation was cancelled. Use this error code to report that a cancellation is done after
      * receiving a cancellation signal.
      */
-    public static final int RESULT_CANCELLED = 7;
+    public static final int RESULT_CANCELLED = 6;
 
     /** The result code of the app function execution. */
     @ResultCode private final int mResultCode;
@@ -282,7 +279,6 @@
                 RESULT_APP_UNKNOWN_ERROR,
                 RESULT_INTERNAL_ERROR,
                 RESULT_INVALID_ARGUMENT,
-                RESULT_TIMED_OUT,
                 RESULT_DISABLED,
                 RESULT_CANCELLED
             })
diff --git a/core/java/android/companion/virtual/IVirtualDevice.aidl b/core/java/android/companion/virtual/IVirtualDevice.aidl
index 8916ce2..6fe0a73 100644
--- a/core/java/android/companion/virtual/IVirtualDevice.aidl
+++ b/core/java/android/companion/virtual/IVirtualDevice.aidl
@@ -84,11 +84,16 @@
     int getDevicePolicy(int policyType);
 
     /**
-    * Returns whether the device has a valid microphone.
-    */
+     * Returns whether the device has a valid microphone.
+     */
     boolean hasCustomAudioInputSupport();
 
     /**
+     * Returns whether this device is allowed to create mirror displays.
+     */
+    boolean canCreateMirrorDisplays();
+
+    /**
      * Closes the virtual device and frees all associated resources.
      */
     @EnforcePermission("CREATE_VIRTUAL_DEVICE")
diff --git a/core/java/android/companion/virtual/flags/flags.aconfig b/core/java/android/companion/virtual/flags/flags.aconfig
index e9fa3e1..9eb6d56 100644
--- a/core/java/android/companion/virtual/flags/flags.aconfig
+++ b/core/java/android/companion/virtual/flags/flags.aconfig
@@ -132,8 +132,16 @@
 }
 
 flag {
-  namespace: "virtual_devices"
-  name: "camera_timestamp_from_surface"
-  description: "Pass the surface timestamp to the capture result"
-  bug: "351341245"
+    namespace: "virtual_devices"
+    name: "camera_timestamp_from_surface"
+    description: "Pass the surface timestamp to the capture result"
+    bug: "351341245"
+}
+
+flag {
+    namespace: "virtual_devices"
+    name: "enable_limited_vdm_role"
+    description: "New VDM role without trusted displays or input"
+    bug: "370657575"
+    is_exported: true
 }
diff --git a/core/java/android/content/om/FabricatedOverlay.java b/core/java/android/content/om/FabricatedOverlay.java
index 40ffb0f..64e9c33 100644
--- a/core/java/android/content/om/FabricatedOverlay.java
+++ b/core/java/android/content/om/FabricatedOverlay.java
@@ -476,6 +476,20 @@
         return entry;
     }
 
+    @NonNull
+    private static FabricatedOverlayInternalEntry generateFabricatedOverlayInternalEntry(
+            @NonNull String resourceName, float dimensionValue,
+            @TypedValue.ComplexDimensionUnit int dimensionUnit, @Nullable String configuration) {
+        final FabricatedOverlayInternalEntry entry = new FabricatedOverlayInternalEntry();
+        entry.resourceName = resourceName;
+        entry.dataType = TypedValue.TYPE_DIMENSION;
+        Preconditions.checkArgumentInRange(dimensionUnit,
+                TypedValue.COMPLEX_UNIT_PX, TypedValue.COMPLEX_UNIT_MM, "dimensionUnit");
+        entry.data = TypedValue.createComplexDimension(dimensionValue, dimensionUnit);
+        entry.configuration = configuration;
+        return entry;
+    }
+
     /**
      * Sets the resource value in the fabricated overlay for the integer-like types with the
      * configuration.
@@ -586,4 +600,25 @@
         mOverlay.entries.add(
                 generateFabricatedOverlayInternalEntry(resourceName, value, configuration));
     }
+
+    /**
+     * Sets the resource value in the fabricated overlay for the dimension type with the
+     * configuration.
+     *
+     * @param resourceName name of the target resource to overlay (in the form
+     *     [package]:type/entry)
+     * @param dimensionValue the float representing the dimension value
+     * @param dimensionUnit the integer representing the dimension unit
+     * @param configuration The string representation of the config this overlay is enabled for
+     */
+    @FlaggedApi(android.content.res.Flags.FLAG_DIMENSION_FRRO)
+    public void setResourceValue(
+            @NonNull String resourceName,
+            float dimensionValue,
+            @TypedValue.ComplexDimensionUnit int dimensionUnit,
+            @Nullable String configuration) {
+        ensureValidResourceName(resourceName);
+        mOverlay.entries.add(generateFabricatedOverlayInternalEntry(resourceName, dimensionValue,
+                dimensionUnit, configuration));
+    }
 }
diff --git a/core/java/android/content/pm/PackageManager.java b/core/java/android/content/pm/PackageManager.java
index fb2655c..e985f88 100644
--- a/core/java/android/content/pm/PackageManager.java
+++ b/core/java/android/content/pm/PackageManager.java
@@ -5039,6 +5039,25 @@
             "android.content.pm.action.REQUEST_PERMISSIONS_FOR_OTHER";
 
     /**
+     * Used by the system to query a {@link android.content.pm.verify.pkg.VerifierService} provider,
+     * which registers itself via an intent-filter handling this action.
+     *
+     * <p class="note">Only the system can bind to such a verifier service. This is protected by the
+     * {@link android.Manifest.permission#BIND_VERIFICATION_AGENT} permission. The verifier service
+     * app should protect the service by adding this permission in the service declaration in its
+     * manifest.
+     * <p>
+     * A verifier service must be a privileged app and hold the
+     * {@link android.Manifest.permission#VERIFICATION_AGENT} permission.
+     *
+     * @hide
+     */
+    @SystemApi
+    @FlaggedApi(android.content.pm.Flags.FLAG_VERIFICATION_SERVICE)
+    @SdkConstant(SdkConstantType.SERVICE_ACTION)
+    public static final String ACTION_VERIFY_PACKAGE = "android.content.pm.action.VERIFY_PACKAGE";
+
+    /**
      * The names of the requested permissions.
      * <p>
      * <strong>Type:</strong> String[]
diff --git a/core/java/android/content/pm/TEST_MAPPING b/core/java/android/content/pm/TEST_MAPPING
index 2cdae21..44f2a4c 100644
--- a/core/java/android/content/pm/TEST_MAPPING
+++ b/core/java/android/content/pm/TEST_MAPPING
@@ -114,6 +114,17 @@
             ]
         },
         {
+            "name": "CtsPackageInstallerCUJDeviceAdminTestCases",
+            "options":[
+               {
+                   "exclude-annotation":"androidx.test.filters.FlakyTest"
+               },
+               {
+                   "exclude-annotation":"org.junit.Ignore"
+               }
+            ]
+        },
+        {
             "name": "CtsPackageInstallerCUJInstallationTestCases",
             "options":[
                {
@@ -125,6 +136,17 @@
             ]
         },
         {
+            "name": "CtsPackageInstallerCUJMultiUsersTestCases",
+            "options":[
+               {
+                   "exclude-annotation":"androidx.test.filters.FlakyTest"
+               },
+               {
+                   "exclude-annotation":"org.junit.Ignore"
+               }
+            ]
+        },
+        {
             "name": "CtsPackageInstallerCUJUninstallationTestCases",
             "options":[
                {
diff --git a/core/java/android/content/pm/flags.aconfig b/core/java/android/content/pm/flags.aconfig
index 160cbdf..300740e 100644
--- a/core/java/android/content/pm/flags.aconfig
+++ b/core/java/android/content/pm/flags.aconfig
@@ -309,3 +309,11 @@
     description: "Feature flag to enable the holder of SYSTEM_APP_PROTECTION_SERVICE role to silently delete packages. To be deprecated by delete_packages_silently."
     bug: "361776825"
 }
+
+flag {
+    name: "verification_service"
+    namespace: "package_manager_service"
+    description: "Feature flag to enable the new verification service."
+    bug: "360129103"
+    is_fixed_read_only: true
+}
diff --git a/core/java/android/content/pm/multiuser.aconfig b/core/java/android/content/pm/multiuser.aconfig
index fd1a896..d5edc92 100644
--- a/core/java/android/content/pm/multiuser.aconfig
+++ b/core/java/android/content/pm/multiuser.aconfig
@@ -68,6 +68,13 @@
 }
 
 flag {
+    name: "multiuser_widget"
+    namespace: "multiuser"
+    description: "Implement the Multiuser Widget"
+    bug: "365748524"
+}
+
+flag {
     name: "enable_biometrics_to_unlock_private_space"
     is_exported: true
     namespace: "profile_experiences"
diff --git a/core/java/android/content/pm/verify/pkg/IVerificationSessionCallback.aidl b/core/java/android/content/pm/verify/pkg/IVerificationSessionCallback.aidl
new file mode 100644
index 0000000..38a7956
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/IVerificationSessionCallback.aidl
@@ -0,0 +1,34 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+import android.content.pm.verify.pkg.VerificationStatus;
+import android.os.PersistableBundle;
+
+/**
+ * Oneway interface that allows the verifier to send response or verification results back to
+ * the system.
+ * @hide
+ */
+oneway interface IVerificationSessionCallback {
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)")
+    void reportVerificationIncomplete(int verificationId, int reason);
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)")
+    void reportVerificationComplete(int verificationId, in VerificationStatus status);
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)")
+    void reportVerificationCompleteWithExtensionResponse(int verificationId, in VerificationStatus status, in PersistableBundle response);
+}
diff --git a/core/java/android/content/pm/verify/pkg/IVerificationSessionInterface.aidl b/core/java/android/content/pm/verify/pkg/IVerificationSessionInterface.aidl
new file mode 100644
index 0000000..7a9484a
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/IVerificationSessionInterface.aidl
@@ -0,0 +1,28 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+/**
+ * Non-oneway interface that allows the verifier to retrieve information from the system.
+ * @hide
+ */
+interface IVerificationSessionInterface {
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)")
+    long getTimeoutTime(int verificationId);
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)")
+    long extendTimeRemaining(int verificationId, long additionalMs);
+}
\ No newline at end of file
diff --git a/core/java/android/content/pm/verify/pkg/IVerifierService.aidl b/core/java/android/content/pm/verify/pkg/IVerifierService.aidl
new file mode 100644
index 0000000..d3071fd
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/IVerifierService.aidl
@@ -0,0 +1,31 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+import android.content.pm.verify.pkg.VerificationSession;
+
+/**
+ * Oneway interface that allows the system to communicate to the verifier service agent.
+ * @hide
+ */
+oneway interface IVerifierService {
+    void onPackageNameAvailable(in String packageName);
+    void onVerificationCancelled(in String packageName);
+    void onVerificationRequired(in VerificationSession session);
+    void onVerificationRetry(in VerificationSession session);
+    void onVerificationTimeout(int verificationId);
+}
\ No newline at end of file
diff --git a/core/java/android/content/pm/verify/pkg/VerificationSession.aidl b/core/java/android/content/pm/verify/pkg/VerificationSession.aidl
new file mode 100644
index 0000000..ac85585
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/VerificationSession.aidl
@@ -0,0 +1,20 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+/** @hide */
+parcelable VerificationSession;
diff --git a/core/java/android/content/pm/verify/pkg/VerificationSession.java b/core/java/android/content/pm/verify/pkg/VerificationSession.java
new file mode 100644
index 0000000..70b4a02
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/VerificationSession.java
@@ -0,0 +1,276 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.content.pm.Flags;
+import android.content.pm.SharedLibraryInfo;
+import android.content.pm.SigningInfo;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.PersistableBundle;
+import android.os.RemoteException;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * This class is used by the system to describe the details about a verification request sent to the
+ * verification agent, aka the verifier. It includes the interfaces for the verifier to communicate
+ * back to the system.
+ * @hide
+ */
+@FlaggedApi(Flags.FLAG_VERIFICATION_SERVICE)
+@SystemApi
+public final class VerificationSession implements Parcelable {
+    /**
+     * The verification cannot be completed because of unknown reasons.
+     */
+    public static final int VERIFICATION_INCOMPLETE_UNKNOWN = 0;
+    /**
+     * The verification cannot be completed because the network is unavailable.
+     */
+    public static final int VERIFICATION_INCOMPLETE_NETWORK_UNAVAILABLE = 1;
+    /**
+     * The verification cannot be completed because the network is limited.
+     */
+    public static final int VERIFICATION_INCOMPLETE_NETWORK_LIMITED = 2;
+
+    /**
+     * @hide
+     */
+    @IntDef(prefix = {"VERIFICATION_INCOMPLETE_"}, value = {
+            VERIFICATION_INCOMPLETE_NETWORK_UNAVAILABLE,
+            VERIFICATION_INCOMPLETE_NETWORK_LIMITED,
+            VERIFICATION_INCOMPLETE_UNKNOWN,
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface VerificationIncompleteReason {
+    }
+
+    private final int mId;
+    private final int mInstallSessionId;
+    @NonNull
+    private final String mPackageName;
+    @NonNull
+    private final Uri mStagedPackageUri;
+    @NonNull
+    private final SigningInfo mSigningInfo;
+    @NonNull
+    private final List<SharedLibraryInfo> mDeclaredLibraries;
+    @NonNull
+    private final PersistableBundle mExtensionParams;
+    @NonNull
+    private final IVerificationSessionInterface mSession;
+    @NonNull
+    private final IVerificationSessionCallback mCallback;
+
+    /**
+     * Constructor used by the system to describe the details of a verification session.
+     * @hide
+     */
+    public VerificationSession(int id, int installSessionId, @NonNull String packageName,
+            @NonNull Uri stagedPackageUri, @NonNull SigningInfo signingInfo,
+            @NonNull List<SharedLibraryInfo> declaredLibraries,
+            @NonNull PersistableBundle extensionParams,
+            @NonNull IVerificationSessionInterface session,
+            @NonNull IVerificationSessionCallback callback) {
+        mId = id;
+        mInstallSessionId = installSessionId;
+        mPackageName = packageName;
+        mStagedPackageUri = stagedPackageUri;
+        mSigningInfo = signingInfo;
+        mDeclaredLibraries = declaredLibraries;
+        mExtensionParams = extensionParams;
+        mSession = session;
+        mCallback = callback;
+    }
+
+    /**
+     * A unique identifier tied to this specific verification session.
+     */
+    public int getId() {
+        return mId;
+    }
+
+    /**
+     * The package name of the app that is to be verified.
+     */
+    public @NonNull String getPackageName() {
+        return mPackageName;
+    }
+
+    /**
+     * The id of the installation session associated with the verification.
+     */
+    public int getInstallSessionId() {
+        return mInstallSessionId;
+    }
+
+    /**
+     * The Uri of the path where the package's code files are located.
+     */
+    public @NonNull Uri getStagedPackageUri() {
+        return mStagedPackageUri;
+    }
+
+    /**
+     * Signing info of the package to be verified.
+     */
+    public @NonNull SigningInfo getSigningInfo() {
+        return mSigningInfo;
+    }
+
+    /**
+     * Returns a mapping of any shared libraries declared in the manifest
+     * to the {@link SharedLibraryInfo#Type} that is declared. This will be an empty
+     * map if no shared libraries are declared by the package.
+     */
+    @NonNull
+    public List<SharedLibraryInfo> getDeclaredLibraries() {
+        return Collections.unmodifiableList(mDeclaredLibraries);
+    }
+
+    /**
+     * Returns any extension params associated with the verification request.
+     */
+    @NonNull
+    public PersistableBundle getExtensionParams() {
+        return mExtensionParams;
+    }
+
+    /**
+     * Get the value of Clock.elapsedRealtime() at which time this verification
+     * will timeout as incomplete if no other verification response is provided.
+     */
+    @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)
+    public long getTimeoutTime() {
+        try {
+            return mSession.getTimeoutTime(mId);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Extend the timeout for this session by the provided additionalMs to
+     * fetch relevant information over the network or wait for the network.
+     * This may be called multiple times. If the request would bypass any max
+     * duration by the system, the method will return a lower value than the
+     * requested amount that indicates how much the time was extended.
+     */
+    @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)
+    public long extendTimeRemaining(long additionalMs) {
+        try {
+            return mSession.extendTimeRemaining(mId, additionalMs);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Report to the system that verification could not be completed along
+     * with an approximate reason to pass on to the installer.
+     */
+    @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)
+    public void reportVerificationIncomplete(@VerificationIncompleteReason int reason) {
+        try {
+            mCallback.reportVerificationIncomplete(mId, reason);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Report to the system that the verification has completed and the
+     * install process may act on that status to either block in the case
+     * of failure or continue to process the install in the case of success.
+     */
+    @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)
+    public void reportVerificationComplete(@NonNull VerificationStatus status) {
+        try {
+            mCallback.reportVerificationComplete(mId, status);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Same as {@link #reportVerificationComplete(VerificationStatus)}, but also provide
+     * a result to the extension params provided in the request, which will be passed to the
+     * installer in the installation result.
+     */
+    @RequiresPermission(android.Manifest.permission.VERIFICATION_AGENT)
+    public void reportVerificationComplete(@NonNull VerificationStatus status,
+            @NonNull PersistableBundle response) {
+        try {
+            mCallback.reportVerificationCompleteWithExtensionResponse(mId, status, response);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    private VerificationSession(@NonNull Parcel in) {
+        mId = in.readInt();
+        mInstallSessionId = in.readInt();
+        mPackageName = in.readString8();
+        mStagedPackageUri = Uri.CREATOR.createFromParcel(in);
+        mSigningInfo = SigningInfo.CREATOR.createFromParcel(in);
+        mDeclaredLibraries = in.createTypedArrayList(SharedLibraryInfo.CREATOR);
+        mExtensionParams = in.readPersistableBundle(getClass().getClassLoader());
+        mSession = IVerificationSessionInterface.Stub.asInterface(in.readStrongBinder());
+        mCallback = IVerificationSessionCallback.Stub.asInterface(in.readStrongBinder());
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mId);
+        dest.writeInt(mInstallSessionId);
+        dest.writeString8(mPackageName);
+        Uri.writeToParcel(dest, mStagedPackageUri);
+        mSigningInfo.writeToParcel(dest, flags);
+        dest.writeTypedList(mDeclaredLibraries);
+        dest.writePersistableBundle(mExtensionParams);
+        dest.writeStrongBinder(mSession.asBinder());
+        dest.writeStrongBinder(mCallback.asBinder());
+    }
+
+    @NonNull
+    public static final Creator<VerificationSession> CREATOR = new Creator<>() {
+        @Override
+        public VerificationSession createFromParcel(@NonNull Parcel in) {
+            return new VerificationSession(in);
+        }
+
+        @Override
+        public VerificationSession[] newArray(int size) {
+            return new VerificationSession[size];
+        }
+    };
+}
diff --git a/core/java/android/content/pm/verify/pkg/VerificationStatus.aidl b/core/java/android/content/pm/verify/pkg/VerificationStatus.aidl
new file mode 100644
index 0000000..6a1cb4f
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/VerificationStatus.aidl
@@ -0,0 +1,20 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+/** @hide */
+parcelable VerificationStatus;
diff --git a/core/java/android/content/pm/verify/pkg/VerificationStatus.java b/core/java/android/content/pm/verify/pkg/VerificationStatus.java
new file mode 100644
index 0000000..4d0379d7
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/VerificationStatus.java
@@ -0,0 +1,166 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.content.pm.Flags;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * This class is used by the verifier to describe the status of the verification request, whether
+ * it's successful or it has failed along with any relevant details.
+ * @hide
+ */
+@SystemApi
+@FlaggedApi(Flags.FLAG_VERIFICATION_SERVICE)
+public final class  VerificationStatus implements Parcelable {
+    /**
+     * The ASL status has not been determined.  This happens in situations where the verification
+     * service is not monitoring ASLs, and means the ASL data in the app is not necessarily bad but
+     * can't be trusted.
+     */
+    public static final int VERIFIER_STATUS_ASL_UNDEFINED = 0;
+
+    /**
+     * The app's ASL data is considered to be in a good state.
+     */
+    public static final int VERIFIER_STATUS_ASL_GOOD = 1;
+
+    /**
+     * There is something bad in the app's ASL data; the user should be warned about this when shown
+     * the ASL data and/or appropriate decisions made about the use of this data by the platform.
+     */
+    public static final int VERIFIER_STATUS_ASL_BAD = 2;
+
+    /** @hide */
+    @IntDef(prefix = {"VERIFIER_STATUS_ASL_"}, value = {
+            VERIFIER_STATUS_ASL_UNDEFINED,
+            VERIFIER_STATUS_ASL_GOOD,
+            VERIFIER_STATUS_ASL_BAD,
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface VerifierStatusAsl {}
+
+    private boolean mIsVerified;
+    private @VerifierStatusAsl int mAslStatus;
+    @NonNull
+    private String mFailuresMessage = "";
+
+    private VerificationStatus() {}
+
+    /**
+     * @return whether the status is set to verified or not.
+     */
+    public boolean isVerified() {
+        return mIsVerified;
+    }
+
+    /**
+     * @return the failure message associated with the failure status.
+     */
+    @NonNull
+    public String getFailureMessage() {
+        return mFailuresMessage;
+    }
+
+    /**
+     * @return the asl status.
+     */
+    public @VerifierStatusAsl int getAslStatus() {
+        return mAslStatus;
+    }
+
+    /**
+     * Builder to construct a {@link VerificationStatus} object.
+     */
+    public static final class Builder {
+        final VerificationStatus mStatus = new VerificationStatus();
+
+        /**
+         * Set in the status whether the verification has succeeded or failed.
+         */
+        @NonNull
+        public Builder setVerified(boolean verified) {
+            mStatus.mIsVerified = verified;
+            return this;
+        }
+
+        /**
+         * Set a developer-facing failure message to include in the verification failure status.
+         */
+        @NonNull
+        public Builder setFailureMessage(@NonNull String failureMessage) {
+            mStatus.mFailuresMessage = failureMessage;
+            return this;
+        }
+
+        /**
+         * Set the ASL status, as defined in {@link VerifierStatusAsl}.
+         */
+        @NonNull
+        public Builder setAslStatus(@VerifierStatusAsl int aslStatus) {
+            mStatus.mAslStatus = aslStatus;
+            return this;
+        }
+
+        /**
+         * Build the status object.
+         */
+        @NonNull
+        public VerificationStatus build() {
+            return mStatus;
+        }
+    }
+
+    private VerificationStatus(Parcel in) {
+        mIsVerified = in.readBoolean();
+        mAslStatus = in.readInt();
+        mFailuresMessage = in.readString8();
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeBoolean(mIsVerified);
+        dest.writeInt(mAslStatus);
+        dest.writeString8(mFailuresMessage);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @NonNull
+    public static final Creator<VerificationStatus> CREATOR = new Creator<>() {
+        @Override
+        public VerificationStatus createFromParcel(@NonNull Parcel in) {
+            return new VerificationStatus(in);
+        }
+
+        @Override
+        public VerificationStatus[] newArray(int size) {
+            return new VerificationStatus[size];
+        }
+    };
+}
diff --git a/core/java/android/content/pm/verify/pkg/VerifierService.java b/core/java/android/content/pm/verify/pkg/VerifierService.java
new file mode 100644
index 0000000..ccf2119
--- /dev/null
+++ b/core/java/android/content/pm/verify/pkg/VerifierService.java
@@ -0,0 +1,113 @@
+/*
+ * 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 android.content.pm.verify.pkg;
+
+import android.annotation.FlaggedApi;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.app.Service;
+import android.content.Intent;
+import android.content.pm.Flags;
+import android.content.pm.PackageManager;
+import android.os.IBinder;
+
+/**
+ * A base service implementation for the verifier agent to implement.
+ *
+ * @hide
+ */
+@SystemApi
+@FlaggedApi(Flags.FLAG_VERIFICATION_SERVICE)
+public abstract class VerifierService extends Service {
+    /**
+     * Called when a package name is available for a pending verification,
+     * giving the verifier opportunity to pre-fetch any relevant information
+     * that may be needed should a verification for the package be required.
+     */
+    public abstract void onPackageNameAvailable(@NonNull String packageName);
+
+    /**
+     * Called when a package recently provided via {@link #onPackageNameAvailable}
+     * is no longer expected to be installed. This is a hint that any pre-fetch or
+     * cache created as a result of the previous call may be be cleared.
+     * <p>This method will never be called after {@link #onVerificationRequired} is called for the
+     * same package. Once a verification is officially requested by
+     * {@link #onVerificationRequired}, it cannot be cancelled.
+     * </p>
+     */
+    public abstract void onVerificationCancelled(@NonNull String packageName);
+
+    /**
+     * Called when an application needs to be verified. Details about the
+     * verification and actions that can be taken on it will be encapsulated in
+     * the provided {@link VerificationSession} parameter.
+     */
+    public abstract void onVerificationRequired(@NonNull VerificationSession session);
+
+    /**
+     * Called when a verification needs to be retried. This can be encountered
+     * when a prior verification was marked incomplete and the user has indicated
+     * that they've resolved the issue, or when a timeout is reached, but the
+     * the system is attempting to retry. Details about the
+     * verification and actions that can be taken on it will be encapsulated in
+     * the provided {@link VerificationSession} parameter.
+     */
+    public abstract void onVerificationRetry(@NonNull VerificationSession session);
+
+    /**
+     * Called in the case that an active verification has failed. Any APIs called
+     * on the {@link VerificationSession} instance associated with this {@code verificationId} will
+     * throw an {@link IllegalStateException}.
+     */
+    public abstract void onVerificationTimeout(int verificationId);
+
+    /**
+     * Called when the verifier service is bound to the system.
+     */
+    public @Nullable IBinder onBind(@Nullable Intent intent) {
+        if (intent == null || !PackageManager.ACTION_VERIFY_PACKAGE.equals(intent.getAction())) {
+            return null;
+        }
+        return new IVerifierService.Stub() {
+            @Override
+            public void onPackageNameAvailable(@NonNull String packageName) {
+                VerifierService.this.onPackageNameAvailable(packageName);
+            }
+
+            @Override
+            public void onVerificationCancelled(@NonNull String packageName) {
+                VerifierService.this.onVerificationCancelled(packageName);
+            }
+
+            @Override
+            public void onVerificationRequired(@NonNull VerificationSession session) {
+                VerifierService.this.onVerificationRequired(session);
+            }
+
+            @Override
+            public void onVerificationRetry(@NonNull VerificationSession session) {
+                VerifierService.this.onVerificationRetry(session);
+            }
+
+            @Override
+            public void onVerificationTimeout(int verificationId) {
+                VerifierService.this.onVerificationTimeout(verificationId);
+            }
+        };
+    }
+}
diff --git a/core/java/android/content/res/flags.aconfig b/core/java/android/content/res/flags.aconfig
index a5f8199..0af2f25 100644
--- a/core/java/android/content/res/flags.aconfig
+++ b/core/java/android/content/res/flags.aconfig
@@ -66,3 +66,11 @@
     # This flag is read at boot time.
     is_fixed_read_only: true
 }
+
+flag {
+    name: "dimension_frro"
+    is_exported: true
+    namespace: "resource_manager"
+    description: "Feature flag for passing a dimension to create an frro"
+    bug: "369672322"
+}
diff --git a/core/java/android/hardware/biometrics/BiometricPrompt.java b/core/java/android/hardware/biometrics/BiometricPrompt.java
index b11961c..e3fdd26 100644
--- a/core/java/android/hardware/biometrics/BiometricPrompt.java
+++ b/core/java/android/hardware/biometrics/BiometricPrompt.java
@@ -139,6 +139,13 @@
     public static final int DISMISSED_REASON_CONTENT_VIEW_MORE_OPTIONS = 8;
 
     /**
+     * Dialog dismissal due to the system being unable to retrieve a WindowManager instance required
+     * to show the dialog.
+     * @hide
+     */
+    public static final int DISMISSED_REASON_ERROR_NO_WM = 9;
+
+    /**
      * @hide
      */
     @IntDef({DISMISSED_REASON_BIOMETRIC_CONFIRMED,
@@ -148,7 +155,8 @@
             DISMISSED_REASON_ERROR,
             DISMISSED_REASON_SERVER_REQUESTED,
             DISMISSED_REASON_CREDENTIAL_CONFIRMED,
-            DISMISSED_REASON_CONTENT_VIEW_MORE_OPTIONS})
+            DISMISSED_REASON_CONTENT_VIEW_MORE_OPTIONS,
+            DISMISSED_REASON_ERROR_NO_WM})
     @Retention(RetentionPolicy.SOURCE)
     public @interface DismissedReason {}
 
diff --git a/core/java/android/hardware/camera2/CameraManager.java b/core/java/android/hardware/camera2/CameraManager.java
index 1b21bdf..9e3a9b3 100644
--- a/core/java/android/hardware/camera2/CameraManager.java
+++ b/core/java/android/hardware/camera2/CameraManager.java
@@ -69,6 +69,7 @@
 import android.os.ServiceManager;
 import android.os.ServiceSpecificException;
 import android.os.SystemProperties;
+import android.text.TextUtils;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
@@ -80,6 +81,7 @@
 import com.android.internal.util.ArrayUtils;
 
 import java.lang.ref.WeakReference;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
@@ -2157,6 +2159,12 @@
 
         private final Set<Set<DeviceCameraInfo>> mConcurrentCameraIdCombinations = new ArraySet<>();
 
+        // Diagnostic messages for ArrayIndexOutOfBoundsException in extractCameraIdListLocked
+        // b/367649718
+        private static final int DEVICE_STATUS_ARRAY_SIZE = 10;
+        private final ArrayDeque<String> mDeviceStatusHistory =
+                new ArrayDeque<>(DEVICE_STATUS_ARRAY_SIZE);
+
         // Registered availability callbacks and their executors
         private final ArrayMap<AvailabilityCallback, Executor> mCallbackMap = new ArrayMap<>();
 
@@ -2274,6 +2282,10 @@
             }
 
             try {
+                addDeviceStatusHistoryLocked(TextUtils.formatSimple(
+                        "connectCameraServiceLocked(E): tid(%d): mDeviceStatus size %d",
+                        Thread.currentThread().getId(), mDeviceStatus.size()));
+
                 CameraStatus[] cameraStatuses = cameraService.addListener(this);
                 for (CameraStatus cameraStatus : cameraStatuses) {
                     DeviceCameraInfo info = new DeviceCameraInfo(cameraStatus.cameraId,
@@ -2296,6 +2308,10 @@
                     }
                 }
                 mCameraService = cameraService;
+
+                addDeviceStatusHistoryLocked(TextUtils.formatSimple(
+                        "connectCameraServiceLocked(X): tid(%d): mDeviceStatus size %d",
+                        Thread.currentThread().getId(), mDeviceStatus.size()));
             } catch (ServiceSpecificException e) {
                 // Unexpected failure
                 throw new IllegalStateException("Failed to register a camera service listener", e);
@@ -2349,18 +2365,28 @@
         }
 
         private String[] extractCameraIdListLocked(int deviceId, int devicePolicy) {
-            List<String> cameraIds = new ArrayList<>();
-            for (int i = 0; i < mDeviceStatus.size(); i++) {
-                int status = mDeviceStatus.valueAt(i);
-                DeviceCameraInfo info = mDeviceStatus.keyAt(i);
-                if (status == ICameraServiceListener.STATUS_NOT_PRESENT
-                        || status == ICameraServiceListener.STATUS_ENUMERATING
-                        || shouldHideCamera(deviceId, devicePolicy, info)) {
-                    continue;
+            addDeviceStatusHistoryLocked(TextUtils.formatSimple(
+                    "extractCameraIdListLocked(E): tid(%d): mDeviceStatus size %d",
+                    Thread.currentThread().getId(), mDeviceStatus.size()));
+            try {
+                List<String> cameraIds = new ArrayList<>();
+                for (int i = 0; i < mDeviceStatus.size(); i++) {
+                    int status = mDeviceStatus.valueAt(i);
+                    DeviceCameraInfo info = mDeviceStatus.keyAt(i);
+                    if (status == ICameraServiceListener.STATUS_NOT_PRESENT
+                            || status == ICameraServiceListener.STATUS_ENUMERATING
+                            || shouldHideCamera(deviceId, devicePolicy, info)) {
+                        continue;
+                    }
+                    cameraIds.add(info.mCameraId);
                 }
-                cameraIds.add(info.mCameraId);
+                return cameraIds.toArray(new String[0]);
+            }  catch (ArrayIndexOutOfBoundsException e) {
+                String message = e.getMessage();
+                String messageWithHistory = message + ": {"
+                        + String.join(" -> ", mDeviceStatusHistory) + "}";
+                throw new ArrayIndexOutOfBoundsException(messageWithHistory);
             }
-            return cameraIds.toArray(new String[0]);
         }
 
         private Set<Set<String>> extractConcurrentCameraIdListLocked(int deviceId,
@@ -2488,6 +2514,10 @@
             synchronized (mLock) {
                 connectCameraServiceLocked();
                 try {
+                    addDeviceStatusHistoryLocked(TextUtils.formatSimple(
+                            "getCameraIdListNoLazy(E): tid(%d): mDeviceStatus size %d",
+                            Thread.currentThread().getId(), mDeviceStatus.size()));
+
                     // The purpose of the addListener, removeListener pair here is to get a fresh
                     // list of camera ids from cameraserver. We do this since for in test processes,
                     // changes can happen w.r.t non-changeable permissions (eg: SYSTEM_CAMERA
@@ -2521,6 +2551,9 @@
                         onStatusChangedLocked(ICameraServiceListener.STATUS_NOT_PRESENT, info);
                         mTorchStatus.remove(info);
                     }
+                    addDeviceStatusHistoryLocked(TextUtils.formatSimple(
+                            "getCameraIdListNoLazy(X): tid(%d): mDeviceStatus size %d",
+                            Thread.currentThread().getId(), mDeviceStatus.size()));
                 } catch (ServiceSpecificException e) {
                     // Unexpected failure
                     throw new IllegalStateException("Failed to register a camera service listener",
@@ -3209,7 +3242,13 @@
         public void onStatusChanged(int status, String cameraId, int deviceId)
                 throws RemoteException {
             synchronized(mLock) {
+                addDeviceStatusHistoryLocked(
+                        TextUtils.formatSimple("onStatusChanged(E): tid(%d): mDeviceStatus size %d",
+                                Thread.currentThread().getId(), mDeviceStatus.size()));
                 onStatusChangedLocked(status, new DeviceCameraInfo(cameraId, deviceId));
+                addDeviceStatusHistoryLocked(
+                        TextUtils.formatSimple("onStatusChanged(X): tid(%d): mDeviceStatus size %d",
+                                Thread.currentThread().getId(), mDeviceStatus.size()));
             }
         }
 
@@ -3352,6 +3391,10 @@
          */
         public void binderDied() {
             synchronized(mLock) {
+                addDeviceStatusHistoryLocked(
+                        TextUtils.formatSimple("binderDied(E): tid(%d): mDeviceStatus size %d",
+                                Thread.currentThread().getId(), mDeviceStatus.size()));
+
                 // Only do this once per service death
                 if (mCameraService == null) return;
 
@@ -3380,6 +3423,10 @@
                 mConcurrentCameraIdCombinations.clear();
 
                 scheduleCameraServiceReconnectionLocked();
+
+                addDeviceStatusHistoryLocked(
+                        TextUtils.formatSimple("binderDied(X): tid(%d): mDeviceStatus size %d",
+                                Thread.currentThread().getId(), mDeviceStatus.size()));
             }
         }
 
@@ -3409,5 +3456,13 @@
                 return Objects.hash(mCameraId, mDeviceId);
             }
         }
+
+        private void addDeviceStatusHistoryLocked(String log) {
+            if (mDeviceStatusHistory.size() == DEVICE_STATUS_ARRAY_SIZE) {
+                mDeviceStatusHistory.removeFirst();
+            }
+            mDeviceStatusHistory.addLast(log);
+        }
+
     } // CameraManagerGlobal
 } // CameraManager
diff --git a/core/java/android/hardware/display/DisplayManagerGlobal.java b/core/java/android/hardware/display/DisplayManagerGlobal.java
index 7185719..6affd12 100644
--- a/core/java/android/hardware/display/DisplayManagerGlobal.java
+++ b/core/java/android/hardware/display/DisplayManagerGlobal.java
@@ -792,7 +792,6 @@
     public void setVirtualDisplaySurface(IVirtualDisplayCallback token, Surface surface) {
         try {
             mDm.setVirtualDisplaySurface(token, surface);
-            setVirtualDisplayState(token, surface != null);
         } catch (RemoteException ex) {
             throw ex.rethrowFromSystemServer();
         }
@@ -815,14 +814,6 @@
         }
     }
 
-    void setVirtualDisplayState(IVirtualDisplayCallback token, boolean isOn) {
-        try {
-            mDm.setVirtualDisplayState(token, isOn);
-        } catch (RemoteException ex) {
-            throw ex.rethrowFromSystemServer();
-        }
-    }
-
     void setVirtualDisplayRotation(IVirtualDisplayCallback token, @Surface.Rotation int rotation) {
         try {
             mDm.setVirtualDisplayRotation(token, rotation);
diff --git a/core/java/android/hardware/display/DisplayManagerInternal.java b/core/java/android/hardware/display/DisplayManagerInternal.java
index 73b5d94..e598097 100644
--- a/core/java/android/hardware/display/DisplayManagerInternal.java
+++ b/core/java/android/hardware/display/DisplayManagerInternal.java
@@ -431,6 +431,17 @@
      */
     public abstract IntArray getDisplayGroupIds();
 
+
+    /**
+     * Get all display ids belonging to the display group with given id.
+     */
+    public abstract int[] getDisplayIdsForGroup(int groupId);
+
+    /**
+     * Get the mapping of display group ids to the display ids that belong to them.
+     */
+    public abstract SparseArray<int[]> getDisplayIdsByGroupsIds();
+
     /**
      * Get all available display ids.
      */
diff --git a/core/java/android/hardware/display/IDisplayManager.aidl b/core/java/android/hardware/display/IDisplayManager.aidl
index aa1539f6..b612bca 100644
--- a/core/java/android/hardware/display/IDisplayManager.aidl
+++ b/core/java/android/hardware/display/IDisplayManager.aidl
@@ -115,9 +115,6 @@
     void releaseVirtualDisplay(in IVirtualDisplayCallback token);
 
     // No permissions required but must be same Uid as the creator.
-    void setVirtualDisplayState(in IVirtualDisplayCallback token, boolean isOn);
-
-    // No permissions required but must be same Uid as the creator.
     void setVirtualDisplayRotation(in IVirtualDisplayCallback token, int rotation);
 
     // Get a stable metric for the device's display size. No permissions required.
diff --git a/core/java/android/hardware/display/VirtualDisplay.java b/core/java/android/hardware/display/VirtualDisplay.java
index 6cc938f..32b6405 100644
--- a/core/java/android/hardware/display/VirtualDisplay.java
+++ b/core/java/android/hardware/display/VirtualDisplay.java
@@ -112,18 +112,6 @@
     }
 
     /**
-     * Sets the on/off state for a virtual display.
-     *
-     * @param isOn Whether the display should be on or off.
-     * @hide
-     */
-    public void setDisplayState(boolean isOn) {
-        if (mToken != null) {
-            mGlobal.setVirtualDisplayState(mToken, isOn);
-        }
-    }
-
-    /**
      * Sets the rotation of the virtual display.
      *
      * @param rotation the new rotation of the display. May be one of {@link Surface#ROTATION_0},
diff --git a/core/java/android/net/vcn/VcnTransportInfo.java b/core/java/android/net/vcn/VcnTransportInfo.java
index f546910..1fc91ee 100644
--- a/core/java/android/net/vcn/VcnTransportInfo.java
+++ b/core/java/android/net/vcn/VcnTransportInfo.java
@@ -17,9 +17,11 @@
 package android.net.vcn;
 
 import static android.net.NetworkCapabilities.REDACT_FOR_NETWORK_SETTINGS;
+import static android.net.vcn.VcnGatewayConnectionConfig.MIN_UDP_PORT_4500_NAT_TIMEOUT_SECONDS;
 import static android.net.vcn.VcnGatewayConnectionConfig.MIN_UDP_PORT_4500_NAT_TIMEOUT_UNSET;
 import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
 
+import android.annotation.IntRange;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.net.NetworkCapabilities;
@@ -29,6 +31,8 @@
 import android.os.Parcelable;
 import android.telephony.SubscriptionManager;
 
+import com.android.internal.util.Preconditions;
+
 import java.util.Objects;
 
 /**
@@ -47,6 +51,7 @@
  *
  * @hide
  */
+// TODO: Do not store WifiInfo and subscription ID in VcnTransportInfo anymore
 public class VcnTransportInfo implements TransportInfo, Parcelable {
     @Nullable private final WifiInfo mWifiInfo;
     private final int mSubId;
@@ -195,4 +200,42 @@
                     return new VcnTransportInfo[size];
                 }
             };
+
+    /** This class can be used to construct a {@link VcnTransportInfo}. */
+    public static final class Builder {
+        private int mMinUdpPort4500NatTimeoutSeconds = MIN_UDP_PORT_4500_NAT_TIMEOUT_UNSET;
+
+        /** Construct Builder */
+        public Builder() {}
+
+        /**
+         * Sets the maximum supported IKEv2/IPsec NATT keepalive timeout.
+         *
+         * <p>This is used as a power-optimization hint for other IKEv2/IPsec use cases (e.g. VPNs,
+         * or IWLAN) to reduce the necessary keepalive frequency, thus conserving power and data.
+         *
+         * @param minUdpPort4500NatTimeoutSeconds the maximum keepalive timeout supported by the VCN
+         *     Gateway Connection, generally the minimum duration a NAT mapping is cached on the VCN
+         *     Gateway.
+         * @return this {@link Builder} instance, for chaining
+         */
+        @NonNull
+        public Builder setMinUdpPort4500NatTimeoutSeconds(
+                @IntRange(from = MIN_UDP_PORT_4500_NAT_TIMEOUT_SECONDS)
+                        int minUdpPort4500NatTimeoutSeconds) {
+            Preconditions.checkArgument(
+                    minUdpPort4500NatTimeoutSeconds >= MIN_UDP_PORT_4500_NAT_TIMEOUT_SECONDS,
+                    "Timeout must be at least 120s");
+
+            mMinUdpPort4500NatTimeoutSeconds = minUdpPort4500NatTimeoutSeconds;
+            return Builder.this;
+        }
+
+        /** Build a VcnTransportInfo instance */
+        @NonNull
+        public VcnTransportInfo build() {
+            return new VcnTransportInfo(
+                    null /* wifiInfo */, INVALID_SUBSCRIPTION_ID, mMinUdpPort4500NatTimeoutSeconds);
+        }
+    }
 }
diff --git a/core/java/android/net/vcn/VcnUtils.java b/core/java/android/net/vcn/VcnUtils.java
new file mode 100644
index 0000000..6dc5180
--- /dev/null
+++ b/core/java/android/net/vcn/VcnUtils.java
@@ -0,0 +1,97 @@
+/*
+ * 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 android.net.vcn;
+
+import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkSpecifier;
+import android.net.TelephonyNetworkSpecifier;
+import android.net.TransportInfo;
+import android.net.wifi.WifiInfo;
+
+import java.util.List;
+
+/**
+ * Utility class for VCN callers get information from VCN network
+ *
+ * @hide
+ */
+public class VcnUtils {
+    /** Get the WifiInfo of the VCN's underlying WiFi network */
+    @Nullable
+    public static WifiInfo getWifiInfoFromVcnCaps(
+            @NonNull ConnectivityManager connectivityMgr,
+            @NonNull NetworkCapabilities networkCapabilities) {
+        final NetworkCapabilities underlyingCaps =
+                getVcnUnderlyingCaps(connectivityMgr, networkCapabilities);
+
+        if (underlyingCaps == null) {
+            return null;
+        }
+
+        final TransportInfo underlyingTransportInfo = underlyingCaps.getTransportInfo();
+        if (!(underlyingTransportInfo instanceof WifiInfo)) {
+            return null;
+        }
+
+        return (WifiInfo) underlyingTransportInfo;
+    }
+
+    /** Get the subscription ID of the VCN's underlying Cell network */
+    public static int getSubIdFromVcnCaps(
+            @NonNull ConnectivityManager connectivityMgr,
+            @NonNull NetworkCapabilities networkCapabilities) {
+        final NetworkCapabilities underlyingCaps =
+                getVcnUnderlyingCaps(connectivityMgr, networkCapabilities);
+
+        if (underlyingCaps == null) {
+            return INVALID_SUBSCRIPTION_ID;
+        }
+
+        final NetworkSpecifier underlyingNetworkSpecifier = underlyingCaps.getNetworkSpecifier();
+        if (!(underlyingNetworkSpecifier instanceof TelephonyNetworkSpecifier)) {
+            return INVALID_SUBSCRIPTION_ID;
+        }
+
+        return ((TelephonyNetworkSpecifier) underlyingNetworkSpecifier).getSubscriptionId();
+    }
+
+    @Nullable
+    private static NetworkCapabilities getVcnUnderlyingCaps(
+            @NonNull ConnectivityManager connectivityMgr,
+            @NonNull NetworkCapabilities networkCapabilities) {
+        // Return null if it is not a VCN network
+        if (networkCapabilities.getTransportInfo() == null
+                || !(networkCapabilities.getTransportInfo() instanceof VcnTransportInfo)) {
+            return null;
+        }
+
+        // As of Android 16, VCN has one underlying network, and only one. If there are more
+        // than one networks due to future changes in the VCN mainline code, just take the first
+        // network
+        final List<Network> underlyingNws = networkCapabilities.getUnderlyingNetworks();
+        if (underlyingNws == null) {
+            return null;
+        }
+
+        return connectivityMgr.getNetworkCapabilities(underlyingNws.get(0));
+    }
+}
diff --git a/core/java/android/os/Build.java b/core/java/android/os/Build.java
index a8267d1..479aa98 100644
--- a/core/java/android/os/Build.java
+++ b/core/java/android/os/Build.java
@@ -22,6 +22,7 @@
 import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
 import android.annotation.SuppressAutoDoc;
+import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.annotation.TestApi;
 import android.app.ActivityThread;
@@ -401,33 +402,42 @@
          * device. This value never changes while a device is booted, but it may
          * increase when the hardware manufacturer provides an OTA update.
          * <p>
-         * Together with {@link SDK_MINOR_INT}, this constant defines the
-         * <pre>major.minor</pre> version of Android. <pre>SDK_INT</pre> is
-         * increased and <pre>SDK_MINOR_INT</pre> is set to 0 on new Android
-         * dessert releases. Between these, Android may also release so called
-         * minor releases where <pre>SDK_INT</pre> remains unchanged and
-         * <pre>SDK_MINOR_INT</pre> is increased. Minor releases can add new
-         * APIs, and have stricter guarantees around backwards compatibility
-         * (e.g. no changes gated by <pre>targetSdkVersion</pre>) compared to
-         * major releases.
+         * This constant records the major version of Android. Use {@link
+         * SDK_INT_FULL} if you need to consider the minor version of Android
+         * as well.
          * <p>
          * Possible values are defined in {@link Build.VERSION_CODES}.
+         * @see #SDK_INT_FULL
          */
         public static final int SDK_INT = SystemProperties.getInt(
                 "ro.build.version.sdk", 0);
 
         /**
-         * The minor SDK version of the software currently running on this hardware
-         * device. This value never changes while a device is booted, but it may
-         * increase when the hardware manufacturer provides an OTA update.
+         * The major and minor SDK version of the software currently running on
+         * this hardware device. This value never changes while a device is
+         * booted, but it may increase when the hardware manufacturer provides
+         * an OTA update.
          * <p>
-         * Together with {@link SDK_INT}, this constant defines the
-         * <pre>major.minor</pre> version of Android. See {@link SDK_INT} for
-         * more information.
+         * <code>SDK_INT</code> is increased on new Android dessert releases,
+         * also called major releases. Between these, Android may also release
+         * minor releases where <code>SDK_INT</code> remains unchanged. Minor
+         * releases can add new APIs, and have stricter guarantees around
+         * backwards compatibility (e.g. no changes gated by
+         * <code>targetSdkVersion</code>) compared to major releases.
+         * <p>
+         * <code>SDK_INT_FULL</code> is increased on every release.
+         * <p>
+         * Possible values are defined in {@link
+         * android.os.Build.VERSION_CODES_FULL}.
          */
         @FlaggedApi(Flags.FLAG_MAJOR_MINOR_VERSIONING_SCHEME)
-        public static final int SDK_MINOR_INT = SystemProperties.getInt(
-                "ro.build.version.sdk_minor", 0);
+        public static final int SDK_INT_FULL;
+
+        static {
+            SDK_INT_FULL = VERSION_CODES_FULL.SDK_INT_MULTIPLIER
+                * SystemProperties.getInt("ro.build.version.sdk", 0)
+                + SystemProperties.getInt("ro.build.version.sdk_minor", 0);
+        }
 
         /**
          * The SDK version of the software that <em>initially</em> shipped on
@@ -1264,6 +1274,25 @@
     }
 
     /**
+     * Enumeration of the currently known SDK major and minor version codes.
+     * The numbers increase for every release, and are guaranteed to be ordered
+     * by the release date of each release. The actual values should be
+     * considered an implementation detail, and the current encoding scheme may
+     * change in the future.
+     *
+     * @see android.os.Build.VERSION#SDK_INT_FULL
+     */
+    @FlaggedApi(Flags.FLAG_MAJOR_MINOR_VERSIONING_SCHEME)
+    @SuppressLint("AcronymName")
+    public static class VERSION_CODES_FULL {
+        private VERSION_CODES_FULL() {}
+
+        // Use the last 5 digits for the minor version. This allows the
+        // minor version to be set to CUR_DEVELOPMENT.
+        private static final int SDK_INT_MULTIPLIER = 100000;
+    }
+
+    /**
      * The vendor API for 2024 Q2
      *
      * <p>For Android 14-QPR3 and later, the vendor API level is completely decoupled from the SDK
diff --git a/core/java/android/os/GraphicsEnvironment.java b/core/java/android/os/GraphicsEnvironment.java
index beb9a93..2d3dd1b 100644
--- a/core/java/android/os/GraphicsEnvironment.java
+++ b/core/java/android/os/GraphicsEnvironment.java
@@ -598,6 +598,11 @@
         final String abi = chooseAbi(angleInfo);
 
         // Build a path that includes installed native libs and APK
+        // TODO (b/370113081): If the native libraries are not found in this path,
+        // the system libraries will be loaded instead.
+        // This can happen if the ANGLE APK is present,
+        // but accidentally packaged without native libraries.
+        // TBD if this should fail instead of falling back to the system version.
         final String paths = angleInfo.nativeLibraryDir
                 + File.pathSeparator
                 + angleInfo.sourceDir
diff --git a/core/java/android/os/RemoteCallbackList.java b/core/java/android/os/RemoteCallbackList.java
index 2cb86f7..769cbdd 100644
--- a/core/java/android/os/RemoteCallbackList.java
+++ b/core/java/android/os/RemoteCallbackList.java
@@ -92,9 +92,9 @@
     /**
      * Add a new callback to the list.  This callback will remain in the list
      * until a corresponding call to {@link #unregister} or its hosting process
-     * goes away.  If the callback was already registered (determined by
+     * goes away. If the callback was already registered (determined by
      * checking to see if the {@link IInterface#asBinder callback.asBinder()}
-     * object is already in the list), then it will be left as-is.
+     * object is already in the list), then it will be replaced with the new callback.
      * Registrations are not counted; a single call to {@link #unregister}
      * will remove a callback after any number calls to register it.
      *
@@ -106,7 +106,7 @@
      *
      * @param cookie Optional additional data to be associated with this
      * callback.
-     * 
+     *
      * @return Returns true if the callback was successfully added to the list.
      * Returns false if it was not added, either because {@link #kill} had
      * previously been called or the callback's process has gone away.
diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java
index d82af55..a2c41c1 100644
--- a/core/java/android/provider/Settings.java
+++ b/core/java/android/provider/Settings.java
@@ -2092,23 +2092,6 @@
     public static final String ACTION_ZEN_MODE_SETTINGS = "android.settings.ZEN_MODE_SETTINGS";
 
     /**
-     * Activity Action: Show Zen Mode visual effects configuration settings.
-     *
-     * @hide
-     */
-    @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
-    public static final String ZEN_MODE_BLOCKED_EFFECTS_SETTINGS =
-            "android.settings.ZEN_MODE_BLOCKED_EFFECTS_SETTINGS";
-
-    /**
-     * Activity Action: Show Zen Mode onboarding activity.
-     *
-     * @hide
-     */
-    @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
-    public static final String ZEN_MODE_ONBOARDING = "android.settings.ZEN_MODE_ONBOARDING";
-
-    /**
      * Activity Action: Show Zen Mode (aka Do Not Disturb) priority configuration settings.
      */
     @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)
@@ -8748,35 +8731,6 @@
         /** @hide */ public static final int ZEN_DURATION_FOREVER = 0;
 
         /**
-         * If nonzero, will show the zen upgrade notification when the user toggles DND on/off.
-         * @hide
-         */
-        @Readable
-        public static final String SHOW_ZEN_UPGRADE_NOTIFICATION = "show_zen_upgrade_notification";
-
-        /**
-         * If nonzero, will show the zen update settings suggestion.
-         * @hide
-         */
-        @Readable
-        public static final String SHOW_ZEN_SETTINGS_SUGGESTION = "show_zen_settings_suggestion";
-
-        /**
-         * If nonzero, zen has not been updated to reflect new changes.
-         * @hide
-         */
-        @Readable
-        public static final String ZEN_SETTINGS_UPDATED = "zen_settings_updated";
-
-        /**
-         * If nonzero, zen setting suggestion has been viewed by user
-         * @hide
-         */
-        @Readable
-        public static final String ZEN_SETTINGS_SUGGESTION_VIEWED =
-                "zen_settings_suggestion_viewed";
-
-        /**
          * Whether the in call notification is enabled to play sound during calls.  The value is
          * boolean (1 or 0).
          * @hide
@@ -18072,10 +18026,6 @@
             MOVED_TO_SECURE = new HashSet<>(8);
             MOVED_TO_SECURE.add(Global.INSTALL_NON_MARKET_APPS);
             MOVED_TO_SECURE.add(Global.ZEN_DURATION);
-            MOVED_TO_SECURE.add(Global.SHOW_ZEN_UPGRADE_NOTIFICATION);
-            MOVED_TO_SECURE.add(Global.SHOW_ZEN_SETTINGS_SUGGESTION);
-            MOVED_TO_SECURE.add(Global.ZEN_SETTINGS_UPDATED);
-            MOVED_TO_SECURE.add(Global.ZEN_SETTINGS_SUGGESTION_VIEWED);
             MOVED_TO_SECURE.add(Global.CHARGING_SOUNDS_ENABLED);
             MOVED_TO_SECURE.add(Global.CHARGING_VIBRATION_ENABLED);
             MOVED_TO_SECURE.add(Global.NOTIFICATION_BUBBLES);
@@ -18910,40 +18860,6 @@
         @Readable
         public static final String SHOW_MUTE_IN_CRASH_DIALOG = "show_mute_in_crash_dialog";
 
-
-        /**
-         * If nonzero, will show the zen upgrade notification when the user toggles DND on/off.
-         * @hide
-         * @deprecated - Use {@link android.provider.Settings.Secure#SHOW_ZEN_UPGRADE_NOTIFICATION}
-         */
-        @Deprecated
-        public static final String SHOW_ZEN_UPGRADE_NOTIFICATION = "show_zen_upgrade_notification";
-
-        /**
-         * If nonzero, will show the zen update settings suggestion.
-         * @hide
-         * @deprecated - Use {@link android.provider.Settings.Secure#SHOW_ZEN_SETTINGS_SUGGESTION}
-         */
-        @Deprecated
-        public static final String SHOW_ZEN_SETTINGS_SUGGESTION = "show_zen_settings_suggestion";
-
-        /**
-         * If nonzero, zen has not been updated to reflect new changes.
-         * @deprecated - Use {@link android.provider.Settings.Secure#ZEN_SETTINGS_UPDATED}
-         * @hide
-         */
-        @Deprecated
-        public static final String ZEN_SETTINGS_UPDATED = "zen_settings_updated";
-
-        /**
-         * If nonzero, zen setting suggestion has been viewed by user
-         * @hide
-         * @deprecated - Use {@link android.provider.Settings.Secure#ZEN_SETTINGS_SUGGESTION_VIEWED}
-         */
-        @Deprecated
-        public static final String ZEN_SETTINGS_SUGGESTION_VIEWED =
-                "zen_settings_suggestion_viewed";
-
         /**
          * Backup and restore agent timeout parameters.
          * These parameters are represented by a comma-delimited key-value list.
diff --git a/core/java/android/service/notification/NotificationAssistantService.java b/core/java/android/service/notification/NotificationAssistantService.java
index 48d7cf7..7b7058e 100644
--- a/core/java/android/service/notification/NotificationAssistantService.java
+++ b/core/java/android/service/notification/NotificationAssistantService.java
@@ -393,6 +393,23 @@
         }
     }
 
+    /**
+     * Informs the notification manager about what {@link Adjustment Adjustments} are supported by
+     * this NAS.
+     *
+     * For backwards compatibility, we assume all Adjustment types are supported by the NAS.
+     */
+    @FlaggedApi(Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+    public final void setAdjustmentTypeSupportedState(@NonNull @Adjustment.Keys String key,
+            boolean supported) {
+        if (!isBound()) return;
+        try {
+            getNotificationInterface().setAdjustmentTypeSupportedState(mWrapper, key, supported);
+        } catch (android.os.RemoteException ex) {
+            Log.v(TAG, "Unable to contact notification manager", ex);
+        }
+    }
+
     private class NotificationAssistantServiceWrapper extends NotificationListenerWrapper {
         @Override
         public void onNotificationEnqueuedWithChannel(IStatusBarNotificationHolder sbnHolder,
diff --git a/core/java/android/service/notification/ZenModeDiff.java b/core/java/android/service/notification/ZenModeDiff.java
index 60a7d6b..c9f4647 100644
--- a/core/java/android/service/notification/ZenModeDiff.java
+++ b/core/java/android/service/notification/ZenModeDiff.java
@@ -16,6 +16,7 @@
 
 package android.service.notification;
 
+import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
 import android.annotation.Nullable;
 import android.app.Flags;
@@ -24,6 +25,7 @@
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
+import java.util.LinkedHashMap;
 import java.util.Objects;
 import java.util.Set;
 
@@ -62,6 +64,7 @@
     public static class FieldDiff<T> {
         private final T mFrom;
         private final T mTo;
+        private final BaseDiff mDetailedDiff;
 
         /**
          * Constructor to create a FieldDiff object with the given values.
@@ -71,6 +74,19 @@
         public FieldDiff(@Nullable T from, @Nullable T to) {
             mFrom = from;
             mTo = to;
+            mDetailedDiff = null;
+        }
+
+        /**
+         * Constructor to create a FieldDiff object with the given values, and that has a
+         * detailed BaseDiff.
+         * @param from from (old) value
+         * @param to to (new) value
+         */
+        public FieldDiff(@Nullable T from, @Nullable T to, @Nullable BaseDiff detailedDiff) {
+            mFrom = from;
+            mTo = to;
+            mDetailedDiff = detailedDiff;
         }
 
         /**
@@ -92,6 +108,9 @@
          */
         @Override
         public String toString() {
+            if (mDetailedDiff != null) {
+                return mDetailedDiff.toString();
+            }
             return mFrom + "->" + mTo;
         }
 
@@ -99,6 +118,9 @@
          * Returns whether this represents an actual diff.
          */
         public boolean hasDiff() {
+            if (mDetailedDiff != null) {
+                return mDetailedDiff.hasDiff();
+            }
             // note that Objects.equals handles null values gracefully.
             return !Objects.equals(mFrom, mTo);
         }
@@ -114,7 +136,8 @@
         @ExistenceChange private int mExists = NONE;
 
         // Map from field name to diffs for any standalone fields in the object.
-        private ArrayMap<String, FieldDiff> mFields = new ArrayMap<>();
+        // LinkedHashMap is specifically chosen here to show insertion order when keys are fetched.
+        private LinkedHashMap<String, FieldDiff> mFields = new LinkedHashMap<>();
 
         // Functions for actually diffing objects and string representations have to be implemented
         // by subclasses.
@@ -549,8 +572,16 @@
             if (!Objects.equals(from.enabler, to.enabler)) {
                 addField(FIELD_ENABLER, new FieldDiff<>(from.enabler, to.enabler));
             }
-            if (!Objects.equals(from.zenPolicy, to.zenPolicy)) {
-                addField(FIELD_ZEN_POLICY, new FieldDiff<>(from.zenPolicy, to.zenPolicy));
+            if (android.app.Flags.modesApi()) {
+                PolicyDiff policyDiff = new PolicyDiff(from.zenPolicy, to.zenPolicy);
+                if (policyDiff.hasDiff()) {
+                    addField(FIELD_ZEN_POLICY, new FieldDiff<>(from.zenPolicy, to.zenPolicy,
+                            policyDiff));
+                }
+            } else {
+                if (!Objects.equals(from.zenPolicy, to.zenPolicy)) {
+                    addField(FIELD_ZEN_POLICY, new FieldDiff<>(from.zenPolicy, to.zenPolicy));
+                }
             }
             if (from.modified != to.modified) {
                 addField(FIELD_MODIFIED, new FieldDiff<>(from.modified, to.modified));
@@ -559,9 +590,12 @@
                 addField(FIELD_PKG, new FieldDiff<>(from.pkg, to.pkg));
             }
             if (android.app.Flags.modesApi()) {
-                if (!Objects.equals(from.zenDeviceEffects, to.zenDeviceEffects)) {
+                DeviceEffectsDiff deviceEffectsDiff = new DeviceEffectsDiff(from.zenDeviceEffects,
+                        to.zenDeviceEffects);
+                if (deviceEffectsDiff.hasDiff()) {
                     addField(FIELD_ZEN_DEVICE_EFFECTS,
-                            new FieldDiff<>(from.zenDeviceEffects, to.zenDeviceEffects));
+                            new FieldDiff<>(from.zenDeviceEffects, to.zenDeviceEffects,
+                                    deviceEffectsDiff));
                 }
                 if (!Objects.equals(from.triggerDescription, to.triggerDescription)) {
                     addField(FIELD_TRIGGER_DESCRIPTION,
@@ -629,7 +663,7 @@
 
                 sb.append(key);
                 sb.append(":");
-                sb.append(diff);
+                sb.append(diff.toString());
             }
 
             if (becameActive()) {
@@ -663,4 +697,338 @@
             return mActiveDiff != null && !mActiveDiff.to();
         }
     }
+
+    /**
+     * Diff class representing a change between two
+     * {@link android.service.notification.ZenDeviceEffects}.
+     */
+    @FlaggedApi(Flags.FLAG_MODES_API)
+    public static class DeviceEffectsDiff extends BaseDiff {
+        public static final String FIELD_GRAYSCALE = "mGrayscale";
+        public static final String FIELD_SUPPRESS_AMBIENT_DISPLAY = "mSuppressAmbientDisplay";
+        public static final String FIELD_DIM_WALLPAPER = "mDimWallpaper";
+        public static final String FIELD_NIGHT_MODE = "mNightMode";
+        public static final String FIELD_DISABLE_AUTO_BRIGHTNESS = "mDisableAutoBrightness";
+        public static final String FIELD_DISABLE_TAP_TO_WAKE = "mDisableTapToWake";
+        public static final String FIELD_DISABLE_TILT_TO_WAKE = "mDisableTiltToWake";
+        public static final String FIELD_DISABLE_TOUCH = "mDisableTouch";
+        public static final String FIELD_MINIMIZE_RADIO_USAGE = "mMinimizeRadioUsage";
+        public static final String FIELD_MAXIMIZE_DOZE = "mMaximizeDoze";
+        public static final String FIELD_EXTRA_EFFECTS = "mExtraEffects";
+        // NOTE: new field strings must match the variable names in ZenDeviceEffects
+
+        /**
+         * Create a DeviceEffectsDiff representing the difference between two ZenDeviceEffects
+         * objects.
+         * @param from previous ZenDeviceEffects
+         * @param to new ZenDeviceEffects
+         * @return The diff between the two given ZenDeviceEffects
+         */
+        public DeviceEffectsDiff(ZenDeviceEffects from, ZenDeviceEffects to) {
+            super(from, to);
+            // Short-circuit the both-null case
+            if (from == null && to == null) {
+                return;
+            }
+            if (hasExistenceChange()) {
+                // either added or removed; return here. otherwise (they're not both null) there's
+                // field diffs.
+                return;
+            }
+
+            // Compare all fields, knowing there's some diff and that neither is null.
+            if (from.shouldDisplayGrayscale() != to.shouldDisplayGrayscale()) {
+                addField(FIELD_GRAYSCALE, new FieldDiff<>(from.shouldDisplayGrayscale(),
+                        to.shouldDisplayGrayscale()));
+            }
+            if (from.shouldSuppressAmbientDisplay() != to.shouldSuppressAmbientDisplay()) {
+                addField(FIELD_SUPPRESS_AMBIENT_DISPLAY,
+                        new FieldDiff<>(from.shouldSuppressAmbientDisplay(),
+                                to.shouldSuppressAmbientDisplay()));
+            }
+            if (from.shouldDimWallpaper() != to.shouldDimWallpaper()) {
+                addField(FIELD_DIM_WALLPAPER, new FieldDiff<>(from.shouldDimWallpaper(),
+                        to.shouldDimWallpaper()));
+            }
+            if (from.shouldUseNightMode() != to.shouldUseNightMode()) {
+                addField(FIELD_NIGHT_MODE, new FieldDiff<>(from.shouldUseNightMode(),
+                        to.shouldUseNightMode()));
+            }
+            if (from.shouldDisableAutoBrightness() != to.shouldDisableAutoBrightness()) {
+                addField(FIELD_DISABLE_AUTO_BRIGHTNESS,
+                        new FieldDiff<>(from.shouldDisableAutoBrightness(),
+                                to.shouldDisableAutoBrightness()));
+            }
+            if (from.shouldDisableTapToWake() != to.shouldDisableTapToWake()) {
+                addField(FIELD_DISABLE_TAP_TO_WAKE, new FieldDiff<>(from.shouldDisableTapToWake(),
+                        to.shouldDisableTapToWake()));
+            }
+            if (from.shouldDisableTiltToWake() != to.shouldDisableTiltToWake()) {
+                addField(FIELD_DISABLE_TILT_TO_WAKE,
+                        new FieldDiff<>(from.shouldDisableTiltToWake(),
+                                to.shouldDisableTiltToWake()));
+            }
+            if (from.shouldDisableTouch() != to.shouldDisableTouch()) {
+                addField(FIELD_DISABLE_TOUCH, new FieldDiff<>(from.shouldDisableTouch(),
+                        to.shouldDisableTouch()));
+            }
+            if (from.shouldMinimizeRadioUsage() != to.shouldMinimizeRadioUsage()) {
+                addField(FIELD_MINIMIZE_RADIO_USAGE,
+                        new FieldDiff<>(from.shouldMinimizeRadioUsage(),
+                                to.shouldMinimizeRadioUsage()));
+            }
+            if (from.shouldMaximizeDoze() != to.shouldMaximizeDoze()) {
+                addField(FIELD_MAXIMIZE_DOZE, new FieldDiff<>(from.shouldMaximizeDoze(),
+                        to.shouldMaximizeDoze()));
+            }
+            if (!Objects.equals(from.getExtraEffects(), to.getExtraEffects())) {
+                addField(FIELD_EXTRA_EFFECTS, new FieldDiff<>(from.getExtraEffects(),
+                        to.getExtraEffects()));
+            }
+        }
+
+        /**
+         * Returns whether this object represents an actual diff.
+         */
+        @Override
+        public boolean hasDiff() {
+            return hasExistenceChange() || hasFieldDiffs();
+        }
+
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder("ZenDeviceEffectsDiff{");
+            if (!hasDiff()) {
+                sb.append("no changes");
+            }
+
+            // If added or deleted, we just append that.
+            if (hasExistenceChange()) {
+                if (wasAdded()) {
+                    sb.append("added");
+                } else if (wasRemoved()) {
+                    sb.append("removed");
+                }
+            }
+
+            // Append all of the individual field diffs
+            boolean first = true;
+            for (String key : fieldNamesWithDiff()) {
+                FieldDiff diff = getDiffForField(key);
+                if (diff == null) {
+                    // The diff should not have null diffs added, but we add this to be defensive.
+                    continue;
+                }
+                if (first) {
+                    first = false;
+                } else {
+                    sb.append(", ");
+                }
+
+                sb.append(key);
+                sb.append(":");
+                sb.append(diff);
+            }
+
+            return sb.append("}").toString();
+        }
+    }
+
+    /**
+     * Diff class representing a change between two {@link android.service.notification.ZenPolicy}.
+     */
+    @FlaggedApi(Flags.FLAG_MODES_API)
+    public static class PolicyDiff extends BaseDiff {
+        public static final String FIELD_PRIORITY_CATEGORY_REMINDERS =
+                "mPriorityCategories_Reminders";
+        public static final String FIELD_PRIORITY_CATEGORY_EVENTS = "mPriorityCategories_Events";
+        public static final String FIELD_PRIORITY_CATEGORY_MESSAGES =
+                "mPriorityCategories_Messages";
+        public static final String FIELD_PRIORITY_CATEGORY_CALLS = "mPriorityCategories_Calls";
+        public static final String FIELD_PRIORITY_CATEGORY_REPEAT_CALLERS =
+                "mPriorityCategories_RepeatCallers";
+        public static final String FIELD_PRIORITY_CATEGORY_ALARMS = "mPriorityCategories_Alarms";
+        public static final String FIELD_PRIORITY_CATEGORY_MEDIA = "mPriorityCategories_Media";
+        public static final String FIELD_PRIORITY_CATEGORY_SYSTEM = "mPriorityCategories_System";
+        public static final String FIELD_PRIORITY_CATEGORY_CONVERSATIONS =
+                "mPriorityCategories_Conversations";
+
+        public static final String FIELD_VISUAL_EFFECT_FULL_SCREEN_INTENT =
+                "mVisualEffects_FullScreenIntent";
+        public static final String FIELD_VISUAL_EFFECT_LIGHTS = "mVisualEffects_Lights";
+        public static final String FIELD_VISUAL_EFFECT_PEEK = "mVisualEffects_Peek";
+        public static final String FIELD_VISUAL_EFFECT_STATUS_BAR = "mVisualEffects_StatusBar";
+        public static final String FIELD_VISUAL_EFFECT_BADGE = "mVisualEffects_Badge";
+        public static final String FIELD_VISUAL_EFFECT_AMBIENT = "mVisualEffects_Ambient";
+        public static final String FIELD_VISUAL_EFFECT_NOTIFICATION_LIST =
+                "mVisualEffects_NotificationList";
+
+        public static final String FIELD_PRIORITY_MESSAGES = "mPriorityMessages";
+        public static final String FIELD_PRIORITY_CALLS = "mPriorityCalls";
+        public static final String FIELD_CONVERSATION_SENDERS = "mConversationSenders";
+        public static final String FIELD_ALLOW_CHANNELS = "mAllowChannels";
+
+        /**
+         * Create a PolicyDiff representing the difference between two ZenPolicy objects.
+         *
+         * @param from previous ZenPolicy
+         * @param to   new ZenPolicy
+         * @return The diff between the two given ZenPolicy
+         */
+        public PolicyDiff(ZenPolicy from, ZenPolicy to) {
+            super(from, to);
+            // Short-circuit the both-null case
+            if (from == null && to == null) {
+                return;
+            }
+            if (hasExistenceChange()) {
+                // either added or removed; return here. otherwise (they're not both null) there's
+                // field diffs.
+                return;
+            }
+
+            // Compare all fields, knowing there's some diff and that neither is null.
+            if (from.getPriorityCategoryReminders() != to.getPriorityCategoryReminders()) {
+                addField(FIELD_PRIORITY_CATEGORY_REMINDERS,
+                        new FieldDiff<>(from.getPriorityCategoryReminders(),
+                                to.getPriorityCategoryReminders()));
+            }
+            if (from.getPriorityCategoryEvents() != to.getPriorityCategoryEvents()) {
+                addField(FIELD_PRIORITY_CATEGORY_EVENTS,
+                        new FieldDiff<>(from.getPriorityCategoryEvents(),
+                                to.getPriorityCategoryEvents()));
+            }
+            if (from.getPriorityCategoryMessages() != to.getPriorityCategoryMessages()) {
+                addField(FIELD_PRIORITY_CATEGORY_MESSAGES,
+                        new FieldDiff<>(from.getPriorityCategoryMessages(),
+                                to.getPriorityCategoryMessages()));
+            }
+            if (from.getPriorityCategoryCalls() != to.getPriorityCategoryCalls()) {
+                addField(FIELD_PRIORITY_CATEGORY_CALLS,
+                        new FieldDiff<>(from.getPriorityCategoryCalls(),
+                                to.getPriorityCategoryCalls()));
+            }
+            if (from.getPriorityCategoryRepeatCallers() != to.getPriorityCategoryRepeatCallers()) {
+                addField(FIELD_PRIORITY_CATEGORY_REPEAT_CALLERS,
+                        new FieldDiff<>(from.getPriorityCategoryRepeatCallers(),
+                                to.getPriorityCategoryRepeatCallers()));
+            }
+            if (from.getPriorityCategoryAlarms() != to.getPriorityCategoryAlarms()) {
+                addField(FIELD_PRIORITY_CATEGORY_ALARMS,
+                        new FieldDiff<>(from.getPriorityCategoryAlarms(),
+                                to.getPriorityCategoryAlarms()));
+            }
+            if (from.getPriorityCategoryMedia() != to.getPriorityCategoryMedia()) {
+                addField(FIELD_PRIORITY_CATEGORY_MEDIA,
+                        new FieldDiff<>(from.getPriorityCategoryMedia(),
+                                to.getPriorityCategoryMedia()));
+            }
+            if (from.getPriorityCategorySystem() != to.getPriorityCategorySystem()) {
+                addField(FIELD_PRIORITY_CATEGORY_SYSTEM,
+                        new FieldDiff<>(from.getPriorityCategorySystem(),
+                                to.getPriorityCategorySystem()));
+            }
+            if (from.getPriorityCategoryConversations() != to.getPriorityCategoryConversations()) {
+                addField(FIELD_PRIORITY_CATEGORY_CONVERSATIONS,
+                        new FieldDiff<>(from.getPriorityCategoryConversations(),
+                                to.getPriorityCategoryConversations()));
+            }
+            if (from.getVisualEffectFullScreenIntent() != to.getVisualEffectFullScreenIntent()) {
+                addField(FIELD_VISUAL_EFFECT_FULL_SCREEN_INTENT,
+                        new FieldDiff<>(from.getVisualEffectFullScreenIntent(),
+                                to.getVisualEffectFullScreenIntent()));
+            }
+            if (from.getVisualEffectLights() != to.getVisualEffectLights()) {
+                addField(FIELD_VISUAL_EFFECT_LIGHTS,
+                        new FieldDiff<>(from.getVisualEffectLights(), to.getVisualEffectLights()));
+            }
+            if (from.getVisualEffectPeek() != to.getVisualEffectPeek()) {
+                addField(FIELD_VISUAL_EFFECT_PEEK, new FieldDiff<>(from.getVisualEffectPeek(),
+                        to.getVisualEffectPeek()));
+            }
+            if (from.getVisualEffectStatusBar() != to.getVisualEffectStatusBar()) {
+                addField(FIELD_VISUAL_EFFECT_STATUS_BAR,
+                        new FieldDiff<>(from.getVisualEffectStatusBar(),
+                                to.getVisualEffectStatusBar()));
+            }
+            if (from.getVisualEffectBadge() != to.getVisualEffectBadge()) {
+                addField(FIELD_VISUAL_EFFECT_BADGE, new FieldDiff<>(from.getVisualEffectBadge(),
+                        to.getVisualEffectBadge()));
+            }
+            if (from.getVisualEffectAmbient() != to.getVisualEffectAmbient()) {
+                addField(FIELD_VISUAL_EFFECT_AMBIENT, new FieldDiff<>(from.getVisualEffectAmbient(),
+                        to.getVisualEffectAmbient()));
+            }
+            if (from.getVisualEffectNotificationList() != to.getVisualEffectNotificationList()) {
+                addField(FIELD_VISUAL_EFFECT_NOTIFICATION_LIST,
+                        new FieldDiff<>(from.getVisualEffectNotificationList(),
+                                to.getVisualEffectNotificationList()));
+            }
+            if (from.getPriorityMessageSenders() != to.getPriorityMessageSenders()) {
+                addField(FIELD_PRIORITY_MESSAGES, new FieldDiff<>(from.getPriorityMessageSenders(),
+                        to.getPriorityMessageSenders()));
+            }
+            if (from.getPriorityCallSenders() != to.getPriorityCallSenders()) {
+                addField(FIELD_PRIORITY_CALLS, new FieldDiff<>(from.getPriorityCallSenders(),
+                        to.getPriorityCallSenders()));
+            }
+            if (from.getPriorityConversationSenders() != to.getPriorityConversationSenders()) {
+                addField(FIELD_CONVERSATION_SENDERS,
+                        new FieldDiff<>(from.getPriorityConversationSenders(),
+                                to.getPriorityConversationSenders()));
+            }
+            if (from.getPriorityChannelsAllowed() != to.getPriorityChannelsAllowed()) {
+                addField(FIELD_ALLOW_CHANNELS, new FieldDiff<>(from.getPriorityChannelsAllowed(),
+                        to.getPriorityChannelsAllowed()));
+            }
+        }
+
+        /**
+         * Returns whether this object represents an actual diff.
+         */
+        @Override
+        public boolean hasDiff() {
+            return hasExistenceChange() || hasFieldDiffs();
+        }
+
+        @Override
+        public String toString() {
+            final StringBuilder sb = new StringBuilder("ZenPolicyDiff{");
+            // The diff should not have null diffs added, but we add this to be defensive.
+            if (!hasDiff()) {
+                sb.append("no changes");
+            }
+
+            // If added or deleted, we just append that.
+            if (hasExistenceChange()) {
+                if (wasAdded()) {
+                    sb.append("added");
+                } else if (wasRemoved()) {
+                    sb.append("removed");
+                }
+            }
+
+            // Go through all of the individual fields
+            boolean first = true;
+            for (String key : fieldNamesWithDiff()) {
+                FieldDiff diff = getDiffForField(key);
+                if (diff == null) {
+                    // this shouldn't happen...
+                    continue;
+                }
+                if (first) {
+                    first = false;
+                } else {
+                    sb.append(", ");
+                }
+
+                sb.append(key);
+                sb.append(":");
+                sb.append(diff);
+            }
+
+            return sb.append("}").toString();
+        }
+    }
+
 }
diff --git a/core/java/android/service/notification/ZenPolicy.java b/core/java/android/service/notification/ZenPolicy.java
index be0d7b3..4cff67e 100644
--- a/core/java/android/service/notification/ZenPolicy.java
+++ b/core/java/android/service/notification/ZenPolicy.java
@@ -29,6 +29,8 @@
 import android.os.Parcelable;
 import android.util.proto.ProtoOutputStream;
 
+import androidx.annotation.VisibleForTesting;
+
 import java.io.ByteArrayOutputStream;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -207,8 +209,10 @@
 
     /**
      * Total number of priority categories. Keep updated with any updates to PriorityCategory enum.
+     * If this changes, you must update {@link ZenModeDiff.PolicyDiff} to include new categories.
      * @hide
      */
+    @VisibleForTesting
     public static final int NUM_PRIORITY_CATEGORIES = 9;
 
     /** @hide */
@@ -241,8 +245,10 @@
 
     /**
      * Total number of visual effects. Keep updated with any updates to VisualEffect enum.
+     * If this changes, you must update {@link ZenModeDiff.PolicyDiff} to include new categories.
      * @hide
      */
+    @VisibleForTesting
     public static final int NUM_VISUAL_EFFECTS = 7;
 
     /** @hide */
diff --git a/core/java/android/text/flags/flags.aconfig b/core/java/android/text/flags/flags.aconfig
index 3599332..ec5b488 100644
--- a/core/java/android/text/flags/flags.aconfig
+++ b/core/java/android/text/flags/flags.aconfig
@@ -178,3 +178,13 @@
     purpose: PURPOSE_BUGFIX
   }
 }
+
+flag {
+  name: "handwriting_unsupported_show_soft_input_fix"
+  namespace: "text"
+  description: "Don't show soft keyboard on stylus input if text field doesn't support handwriting and getShowSoftInputOnFocus() returns false."
+  bug: "363180475"
+  metadata {
+    purpose: PURPOSE_BUGFIX
+  }
+}
diff --git a/core/java/android/tracing/TEST_MAPPING b/core/java/android/tracing/TEST_MAPPING
new file mode 100644
index 0000000..b51d19d
--- /dev/null
+++ b/core/java/android/tracing/TEST_MAPPING
@@ -0,0 +1,10 @@
+{
+  "postsubmit": [
+    {
+      "name": "TracingTests"
+    },
+    {
+      "name": "ProtologPerfTests"
+    }
+  ]
+}
diff --git a/core/java/android/view/HandwritingInitiator.java b/core/java/android/view/HandwritingInitiator.java
index f132963..c217999 100644
--- a/core/java/android/view/HandwritingInitiator.java
+++ b/core/java/android/view/HandwritingInitiator.java
@@ -19,6 +19,7 @@
 import static com.android.text.flags.Flags.handwritingCursorPosition;
 import static com.android.text.flags.Flags.handwritingTrackDisabled;
 import static com.android.text.flags.Flags.handwritingUnsupportedMessage;
+import static com.android.text.flags.Flags.handwritingUnsupportedShowSoftInputFix;
 
 import android.annotation.FlaggedApi;
 import android.annotation.NonNull;
@@ -241,7 +242,11 @@
                             if (!candidateView.hasFocus()) {
                                 requestFocusWithoutReveal(candidateView);
                             }
-                            mImm.showSoftInput(candidateView, 0);
+                            if (!handwritingUnsupportedShowSoftInputFix()
+                                    || (candidateView instanceof TextView tv
+                                            && tv.getShowSoftInputOnFocus())) {
+                                mImm.showSoftInput(candidateView, 0);
+                            }
                             mState.mHandled = true;
                             mState.mShouldInitHandwriting = false;
                             motionEvent.setAction((motionEvent.getAction()
diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java
index 40a75fd..8fb17c7 100644
--- a/core/java/android/view/ViewRootImpl.java
+++ b/core/java/android/view/ViewRootImpl.java
@@ -238,7 +238,7 @@
 import android.view.accessibility.AccessibilityInteractionClient;
 import android.view.accessibility.AccessibilityManager;
 import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
-import android.view.accessibility.AccessibilityManager.HighTextContrastChangeListener;
+import android.view.accessibility.AccessibilityManager.HighContrastTextStateChangeListener;
 import android.view.accessibility.AccessibilityNodeIdManager;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
@@ -1800,8 +1800,8 @@
         }
         mAccessibilityManager.addAccessibilityStateChangeListener(
                 mAccessibilityInteractionConnectionManager, mHandler);
-        mAccessibilityManager.addHighTextContrastStateChangeListener(
-                mHighContrastTextManager, mHandler);
+        mAccessibilityManager.addHighContrastTextStateChangeListener(
+                mExecutor, mHighContrastTextManager);
         DisplayManagerGlobal
                 .getInstance()
                 .registerDisplayListener(
@@ -1838,7 +1838,7 @@
     private void unregisterListeners() {
         mAccessibilityManager.removeAccessibilityStateChangeListener(
                 mAccessibilityInteractionConnectionManager);
-        mAccessibilityManager.removeHighTextContrastStateChangeListener(
+        mAccessibilityManager.removeHighContrastTextStateChangeListener(
                 mHighContrastTextManager);
         DisplayManagerGlobal
                 .getInstance()
@@ -11907,12 +11907,12 @@
         }
     }
 
-    final class HighContrastTextManager implements HighTextContrastChangeListener {
+    final class HighContrastTextManager implements HighContrastTextStateChangeListener {
         HighContrastTextManager() {
-            ThreadedRenderer.setHighContrastText(mAccessibilityManager.isHighTextContrastEnabled());
+            ThreadedRenderer.setHighContrastText(mAccessibilityManager.isHighContrastTextEnabled());
         }
         @Override
-        public void onHighTextContrastStateChanged(boolean enabled) {
+        public void onHighContrastTextStateChanged(boolean enabled) {
             ThreadedRenderer.setHighContrastText(enabled);
 
             destroyAndInvalidate();
diff --git a/core/java/android/view/accessibility/AccessibilityManager.java b/core/java/android/view/accessibility/AccessibilityManager.java
index 2b7cf42..fd57aec 100644
--- a/core/java/android/view/accessibility/AccessibilityManager.java
+++ b/core/java/android/view/accessibility/AccessibilityManager.java
@@ -253,7 +253,7 @@
     boolean mIsTouchExplorationEnabled;
 
     @UnsupportedAppUsage(trackingBug = 123768939L)
-    boolean mIsHighTextContrastEnabled;
+    boolean mIsHighContrastTextEnabled;
 
     boolean mIsAudioDescriptionByDefaultRequested;
 
@@ -276,8 +276,8 @@
     private final ArrayMap<TouchExplorationStateChangeListener, Handler>
             mTouchExplorationStateChangeListeners = new ArrayMap<>();
 
-    private final ArrayMap<HighTextContrastChangeListener, Handler>
-            mHighTextContrastStateChangeListeners = new ArrayMap<>();
+    private final ArrayMap<HighContrastTextStateChangeListener, Executor>
+            mHighContrastTextStateChangeListeners = new ArrayMap<>();
 
     private final ArrayMap<AccessibilityServicesStateChangeListener, Executor>
             mServicesStateChangeListeners = new ArrayMap<>();
@@ -356,21 +356,20 @@
     }
 
     /**
-     * Listener for the system high text contrast state. To listen for changes to
-     * the high text contrast state on the device, implement this interface and
+     * Listener for the system high contrast text state. To listen for changes to
+     * the high contrast text state on the device, implement this interface and
      * register it with the system by calling
-     * {@link #addHighTextContrastStateChangeListener}.
-     *
-     * @hide
+     * {@link #addHighContrastTextStateChangeListener}.
      */
-    public interface HighTextContrastChangeListener {
+    @FlaggedApi(com.android.graphics.hwui.flags.Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
+    public interface HighContrastTextStateChangeListener {
 
         /**
-         * Called when the high text contrast enabled state changes.
+         * Called when the high contrast text enabled state changes.
          *
-         * @param enabled Whether high text contrast is enabled.
+         * @param enabled Whether high contrast text is enabled.
          */
-        void onHighTextContrastStateChanged(boolean enabled);
+        void onHighContrastTextStateChanged(boolean enabled);
     }
 
     /**
@@ -655,24 +654,23 @@
     }
 
     /**
-     * Returns if the high text contrast in the system is enabled.
+     * Returns if high contrast text in the system is enabled.
      * <p>
      * <strong>Note:</strong> You need to query this only if you application is
      * doing its own rendering and does not rely on the platform rendering pipeline.
      * </p>
      *
-     * @return True if high text contrast is enabled, false otherwise.
+     * @return True if high contrast text is enabled, false otherwise.
      *
-     * @hide
      */
-    @UnsupportedAppUsage
-    public boolean isHighTextContrastEnabled() {
+    @FlaggedApi(com.android.graphics.hwui.flags.Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
+    public boolean isHighContrastTextEnabled() {
         synchronized (mLock) {
             IAccessibilityManager service = getServiceLocked();
             if (service == null) {
                 return false;
             }
-            return mIsHighTextContrastEnabled;
+            return mIsHighContrastTextEnabled;
         }
     }
 
@@ -1303,32 +1301,32 @@
     }
 
     /**
-     * Registers a {@link HighTextContrastChangeListener} for changes in
-     * the global high text contrast state of the system.
+     * Registers a {@link HighContrastTextStateChangeListener} for changes in
+     * the global high contrast text state of the system.
      *
-     * @param listener The listener.
-     *
-     * @hide
+     * @param executor a executor to call the listener from
+     * @param listener The listener to be called
      */
-    public void addHighTextContrastStateChangeListener(
-            @NonNull HighTextContrastChangeListener listener, @Nullable Handler handler) {
+    @FlaggedApi(com.android.graphics.hwui.flags.Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
+    public void addHighContrastTextStateChangeListener(
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull HighContrastTextStateChangeListener listener
+    ) {
         synchronized (mLock) {
-            mHighTextContrastStateChangeListeners
-                    .put(listener, (handler == null) ? mHandler : handler);
+            mHighContrastTextStateChangeListeners.put(listener, executor);
         }
     }
 
     /**
-     * Unregisters a {@link HighTextContrastChangeListener}.
+     * Unregisters a {@link HighContrastTextStateChangeListener}.
      *
      * @param listener The listener.
-     *
-     * @hide
      */
-    public void removeHighTextContrastStateChangeListener(
-            @NonNull HighTextContrastChangeListener listener) {
+    @FlaggedApi(com.android.graphics.hwui.flags.Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
+    public void removeHighContrastTextStateChangeListener(
+            @NonNull HighContrastTextStateChangeListener listener) {
         synchronized (mLock) {
-            mHighTextContrastStateChangeListeners.remove(listener);
+            mHighContrastTextStateChangeListeners.remove(listener);
         }
     }
 
@@ -1505,13 +1503,13 @@
 
         final boolean wasEnabled = isEnabled();
         final boolean wasTouchExplorationEnabled = mIsTouchExplorationEnabled;
-        final boolean wasHighTextContrastEnabled = mIsHighTextContrastEnabled;
+        final boolean wasHighTextContrastEnabled = mIsHighContrastTextEnabled;
         final boolean wasAudioDescriptionByDefaultRequested = mIsAudioDescriptionByDefaultRequested;
 
         // Ensure listeners get current state from isZzzEnabled() calls.
         mIsEnabled = enabled;
         mIsTouchExplorationEnabled = touchExplorationEnabled;
-        mIsHighTextContrastEnabled = highTextContrastEnabled;
+        mIsHighContrastTextEnabled = highTextContrastEnabled;
         mIsAudioDescriptionByDefaultRequested = audioDescriptionEnabled;
 
         if (wasEnabled != isEnabled()) {
@@ -1523,7 +1521,7 @@
         }
 
         if (wasHighTextContrastEnabled != highTextContrastEnabled) {
-            notifyHighTextContrastStateChanged();
+            notifyHighContrastTextStateChanged();
         }
 
         if (wasAudioDescriptionByDefaultRequested
@@ -2397,24 +2395,24 @@
     }
 
     /**
-     * Notifies the registered {@link HighTextContrastChangeListener}s.
+     * Notifies the registered {@link HighContrastTextStateChangeListener}s.
      */
-    private void notifyHighTextContrastStateChanged() {
+    private void notifyHighContrastTextStateChanged() {
         final boolean isHighTextContrastEnabled;
-        final ArrayMap<HighTextContrastChangeListener, Handler> listeners;
+        final ArrayMap<HighContrastTextStateChangeListener, Executor> listeners;
         synchronized (mLock) {
-            if (mHighTextContrastStateChangeListeners.isEmpty()) {
+            if (mHighContrastTextStateChangeListeners.isEmpty()) {
                 return;
             }
-            isHighTextContrastEnabled = mIsHighTextContrastEnabled;
-            listeners = new ArrayMap<>(mHighTextContrastStateChangeListeners);
+            isHighTextContrastEnabled = mIsHighContrastTextEnabled;
+            listeners = new ArrayMap<>(mHighContrastTextStateChangeListeners);
         }
 
         final int numListeners = listeners.size();
         for (int i = 0; i < numListeners; i++) {
-            final HighTextContrastChangeListener listener = listeners.keyAt(i);
-            listeners.valueAt(i).post(() ->
-                    listener.onHighTextContrastStateChanged(isHighTextContrastEnabled));
+            final HighContrastTextStateChangeListener listener = listeners.keyAt(i);
+            listeners.valueAt(i).execute(() ->
+                    listener.onHighContrastTextStateChanged(isHighTextContrastEnabled));
         }
     }
 
diff --git a/core/java/android/view/accessibility/flags/accessibility_flags.aconfig b/core/java/android/view/accessibility/flags/accessibility_flags.aconfig
index 513587e..b9e9750 100644
--- a/core/java/android/view/accessibility/flags/accessibility_flags.aconfig
+++ b/core/java/android/view/accessibility/flags/accessibility_flags.aconfig
@@ -4,6 +4,13 @@
 # NOTE: Keep alphabetized to help limit merge conflicts from multiple simultaneous editors.
 
 flag {
+    name: "a11y_expansion_state_api"
+    namespace: "accessibility"
+    description: "Enables new APIs for an app to convey if a node is expanded or collapsed."
+    bug: "362782536"
+}
+
+flag {
     name: "a11y_overlay_callbacks"
     is_exported: true
     namespace: "accessibility"
diff --git a/core/java/android/view/autofill/AutofillStateFingerprint.java b/core/java/android/view/autofill/AutofillStateFingerprint.java
index 2db4285..7f3858e 100644
--- a/core/java/android/view/autofill/AutofillStateFingerprint.java
+++ b/core/java/android/view/autofill/AutofillStateFingerprint.java
@@ -97,7 +97,6 @@
             if (sDebug) {
                 Log.d(TAG, "Autofillable views count prior to auth:" + autofillableViews.size());
             }
-//            ArrayList<Integer> hashes = getFingerprintIds(autofillableViews);
 
             ArrayMap<Integer, View> hashes = getFingerprintIds(autofillableViews);
             for (Map.Entry<Integer, View> entry : hashes.entrySet()) {
@@ -123,7 +122,6 @@
                 if (view != null) {
                     int id = getEphemeralFingerprintId(view, 0 /* position irrelevant */);
                     AutofillId autofillId = view.getAutofillId();
-                    autofillId.setSessionId(mSessionId);
                     mHashToAutofillIdMap.put(id, autofillId);
                 } else {
                     if (sDebug) {
diff --git a/core/java/android/window/flags/DesktopModeFlags.java b/core/java/android/window/flags/DesktopModeFlags.java
index 944a106..47af50da 100644
--- a/core/java/android/window/flags/DesktopModeFlags.java
+++ b/core/java/android/window/flags/DesktopModeFlags.java
@@ -64,7 +64,8 @@
     ENABLE_WINDOWING_EDGE_DRAG_RESIZE(Flags::enableWindowingEdgeDragResize, true),
     ENABLE_DESKTOP_WINDOWING_TASKBAR_RUNNING_APPS(
             Flags::enableDesktopWindowingTaskbarRunningApps, true),
-    ENABLE_DESKTOP_WINDOWING_TRANSITIONS(Flags::enableDesktopWindowingTransitions, false);
+    ENABLE_DESKTOP_WINDOWING_TRANSITIONS(Flags::enableDesktopWindowingTransitions, false),
+    ENABLE_DESKTOP_WINDOWING_EXIT_TRANSITIONS(Flags::enableDesktopWindowingExitTransitions, false);
 
     private static final String TAG = "DesktopModeFlagsUtil";
     // Function called to obtain aconfig flag value.
diff --git a/core/java/android/window/flags/lse_desktop_experience.aconfig b/core/java/android/window/flags/lse_desktop_experience.aconfig
index fbc30ed..b22aa22 100644
--- a/core/java/android/window/flags/lse_desktop_experience.aconfig
+++ b/core/java/android/window/flags/lse_desktop_experience.aconfig
@@ -162,6 +162,13 @@
 }
 
 flag {
+    name: "enable_a11y_metrics"
+    namespace: "lse_desktop_experience"
+    description: "Whether to enable log collection for a11y actions in desktop windowing mode"
+    bug: "341319597"
+}
+
+flag {
     name: "enable_caption_compat_inset_force_consumption"
     namespace: "lse_desktop_experience"
     description: "Enables force-consumption of caption bar insets for immersive apps in freeform"
@@ -306,3 +313,10 @@
     description: "Allow entering desktop mode by default on freeform displays"
     bug: "361419732"
 }
+
+flag {
+    name: "enable_desktop_app_launch_alttab_transitions"
+    namespace: "lse_desktop_experience"
+    description: "Enables custom transitions for alt-tab app launches in Desktop Mode."
+    bug: "370735595"
+}
\ No newline at end of file
diff --git a/core/java/com/android/internal/notification/SystemNotificationChannels.java b/core/java/com/android/internal/notification/SystemNotificationChannels.java
index fef5e83..4aebde5 100644
--- a/core/java/com/android/internal/notification/SystemNotificationChannels.java
+++ b/core/java/com/android/internal/notification/SystemNotificationChannels.java
@@ -26,6 +26,7 @@
 import android.os.RemoteException;
 
 import com.android.internal.R;
+import com.android.internal.annotations.VisibleForTesting;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -37,38 +38,40 @@
      * @deprecated Legacy system channel, which is no longer used,
      */
     @Deprecated public static String VIRTUAL_KEYBOARD  = "VIRTUAL_KEYBOARD";
-    public static String PHYSICAL_KEYBOARD = "PHYSICAL_KEYBOARD";
-    public static String SECURITY = "SECURITY";
-    public static String CAR_MODE = "CAR_MODE";
-    public static String ACCOUNT = "ACCOUNT";
-    public static String DEVELOPER = "DEVELOPER";
-    public static String DEVELOPER_IMPORTANT = "DEVELOPER_IMPORTANT";
-    public static String UPDATES = "UPDATES";
-    public static String NETWORK_STATUS = "NETWORK_STATUS";
-    public static String NETWORK_ALERTS = "NETWORK_ALERTS";
-    public static String NETWORK_AVAILABLE = "NETWORK_AVAILABLE";
-    public static String VPN = "VPN";
+    public static final String PHYSICAL_KEYBOARD = "PHYSICAL_KEYBOARD";
+    public static final String SECURITY = "SECURITY";
+    public static final String CAR_MODE = "CAR_MODE";
+    public static final String ACCOUNT = "ACCOUNT";
+    public static final String DEVELOPER = "DEVELOPER";
+    public static final String DEVELOPER_IMPORTANT = "DEVELOPER_IMPORTANT";
+    public static final String UPDATES = "UPDATES";
+    public static final String NETWORK_STATUS = "NETWORK_STATUS";
+    public static final String NETWORK_ALERTS = "NETWORK_ALERTS";
+    public static final String NETWORK_AVAILABLE = "NETWORK_AVAILABLE";
+    public static final String VPN = "VPN";
     /**
      * @deprecated Legacy device admin channel with low importance which is no longer used,
      *  Use the high importance {@link #DEVICE_ADMIN} channel instead.
      */
-    @Deprecated public static String DEVICE_ADMIN_DEPRECATED = "DEVICE_ADMIN";
-    public static String DEVICE_ADMIN = "DEVICE_ADMIN_ALERTS";
-    public static String ALERTS = "ALERTS";
-    public static String RETAIL_MODE = "RETAIL_MODE";
-    public static String USB = "USB";
-    public static String FOREGROUND_SERVICE = "FOREGROUND_SERVICE";
-    public static String HEAVY_WEIGHT_APP = "HEAVY_WEIGHT_APP";
+    @Deprecated public static final String DEVICE_ADMIN_DEPRECATED = "DEVICE_ADMIN";
+    public static final String DEVICE_ADMIN = "DEVICE_ADMIN_ALERTS";
+    public static final String ALERTS = "ALERTS";
+    public static final String RETAIL_MODE = "RETAIL_MODE";
+    public static final String USB = "USB";
+    public static final String FOREGROUND_SERVICE = "FOREGROUND_SERVICE";
+    public static final String HEAVY_WEIGHT_APP = "HEAVY_WEIGHT_APP";
     /**
      * @deprecated Legacy system changes channel with low importance which is no longer used,
      *  Use the default importance {@link #SYSTEM_CHANGES} channel instead.
      */
-    @Deprecated public static String SYSTEM_CHANGES_DEPRECATED = "SYSTEM_CHANGES";
-    public static String SYSTEM_CHANGES = "SYSTEM_CHANGES_ALERTS";
-    public static String DO_NOT_DISTURB = "DO_NOT_DISTURB";
-    public static String ACCESSIBILITY_MAGNIFICATION = "ACCESSIBILITY_MAGNIFICATION";
-    public static String ACCESSIBILITY_SECURITY_POLICY = "ACCESSIBILITY_SECURITY_POLICY";
-    public static String ABUSIVE_BACKGROUND_APPS = "ABUSIVE_BACKGROUND_APPS";
+    @Deprecated public static final String SYSTEM_CHANGES_DEPRECATED = "SYSTEM_CHANGES";
+    public static final String SYSTEM_CHANGES = "SYSTEM_CHANGES_ALERTS";
+    public static final String ACCESSIBILITY_MAGNIFICATION = "ACCESSIBILITY_MAGNIFICATION";
+    public static final String ACCESSIBILITY_SECURITY_POLICY = "ACCESSIBILITY_SECURITY_POLICY";
+    public static final String ABUSIVE_BACKGROUND_APPS = "ABUSIVE_BACKGROUND_APPS";
+
+    @VisibleForTesting
+    static final String OBSOLETE_DO_NOT_DISTURB = "DO_NOT_DISTURB";
 
     public static void createAll(Context context) {
         final NotificationManager nm = context.getSystemService(NotificationManager.class);
@@ -193,11 +196,6 @@
                 .build());
         channelsList.add(systemChanges);
 
-        NotificationChannel dndChanges = new NotificationChannel(DO_NOT_DISTURB,
-                context.getString(R.string.notification_channel_do_not_disturb),
-                NotificationManager.IMPORTANCE_LOW);
-        channelsList.add(dndChanges);
-
         final NotificationChannel newFeaturePrompt = new NotificationChannel(
                 ACCESSIBILITY_MAGNIFICATION,
                 context.getString(R.string.notification_channel_accessibility_magnification),
@@ -218,6 +216,9 @@
         channelsList.add(abusiveBackgroundAppsChannel);
 
         nm.createNotificationChannels(channelsList);
+
+        // Delete channels created by previous Android versions that are no longer used.
+        nm.deleteNotificationChannel(OBSOLETE_DO_NOT_DISTURB);
     }
 
     private static String getDeviceAdminNotificationChannelName(Context context) {
diff --git a/core/java/com/android/internal/protolog/PerfettoProtoLogImpl.java b/core/java/com/android/internal/protolog/PerfettoProtoLogImpl.java
index 4d0cd27..f3dc896 100644
--- a/core/java/com/android/internal/protolog/PerfettoProtoLogImpl.java
+++ b/core/java/com/android/internal/protolog/PerfettoProtoLogImpl.java
@@ -56,6 +56,7 @@
 import android.tracing.perfetto.Producer;
 import android.tracing.perfetto.TracingContext;
 import android.util.ArrayMap;
+import android.util.ArraySet;
 import android.util.Log;
 import android.util.LongArray;
 import android.util.Slog;
@@ -351,6 +352,10 @@
     }
 
     private void registerGroupsLocally(@NonNull IProtoLogGroup[] protoLogGroups) {
+        // Verify we don't have id collisions, if we do we want to know as soon as possible and
+        // we might want to manually specify an id for the group with a collision
+        verifyNoCollisionsOrDuplicates(protoLogGroups);
+
         final var groupsLoggingToLogcat = new ArrayList<String>();
         for (IProtoLogGroup protoLogGroup : protoLogGroups) {
             mLogGroups.put(protoLogGroup.name(), protoLogGroup);
@@ -369,6 +374,19 @@
         }
     }
 
+    private void verifyNoCollisionsOrDuplicates(@NonNull IProtoLogGroup[] protoLogGroups) {
+        final var groupId = new ArraySet<Integer>();
+
+        for (IProtoLogGroup protoLogGroup : protoLogGroups) {
+            if (groupId.contains(protoLogGroup.getId())) {
+                throw new RuntimeException(
+                        "Group with same id (" + protoLogGroup.getId() + ") registered twice. "
+                                + "Potential duplicate or hash id collision.");
+            }
+            groupId.add(protoLogGroup.getId());
+        }
+    }
+
     /**
      * Responds to a shell command.
      */
diff --git a/core/java/com/android/internal/protolog/ProtoLog.java b/core/java/com/android/internal/protolog/ProtoLog.java
index adf03fe..60213b1 100644
--- a/core/java/com/android/internal/protolog/ProtoLog.java
+++ b/core/java/com/android/internal/protolog/ProtoLog.java
@@ -22,8 +22,8 @@
 import com.android.internal.protolog.common.IProtoLogGroup;
 import com.android.internal.protolog.common.LogLevel;
 
-import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashSet;
 
 /**
  * ProtoLog API - exposes static logging methods. Usage of this API is similar
@@ -73,7 +73,7 @@
                 if (sProtoLogInstance != null) {
                     // The ProtoLog instance has already been initialized in this process
                     final var alreadyRegisteredGroups = sProtoLogInstance.getRegisteredGroups();
-                    final var allGroups = new ArrayList<>(alreadyRegisteredGroups);
+                    final var allGroups = new HashSet<>(alreadyRegisteredGroups);
                     allGroups.addAll(Arrays.stream(groups).toList());
                     groups = allGroups.toArray(new IProtoLogGroup[0]);
                 }
diff --git a/core/java/com/android/internal/protolog/ProtoLogGroup.java b/core/java/com/android/internal/protolog/ProtoLogGroup.java
new file mode 100644
index 0000000..6521870
--- /dev/null
+++ b/core/java/com/android/internal/protolog/ProtoLogGroup.java
@@ -0,0 +1,93 @@
+/*
+ * 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.internal.protolog;
+
+import android.annotation.NonNull;
+
+import com.android.internal.protolog.common.IProtoLogGroup;
+
+public class ProtoLogGroup implements IProtoLogGroup {
+
+    /** The name should be unique across the codebase. */
+    @NonNull
+    private final String mName;
+    @NonNull
+    private final String mTag;
+    private final boolean mEnabled;
+    private boolean mLogToProto;
+    private boolean mLogToLogcat;
+
+    public ProtoLogGroup(@NonNull String name) {
+        this(name, name);
+    }
+
+    public ProtoLogGroup(@NonNull String name, @NonNull String tag) {
+        this(name, tag, true);
+    }
+
+    public ProtoLogGroup(@NonNull String name, @NonNull String tag, boolean enabled) {
+        mName = name;
+        mTag = tag;
+        mEnabled = enabled;
+        mLogToProto = enabled;
+        mLogToLogcat = enabled;
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return mEnabled;
+    }
+
+    @Deprecated
+    @Override
+    public boolean isLogToProto() {
+        return mLogToProto;
+    }
+
+    @Override
+    public boolean isLogToLogcat() {
+        return mLogToLogcat;
+    }
+
+    @Override
+    @NonNull
+    public String getTag() {
+        return mTag;
+    }
+
+    @Deprecated
+    @Override
+    public void setLogToProto(boolean logToProto) {
+        mLogToProto = logToProto;
+    }
+
+    @Override
+    public void setLogToLogcat(boolean logToLogcat) {
+        mLogToLogcat = logToLogcat;
+    }
+
+    @Override
+    @NonNull
+    public String name() {
+        return mName;
+    }
+
+    @Override
+    public int getId() {
+        return mName.hashCode();
+    }
+}
diff --git a/core/java/com/android/internal/protolog/TEST_MAPPING b/core/java/com/android/internal/protolog/TEST_MAPPING
index 37d57ee..b51d19d 100644
--- a/core/java/com/android/internal/protolog/TEST_MAPPING
+++ b/core/java/com/android/internal/protolog/TEST_MAPPING
@@ -1,6 +1,9 @@
 {
   "postsubmit": [
     {
+      "name": "TracingTests"
+    },
+    {
       "name": "ProtologPerfTests"
     }
   ]
diff --git a/core/jni/android_database_SQLiteConnection.cpp b/core/jni/android_database_SQLiteConnection.cpp
index 3370f38..ba7e705 100644
--- a/core/jni/android_database_SQLiteConnection.cpp
+++ b/core/jni/android_database_SQLiteConnection.cpp
@@ -16,27 +16,22 @@
 
 #define LOG_TAG "SQLiteConnection"
 
-#include <jni.h>
-#include <nativehelper/JNIHelp.h>
+#include <android-base/mapped_file.h>
 #include <android_runtime/AndroidRuntime.h>
 #include <android_runtime/Log.h>
-
-#include <utils/Log.h>
-#include <utils/String8.h>
-#include <utils/String16.h>
-#include <cutils/ashmem.h>
-#include <sys/mman.h>
-
-#include <string.h>
-#include <unistd.h>
-
 #include <androidfw/CursorWindow.h>
-
+#include <cutils/ashmem.h>
+#include <jni.h>
+#include <nativehelper/JNIHelp.h>
 #include <sqlite3.h>
 #include <sqlite3_android.h>
+#include <string.h>
+#include <unistd.h>
+#include <utils/Log.h>
+#include <utils/String16.h>
+#include <utils/String8.h>
 
 #include "android_database_SQLiteCommon.h"
-
 #include "core_jni_helpers.h"
 
 // Set to 1 to use UTF16 storage for localized indexes.
@@ -669,13 +664,14 @@
         ALOGE("ashmem_create_region failed: %s", strerror(error));
     } else {
         if (length > 0) {
-            void* ptr = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
-            if (ptr == MAP_FAILED) {
+            std::unique_ptr<base::MappedFile> mappedFile =
+                    base::MappedFile::FromFd(fd, 0, length, PROT_READ | PROT_WRITE);
+            if (mappedFile == nullptr) {
                 error = errno;
                 ALOGE("mmap failed: %s", strerror(error));
             } else {
-                memcpy(ptr, data, length);
-                munmap(ptr, length);
+                memcpy(mappedFile->data(), data, length);
+                mappedFile.reset();
             }
         }
 
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index d35c66e..ed33ede 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -8449,6 +8449,29 @@
     <permission android:name="android.permission.RESERVED_FOR_TESTING_SIGNATURE"
                 android:protectionLevel="signature"/>
 
+    <!-- @SystemApi
+        @FlaggedApi("android.content.pm.verification_service")
+        Allows app to be the verification agent to verify packages.
+        <p>Protection level: signature|privileged
+        @hide
+    -->
+    <permission android:name="android.permission.VERIFICATION_AGENT"
+        android:protectionLevel="signature|privileged"
+        android:featureFlag="android.content.pm.verification_service" />
+
+    <!-- @SystemApi
+        @FlaggedApi("android.content.pm.verification_service")
+        Must be required by a privileged {@link android.content.pm.verify.pkg.VerifierService}
+        to ensure that only the system can bind to it.
+        This permission should not be held by anything other than the system.
+        <p>Not for use by third-party applications. </p>
+        <p>Protection level: signature
+        @hide
+    -->
+    <permission android:name="android.permission.BIND_VERIFICATION_AGENT"
+        android:protectionLevel="internal"
+        android:featureFlag="android.content.pm.verification_service" />
+
     <!-- Attribution for Geofencing service. -->
     <attribution android:tag="GeofencingService" android:label="@string/geofencing_service"/>
     <!-- Attribution for Country Detector. -->
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index 5c0dca2..1a3a30d 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -1250,6 +1250,7 @@
          a watch, setting this config is no-op.
             0 - Nothing
             1 - Switch to the recent app
+            2 - Launch the default fitness app
     -->
     <integer name="config_doublePressOnStemPrimaryBehavior">0</integer>
 
@@ -2309,10 +2310,6 @@
          spatial audio is enabled for a newly connected audio device -->
     <bool name="config_spatial_audio_head_tracking_enabled_default">false</bool>
 
-    <!-- Flag indicating whether platform level volume adjustments are enabled for remote sessions
-         on grouped devices. -->
-    <bool name="config_volumeAdjustmentForRemoteGroupSessions">true</bool>
-
     <!-- Flag indicating current media Output Switcher version. -->
     <integer name="config_mediaOutputSwitchDialogVersion">1</integer>
 
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml
index d634210..7aca535 100644
--- a/core/res/res/values/strings.xml
+++ b/core/res/res/values/strings.xml
@@ -5818,16 +5818,6 @@
 
     <!-- Title for the notification channel notifying user of settings system changes. [CHAR LIMIT=NONE] -->
     <string name="notification_channel_system_changes">System changes</string>
-    <!-- Title for the notification channel notifying user of do not disturb system changes (i.e. Do Not Disturb has changed). [CHAR LIMIT=NONE] -->
-    <string name="notification_channel_do_not_disturb">Do Not Disturb</string>
-    <!-- Title of notification indicating do not disturb visual interruption settings have changed when upgrading to P -->
-    <string name="zen_upgrade_notification_visd_title">New: Do Not Disturb is hiding notifications</string>
-    <!-- Content of notification indicating users can tap on the notification to go to dnd behavior settings -->
-    <string name="zen_upgrade_notification_visd_content">Tap to learn more and change.</string>
-    <!-- Title of notification indicating do not disturb settings have changed when upgrading to P -->
-    <string name="zen_upgrade_notification_title">Do Not Disturb has changed</string>
-    <!-- Content of notification indicating users can tap on the notification to go to dnd behavior settings -->
-    <string name="zen_upgrade_notification_content">Tap to check what\'s blocked.</string>
 
     <!-- Notification permission informational notification text -->
     <!-- Title for notification inviting users to review their notification settings [CHAR LIMIT=NONE] -->
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 807df1b..d5298ac 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -3937,7 +3937,6 @@
   <java-symbol type="string" name="notification_channel_usb" />
   <java-symbol type="string" name="notification_channel_heavy_weight_app" />
   <java-symbol type="string" name="notification_channel_system_changes" />
-  <java-symbol type="string" name="notification_channel_do_not_disturb" />
   <java-symbol type="string" name="notification_channel_accessibility_magnification" />
   <java-symbol type="string" name="notification_channel_accessibility_security_policy" />
   <java-symbol type="string" name="notification_channel_display" />
@@ -4164,11 +4163,6 @@
   <!-- For Wear devices -->
   <java-symbol type="array" name="config_wearActivityModeRadios" />
 
-  <java-symbol type="string" name="zen_upgrade_notification_title" />
-  <java-symbol type="string" name="zen_upgrade_notification_content" />
-  <java-symbol type="string" name="zen_upgrade_notification_visd_title" />
-  <java-symbol type="string" name="zen_upgrade_notification_visd_content" />
-
   <java-symbol type="string" name="review_notification_settings_title" />
   <java-symbol type="string" name="review_notification_settings_text" />
   <java-symbol type="string" name="review_notification_settings_remind_me_action" />
@@ -5102,8 +5096,6 @@
 
   <java-symbol type="dimen" name="config_wallpaperDimAmount" />
 
-  <java-symbol type="bool" name="config_volumeAdjustmentForRemoteGroupSessions" />
-
   <java-symbol type="integer" name="config_mediaOutputSwitchDialogVersion" />
 
   <!-- List of shared library packages that should be loaded by the classloader after the
diff --git a/core/tests/coretests/Android.bp b/core/tests/coretests/Android.bp
index 9821d43..56e18e6 100644
--- a/core/tests/coretests/Android.bp
+++ b/core/tests/coretests/Android.bp
@@ -250,7 +250,7 @@
         "androidx.test.rules",
         "androidx.test.ext.junit",
         "androidx.test.uiautomator_uiautomator",
-        "compatibility-device-util-axt",
+        "compatibility-device-util-axt-ravenwood",
         "flag-junit",
         "platform-test-annotations",
         "flag-junit",
diff --git a/core/tests/coretests/src/android/content/pm/verify/VerificationSessionTest.java b/core/tests/coretests/src/android/content/pm/verify/VerificationSessionTest.java
new file mode 100644
index 0000000..987f68d
--- /dev/null
+++ b/core/tests/coretests/src/android/content/pm/verify/VerificationSessionTest.java
@@ -0,0 +1,155 @@
+/*
+ * 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 android.content.pm.verify;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.pm.SharedLibraryInfo;
+import android.content.pm.SigningInfo;
+import android.content.pm.VersionedPackage;
+import android.content.pm.verify.pkg.IVerificationSessionCallback;
+import android.content.pm.verify.pkg.IVerificationSessionInterface;
+import android.content.pm.verify.pkg.VerificationSession;
+import android.content.pm.verify.pkg.VerificationStatus;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.PersistableBundle;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class VerificationSessionTest {
+    private static final int TEST_ID = 100;
+    private static final int TEST_INSTALL_SESSION_ID = 33;
+    private static final String TEST_PACKAGE_NAME = "com.foo";
+    private static final Uri TEST_PACKAGE_URI = Uri.parse("test://test");
+    private static final SigningInfo TEST_SIGNING_INFO = new SigningInfo();
+    private static final SharedLibraryInfo TEST_SHARED_LIBRARY_INFO1 =
+            new SharedLibraryInfo("sharedLibPath1", TEST_PACKAGE_NAME,
+                    Collections.singletonList("path1"), "sharedLib1", 101,
+                    SharedLibraryInfo.TYPE_DYNAMIC, new VersionedPackage(TEST_PACKAGE_NAME, 1),
+                    null, null, false);
+    private static final SharedLibraryInfo TEST_SHARED_LIBRARY_INFO2 =
+            new SharedLibraryInfo("sharedLibPath2", TEST_PACKAGE_NAME,
+                    Collections.singletonList("path2"), "sharedLib2", 102,
+                    SharedLibraryInfo.TYPE_DYNAMIC,
+                    new VersionedPackage(TEST_PACKAGE_NAME, 2), null, null, false);
+    private static final long TEST_TIMEOUT_TIME = System.currentTimeMillis();
+    private static final long TEST_EXTEND_TIME = 2000L;
+    private static final String TEST_KEY = "test key";
+    private static final String TEST_VALUE = "test value";
+
+    private final ArrayList<SharedLibraryInfo> mTestDeclaredLibraries = new ArrayList<>();
+    private final PersistableBundle mTestExtensionParams = new PersistableBundle();
+    @Mock
+    private IVerificationSessionInterface mTestSessionInterface;
+    @Mock
+    private IVerificationSessionCallback mTestCallback;
+    private VerificationSession mTestSession;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mTestDeclaredLibraries.add(TEST_SHARED_LIBRARY_INFO1);
+        mTestDeclaredLibraries.add(TEST_SHARED_LIBRARY_INFO2);
+        mTestExtensionParams.putString(TEST_KEY, TEST_VALUE);
+        mTestSession = new VerificationSession(TEST_ID, TEST_INSTALL_SESSION_ID,
+                TEST_PACKAGE_NAME, TEST_PACKAGE_URI, TEST_SIGNING_INFO, mTestDeclaredLibraries,
+                mTestExtensionParams, mTestSessionInterface, mTestCallback);
+    }
+
+    @Test
+    public void testParcel() {
+        Parcel parcel = Parcel.obtain();
+        mTestSession.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        VerificationSession sessionFromParcel =
+                VerificationSession.CREATOR.createFromParcel(parcel);
+        assertThat(sessionFromParcel.getId()).isEqualTo(TEST_ID);
+        assertThat(sessionFromParcel.getInstallSessionId()).isEqualTo(TEST_INSTALL_SESSION_ID);
+        assertThat(sessionFromParcel.getPackageName()).isEqualTo(TEST_PACKAGE_NAME);
+        assertThat(sessionFromParcel.getStagedPackageUri()).isEqualTo(TEST_PACKAGE_URI);
+        assertThat(sessionFromParcel.getSigningInfo().getSigningDetails())
+                .isEqualTo(TEST_SIGNING_INFO.getSigningDetails());
+        List<SharedLibraryInfo> declaredLibrariesFromParcel =
+                sessionFromParcel.getDeclaredLibraries();
+        assertThat(declaredLibrariesFromParcel).hasSize(2);
+        // SharedLibraryInfo doesn't have a "equals" method, so we have to check it indirectly
+        assertThat(declaredLibrariesFromParcel.getFirst().toString())
+                .isEqualTo(TEST_SHARED_LIBRARY_INFO1.toString());
+        assertThat(declaredLibrariesFromParcel.get(1).toString())
+                .isEqualTo(TEST_SHARED_LIBRARY_INFO2.toString());
+        // We can't directly test with PersistableBundle.equals() because the parceled bundle's
+        // structure is different, but all the key/value pairs should be preserved as before.
+        assertThat(sessionFromParcel.getExtensionParams().getString(TEST_KEY))
+                .isEqualTo(mTestExtensionParams.getString(TEST_KEY));
+    }
+
+    @Test
+    public void testInterface() throws Exception {
+        when(mTestSessionInterface.getTimeoutTime(anyInt())).thenAnswer(i -> TEST_TIMEOUT_TIME);
+        when(mTestSessionInterface.extendTimeRemaining(anyInt(), anyLong())).thenAnswer(
+                i -> i.getArguments()[1]);
+
+        assertThat(mTestSession.getTimeoutTime()).isEqualTo(TEST_TIMEOUT_TIME);
+        verify(mTestSessionInterface, times(1)).getTimeoutTime(eq(TEST_ID));
+        assertThat(mTestSession.extendTimeRemaining(TEST_EXTEND_TIME)).isEqualTo(TEST_EXTEND_TIME);
+        verify(mTestSessionInterface, times(1)).extendTimeRemaining(
+                eq(TEST_ID), eq(TEST_EXTEND_TIME));
+    }
+
+    @Test
+    public void testCallback() throws Exception {
+        PersistableBundle response = new PersistableBundle();
+        response.putString("test key", "test value");
+        final VerificationStatus status =
+                new VerificationStatus.Builder().setVerified(true).build();
+        mTestSession.reportVerificationComplete(status);
+        verify(mTestCallback, times(1)).reportVerificationComplete(
+                eq(TEST_ID), eq(status));
+        mTestSession.reportVerificationComplete(status, response);
+        verify(mTestCallback, times(1))
+                .reportVerificationCompleteWithExtensionResponse(
+                        eq(TEST_ID), eq(status), eq(response));
+
+        final int reason = VerificationSession.VERIFICATION_INCOMPLETE_UNKNOWN;
+        mTestSession.reportVerificationIncomplete(reason);
+        verify(mTestCallback, times(1)).reportVerificationIncomplete(
+                eq(TEST_ID), eq(reason));
+    }
+}
diff --git a/core/tests/coretests/src/android/content/pm/verify/VerificationStatusTest.java b/core/tests/coretests/src/android/content/pm/verify/VerificationStatusTest.java
new file mode 100644
index 0000000..67d407a
--- /dev/null
+++ b/core/tests/coretests/src/android/content/pm/verify/VerificationStatusTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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 android.content.pm.verify;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.pm.verify.pkg.VerificationStatus;
+import android.os.Parcel;
+import android.os.PersistableBundle;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class VerificationStatusTest {
+    private static final boolean TEST_VERIFIED = true;
+    private static final int TEST_ASL_STATUS = VerificationStatus.VERIFIER_STATUS_ASL_GOOD;
+    private static final String TEST_FAILURE_MESSAGE = "test test";
+    private static final String TEST_KEY = "test key";
+    private static final String TEST_VALUE = "test value";
+    private final PersistableBundle mTestExtras = new PersistableBundle();
+    private VerificationStatus mStatus;
+
+    @Before
+    public void setUpWithBuilder() {
+        mTestExtras.putString(TEST_KEY, TEST_VALUE);
+        mStatus = new VerificationStatus.Builder()
+                .setAslStatus(TEST_ASL_STATUS)
+                .setFailureMessage(TEST_FAILURE_MESSAGE)
+                .setVerified(TEST_VERIFIED)
+                .build();
+    }
+
+    @Test
+    public void testGetters() {
+        assertThat(mStatus.isVerified()).isEqualTo(TEST_VERIFIED);
+        assertThat(mStatus.getAslStatus()).isEqualTo(TEST_ASL_STATUS);
+        assertThat(mStatus.getFailureMessage()).isEqualTo(TEST_FAILURE_MESSAGE);
+    }
+
+    @Test
+    public void testParcel() {
+        Parcel parcel = Parcel.obtain();
+        mStatus.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        VerificationStatus statusFromParcel = VerificationStatus.CREATOR.createFromParcel(parcel);
+        assertThat(statusFromParcel.isVerified()).isEqualTo(TEST_VERIFIED);
+        assertThat(statusFromParcel.getAslStatus()).isEqualTo(TEST_ASL_STATUS);
+        assertThat(statusFromParcel.getFailureMessage()).isEqualTo(TEST_FAILURE_MESSAGE);
+    }
+}
diff --git a/core/tests/coretests/src/android/content/pm/verify/VerifierServiceTest.java b/core/tests/coretests/src/android/content/pm/verify/VerifierServiceTest.java
new file mode 100644
index 0000000..7f73a1e
--- /dev/null
+++ b/core/tests/coretests/src/android/content/pm/verify/VerifierServiceTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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 android.content.pm.verify;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.SigningInfo;
+import android.content.pm.verify.pkg.IVerifierService;
+import android.content.pm.verify.pkg.VerificationSession;
+import android.content.pm.verify.pkg.VerifierService;
+import android.net.Uri;
+import android.os.PersistableBundle;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Answers;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class VerifierServiceTest {
+    private static final int TEST_ID = 100;
+    private static final int TEST_INSTALL_SESSION_ID = 33;
+    private static final String TEST_PACKAGE_NAME = "com.foo";
+    private static final Uri TEST_PACKAGE_URI = Uri.parse("test://test");
+    private static final SigningInfo TEST_SIGNING_INFO = new SigningInfo();
+    private VerifierService mService;
+    private VerificationSession mSession;
+
+    @Before
+    public void setUp() {
+        mService = Mockito.mock(VerifierService.class, Answers.CALLS_REAL_METHODS);
+        mSession = new VerificationSession(TEST_ID, TEST_INSTALL_SESSION_ID,
+                TEST_PACKAGE_NAME, TEST_PACKAGE_URI, TEST_SIGNING_INFO,
+                new ArrayList<>(),
+                new PersistableBundle(), null, null);
+    }
+
+    @Test
+    public void testBind() throws Exception {
+        Intent intent = Mockito.mock(Intent.class);
+        when(intent.getAction()).thenReturn(PackageManager.ACTION_VERIFY_PACKAGE);
+        IVerifierService binder =
+                (IVerifierService) mService.onBind(intent);
+        assertThat(binder).isNotNull();
+        binder.onPackageNameAvailable(TEST_PACKAGE_NAME);
+        verify(mService).onPackageNameAvailable(eq(TEST_PACKAGE_NAME));
+        binder.onVerificationCancelled(TEST_PACKAGE_NAME);
+        verify(mService).onVerificationCancelled(eq(TEST_PACKAGE_NAME));
+        binder.onVerificationRequired(mSession);
+        verify(mService).onVerificationRequired(eq(mSession));
+        binder.onVerificationRetry(mSession);
+        verify(mService).onVerificationRetry(eq(mSession));
+        binder.onVerificationTimeout(TEST_ID);
+        verify(mService).onVerificationTimeout(eq(TEST_ID));
+    }
+
+    @Test
+    public void testBindFailsWithWrongIntent() {
+        Intent intent = Mockito.mock(Intent.class);
+        when(intent.getAction()).thenReturn(Intent.ACTION_SEND);
+        assertThat(mService.onBind(intent)).isNull();
+    }
+}
diff --git a/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java b/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java
new file mode 100644
index 0000000..0bf406c
--- /dev/null
+++ b/core/tests/coretests/src/com/android/internal/notification/SystemNotificationChannelsTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.internal.notification;
+
+import static com.android.internal.notification.SystemNotificationChannels.ABUSIVE_BACKGROUND_APPS;
+import static com.android.internal.notification.SystemNotificationChannels.ACCESSIBILITY_MAGNIFICATION;
+import static com.android.internal.notification.SystemNotificationChannels.ACCESSIBILITY_SECURITY_POLICY;
+import static com.android.internal.notification.SystemNotificationChannels.ACCOUNT;
+import static com.android.internal.notification.SystemNotificationChannels.ALERTS;
+import static com.android.internal.notification.SystemNotificationChannels.CAR_MODE;
+import static com.android.internal.notification.SystemNotificationChannels.DEVELOPER;
+import static com.android.internal.notification.SystemNotificationChannels.DEVELOPER_IMPORTANT;
+import static com.android.internal.notification.SystemNotificationChannels.DEVICE_ADMIN;
+import static com.android.internal.notification.SystemNotificationChannels.FOREGROUND_SERVICE;
+import static com.android.internal.notification.SystemNotificationChannels.HEAVY_WEIGHT_APP;
+import static com.android.internal.notification.SystemNotificationChannels.NETWORK_ALERTS;
+import static com.android.internal.notification.SystemNotificationChannels.NETWORK_AVAILABLE;
+import static com.android.internal.notification.SystemNotificationChannels.NETWORK_STATUS;
+import static com.android.internal.notification.SystemNotificationChannels.OBSOLETE_DO_NOT_DISTURB;
+import static com.android.internal.notification.SystemNotificationChannels.PHYSICAL_KEYBOARD;
+import static com.android.internal.notification.SystemNotificationChannels.RETAIL_MODE;
+import static com.android.internal.notification.SystemNotificationChannels.SECURITY;
+import static com.android.internal.notification.SystemNotificationChannels.SYSTEM_CHANGES;
+import static com.android.internal.notification.SystemNotificationChannels.UPDATES;
+import static com.android.internal.notification.SystemNotificationChannels.USB;
+import static com.android.internal.notification.SystemNotificationChannels.VPN;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.verify;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.testing.TestableContext;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class SystemNotificationChannelsTest {
+
+    @Rule public TestableContext mContext = new TestableContext(
+            ApplicationProvider.getApplicationContext());
+
+    @Mock private NotificationManager mNm;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mContext.addMockSystemService(NotificationManager.class, mNm);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void createAll_createsExpectedChannels() {
+        ArgumentCaptor<List<NotificationChannel>> createdChannelsCaptor =
+                ArgumentCaptor.forClass(List.class);
+
+        SystemNotificationChannels.createAll(mContext);
+
+        verify(mNm).createNotificationChannels(createdChannelsCaptor.capture());
+        List<NotificationChannel> createdChannels = createdChannelsCaptor.getValue();
+        assertThat(createdChannels.stream().map(NotificationChannel::getId).toList())
+                .containsExactly(PHYSICAL_KEYBOARD, SECURITY, CAR_MODE, ACCOUNT, DEVELOPER,
+                        DEVELOPER_IMPORTANT, UPDATES, NETWORK_STATUS, NETWORK_ALERTS,
+                        NETWORK_AVAILABLE, VPN, DEVICE_ADMIN, ALERTS, RETAIL_MODE, USB,
+                        FOREGROUND_SERVICE, HEAVY_WEIGHT_APP, SYSTEM_CHANGES,
+                        ACCESSIBILITY_MAGNIFICATION, ACCESSIBILITY_SECURITY_POLICY,
+                        ABUSIVE_BACKGROUND_APPS);
+    }
+
+    @Test
+    public void createAll_deletesObsoleteChannels() {
+        ArgumentCaptor<String> deletedChannelCaptor = ArgumentCaptor.forClass(String.class);
+
+        SystemNotificationChannels.createAll(mContext);
+
+        verify(mNm, atLeastOnce()).deleteNotificationChannel(deletedChannelCaptor.capture());
+        List<String> deletedChannels = deletedChannelCaptor.getAllValues();
+        assertThat(deletedChannels).containsExactly(OBSOLETE_DO_NOT_DISTURB);
+    }
+}
diff --git a/core/tests/utiltests/Android.bp b/core/tests/utiltests/Android.bp
index cdc8a9e..7cf49ab 100644
--- a/core/tests/utiltests/Android.bp
+++ b/core/tests/utiltests/Android.bp
@@ -61,7 +61,7 @@
         "androidx.annotation_annotation",
         "androidx.test.rules",
         "frameworks-base-testutils",
-        "servicestests-utils",
+        "servicestests-utils-ravenwood",
     ],
     srcs: [
         "src/android/util/IRemoteMemoryIntArray.aidl",
diff --git a/graphics/java/android/graphics/Canvas.java b/graphics/java/android/graphics/Canvas.java
index 0b3e545..28c2ca3 100644
--- a/graphics/java/android/graphics/Canvas.java
+++ b/graphics/java/android/graphics/Canvas.java
@@ -155,7 +155,7 @@
     /**
      * Indicates whether this Canvas is drawing high contrast text.
      *
-     * @see android.view.accessibility.AccessibilityManager#isHighTextContrastEnabled()
+     * @see android.view.accessibility.AccessibilityManager#isHighContrastTextEnabled()
      * @return True if high contrast text is enabled, false otherwise.
      *
      * @hide
diff --git a/graphics/java/android/graphics/Paint.java b/graphics/java/android/graphics/Paint.java
index b866382..68d8ebb 100644
--- a/graphics/java/android/graphics/Paint.java
+++ b/graphics/java/android/graphics/Paint.java
@@ -41,6 +41,7 @@
 import android.text.TextUtils;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.text.flags.Flags;
 
 import dalvik.annotation.optimization.CriticalNative;
 import dalvik.annotation.optimization.FastNative;
@@ -2000,6 +2001,14 @@
     }
 
     /**
+     * A change ID for new font variation settings management.
+     * @hide
+     */
+    @ChangeId
+    @EnabledSince(targetSdkVersion = 36)
+    public static final long NEW_FONT_VARIATION_MANAGEMENT = 361260253L;
+
+    /**
      * Sets TrueType or OpenType font variation settings. The settings string is constructed from
      * multiple pairs of axis tag and style values. The axis tag must contain four ASCII characters
      * and must be wrapped with single quotes (U+0027) or double quotes (U+0022). Axis strings that
@@ -2028,12 +2037,16 @@
      * </li>
      * </ul>
      *
+     * <p>Note: If the application that targets API 35 or before, this function mutates the
+     * underlying typeface instance.
+     *
      * @param fontVariationSettings font variation settings. You can pass null or empty string as
      *                              no variation settings.
      *
-     * @return true if the given settings is effective to at least one font file underlying this
-     *         typeface. This function also returns true for empty settings string. Otherwise
-     *         returns false
+     * @return If the application that targets API 36 or later and is running on devices API 36 or
+     *         later, this function always returns true. Otherwise, this function returns true if
+     *         the given settings is effective to at least one font file underlying this typeface.
+     *         This function also returns true for empty settings string. Otherwise returns false.
      *
      * @throws IllegalArgumentException If given string is not a valid font variation settings
      *                                  format
@@ -2042,6 +2055,26 @@
      * @see FontVariationAxis
      */
     public boolean setFontVariationSettings(String fontVariationSettings) {
+        final boolean useFontVariationStore = Flags.typefaceRedesign()
+                && CompatChanges.isChangeEnabled(NEW_FONT_VARIATION_MANAGEMENT);
+        if (useFontVariationStore) {
+            FontVariationAxis[] axes =
+                    FontVariationAxis.fromFontVariationSettings(fontVariationSettings);
+            if (axes == null) {
+                nSetFontVariationOverride(mNativePaint, 0);
+                mFontVariationSettings = null;
+                return true;
+            }
+
+            long builderPtr = nCreateFontVariationBuilder(axes.length);
+            for (int i = 0; i < axes.length; ++i) {
+                nAddFontVariationToBuilder(builderPtr, axes[i].getOpenTypeTagValue(),
+                        axes[i].getStyleValue());
+            }
+            nSetFontVariationOverride(mNativePaint, builderPtr);
+            mFontVariationSettings = fontVariationSettings;
+            return true;
+        }
         final String settings = TextUtils.nullIfEmpty(fontVariationSettings);
         if (settings == mFontVariationSettings
                 || (settings != null && settings.equals(mFontVariationSettings))) {
@@ -3829,7 +3862,12 @@
     private static native void nSetTextSize(long paintPtr, float textSize);
     @CriticalNative
     private static native boolean nEqualsForTextMeasurement(long leftPaintPtr, long rightPaintPtr);
-
+    @CriticalNative
+    private static native long nCreateFontVariationBuilder(int size);
+    @CriticalNative
+    private static native void nAddFontVariationToBuilder(long builderPtr, int tag, float value);
+    @CriticalNative
+    private static native void nSetFontVariationOverride(long paintPtr, long builderPtr);
 
     // Following Native methods are kept for old Robolectric JNI signature used by
     // SystemUIGoogleRoboRNGTests
diff --git a/libs/WindowManager/Shell/Android.bp b/libs/WindowManager/Shell/Android.bp
index f857429..e493ed1 100644
--- a/libs/WindowManager/Shell/Android.bp
+++ b/libs/WindowManager/Shell/Android.bp
@@ -197,6 +197,7 @@
 android_library {
     name: "WindowManager-Shell",
     srcs: [
+        "src/com/android/wm/shell/EventLogTags.logtags",
         ":wm_shell_protolog_src",
         // TODO(b/168581922) protologtool do not support kotlin(*.kt)
         ":wm_shell-sources-kt",
@@ -220,6 +221,7 @@
         "//frameworks/libs/systemui:com_android_systemui_shared_flags_lib",
         "//frameworks/libs/systemui:iconloader_base",
         "com_android_wm_shell_flags_lib",
+        "PlatformAnimationLib",
         "WindowManager-Shell-proto",
         "WindowManager-Shell-lite-proto",
         "WindowManager-Shell-shared",
diff --git a/libs/WindowManager/Shell/res/drawable/decor_desktop_mode_immersive_exit_button_dark.xml b/libs/WindowManager/Shell/res/drawable/decor_desktop_mode_immersive_exit_button_dark.xml
new file mode 100644
index 0000000..5260450
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/decor_desktop_mode_immersive_exit_button_dark.xml
@@ -0,0 +1,26 @@
+<?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.
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+    android:tint="?attr/colorControlNormal">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M240,840L240,720L120,720L120,640L320,640L320,840L240,840ZM640,840L640,640L840,640L840,720L720,720L720,840L640,840ZM120,320L120,240L240,240L240,120L320,120L320,320L120,320ZM640,320L640,120L720,120L720,240L840,240L840,320L640,320Z"/>
+</vector>
diff --git a/libs/WindowManager/Shell/res/values-en-rGB/strings.xml b/libs/WindowManager/Shell/res/values-en-rGB/strings.xml
index 766852d..d5b9703 100644
--- a/libs/WindowManager/Shell/res/values-en-rGB/strings.xml
+++ b/libs/WindowManager/Shell/res/values-en-rGB/strings.xml
@@ -97,6 +97,12 @@
     <string name="camera_compat_treatment_suggested_button_description" msgid="8103916969024076767">"Camera issues?\nTap to refit"</string>
     <string name="camera_compat_treatment_applied_button_description" msgid="2944157113330703897">"Didn’t fix it?\nTap to revert"</string>
     <string name="camera_compat_dismiss_button_description" msgid="2795364433503817511">"No camera issues? Tap to dismiss."</string>
+    <!-- no translation found for windowing_app_handle_education_tooltip (6398482412956375783) -->
+    <skip />
+    <!-- no translation found for windowing_desktop_mode_image_button_education_tooltip (6285279585554484957) -->
+    <skip />
+    <!-- no translation found for windowing_desktop_mode_exit_education_tooltip (6685429075790085337) -->
+    <skip />
     <string name="letterbox_education_dialog_title" msgid="7739895354143295358">"See and do more"</string>
     <string name="letterbox_education_split_screen_text" msgid="449233070804658627">"Drag in another app for split screen"</string>
     <string name="letterbox_education_reposition_text" msgid="4589957299813220661">"Double-tap outside an app to reposition it"</string>
@@ -129,7 +135,8 @@
     <string name="desktop_mode_app_header_chip_text" msgid="6366422614991687237">"Open menu"</string>
     <string name="desktop_mode_maximize_menu_maximize_text" msgid="3275717276171114411">"Maximise screen"</string>
     <string name="desktop_mode_maximize_menu_snap_text" msgid="2065251022783880154">"Snap screen"</string>
-    <string name="desktop_mode_non_resizable_snap_text" msgid="1049800446363800707">"This app can\'t be resized"</string>
+    <!-- no translation found for desktop_mode_non_resizable_snap_text (3771776422751387878) -->
+    <skip />
     <string name="desktop_mode_maximize_menu_maximize_button_text" msgid="3090199175564175845">"Maximise"</string>
     <string name="desktop_mode_maximize_menu_snap_left_button_text" msgid="8077452201179893424">"Snap left"</string>
     <string name="desktop_mode_maximize_menu_snap_right_button_text" msgid="7117751068945657304">"Snap right"</string>
diff --git a/libs/WindowManager/Shell/res/values-pt-rBR/strings.xml b/libs/WindowManager/Shell/res/values-pt-rBR/strings.xml
index 75c445c..18048ff 100644
--- a/libs/WindowManager/Shell/res/values-pt-rBR/strings.xml
+++ b/libs/WindowManager/Shell/res/values-pt-rBR/strings.xml
@@ -97,6 +97,12 @@
     <string name="camera_compat_treatment_suggested_button_description" msgid="8103916969024076767">"Problemas com a câmera?\nToque para ajustar o enquadramento"</string>
     <string name="camera_compat_treatment_applied_button_description" msgid="2944157113330703897">"O problema não foi corrigido?\nToque para reverter"</string>
     <string name="camera_compat_dismiss_button_description" msgid="2795364433503817511">"Não tem problemas com a câmera? Toque para dispensar."</string>
+    <!-- no translation found for windowing_app_handle_education_tooltip (6398482412956375783) -->
+    <skip />
+    <!-- no translation found for windowing_desktop_mode_image_button_education_tooltip (6285279585554484957) -->
+    <skip />
+    <!-- no translation found for windowing_desktop_mode_exit_education_tooltip (6685429075790085337) -->
+    <skip />
     <string name="letterbox_education_dialog_title" msgid="7739895354143295358">"Veja e faça mais"</string>
     <string name="letterbox_education_split_screen_text" msgid="449233070804658627">"Arraste outro app para dividir a tela"</string>
     <string name="letterbox_education_reposition_text" msgid="4589957299813220661">"Toque duas vezes fora de um app para reposicionar"</string>
@@ -126,15 +132,12 @@
     <string name="manage_windows_text" msgid="5567366688493093920">"Gerenciar janelas"</string>
     <string name="close_text" msgid="4986518933445178928">"Fechar"</string>
     <string name="collapse_menu_text" msgid="7515008122450342029">"Fechar menu"</string>
-    <!-- no translation found for desktop_mode_app_header_chip_text (6366422614991687237) -->
-    <skip />
+    <string name="desktop_mode_app_header_chip_text" msgid="6366422614991687237">"Abrir o menu"</string>
     <string name="desktop_mode_maximize_menu_maximize_text" msgid="3275717276171114411">"Ampliar tela"</string>
     <string name="desktop_mode_maximize_menu_snap_text" msgid="2065251022783880154">"Ajustar tela"</string>
-    <string name="desktop_mode_non_resizable_snap_text" msgid="1049800446363800707">"Não é possível redimensionar o app"</string>
-    <!-- no translation found for desktop_mode_maximize_menu_maximize_button_text (3090199175564175845) -->
+    <!-- no translation found for desktop_mode_non_resizable_snap_text (3771776422751387878) -->
     <skip />
-    <!-- no translation found for desktop_mode_maximize_menu_snap_left_button_text (8077452201179893424) -->
-    <skip />
-    <!-- no translation found for desktop_mode_maximize_menu_snap_right_button_text (7117751068945657304) -->
-    <skip />
+    <string name="desktop_mode_maximize_menu_maximize_button_text" msgid="3090199175564175845">"Maximizar"</string>
+    <string name="desktop_mode_maximize_menu_snap_left_button_text" msgid="8077452201179893424">"Ajustar à esquerda"</string>
+    <string name="desktop_mode_maximize_menu_snap_right_button_text" msgid="7117751068945657304">"Ajustar à direita"</string>
 </resources>
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/EventLogTags.logtags b/libs/WindowManager/Shell/src/com/android/wm/shell/EventLogTags.logtags
new file mode 100644
index 0000000..db960d1
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/EventLogTags.logtags
@@ -0,0 +1,11 @@
+# See system/logging/logcat/event.logtags for a description of the format of this file.
+
+option java_package com.android.wm.shell
+
+# Do not change these names without updating the checkin_events setting in
+# google3/googledata/wireless/android/provisioning/gservices.config !!
+#
+
+38500 wm_shell_enter_desktop_mode (EnterReason|1|5),(SessionId|1|5)
+38501 wm_shell_exit_desktop_mode (ExitReason|1|5),(SessionId|1|5)
+38502 wm_shell_desktop_mode_task_update (TaskEvent|1|5),(InstanceId|1|5),(uid|1|5),(TaskHeight|1),(TaskWidth|1),(TaskX|1),(TaskY|1),(SessionId|1|5),(MinimiseReason|1|5),(UnminimiseReason|1|5),(VisibleTaskCount|1)
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt
index 05ce361..71bcb59 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/apptoweb/AppToWebUtils.kt
@@ -20,10 +20,11 @@
 
 import android.content.Context
 import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
 import android.content.pm.PackageManager
 import android.net.Uri
 
-private val browserIntent = Intent()
+private val GenericBrowserIntent = Intent()
     .setAction(Intent.ACTION_VIEW)
     .addCategory(Intent.CATEGORY_BROWSABLE)
     .setData(Uri.parse("http:"))
@@ -32,9 +33,9 @@
  * Returns a boolean indicating whether a given package is a browser app.
  */
 fun isBrowserApp(context: Context, packageName: String, userId: Int): Boolean {
-    browserIntent.setPackage(packageName)
+    GenericBrowserIntent.setPackage(packageName)
     val list = context.packageManager.queryIntentActivitiesAsUser(
-        browserIntent, PackageManager.MATCH_ALL, userId
+        GenericBrowserIntent, PackageManager.MATCH_ALL, userId
     )
 
     list.forEach {
@@ -44,3 +45,17 @@
     }
     return false
 }
+
+/**
+ * Returns intent if there is a browser application available to handle the uri. Otherwise, returns
+ * null.
+ */
+fun getBrowserIntent(uri: Uri, packageManager: PackageManager): Intent? {
+    val intent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_BROWSER)
+        .setData(uri)
+        .addFlags(FLAG_ACTIVITY_NEW_TASK)
+    // If there is no browser application available to handle intent, return null
+    val component = intent.resolveActivity(packageManager) ?: return null
+    intent.setComponent(component)
+    return intent
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java
index 3e5adf3..5836085 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/back/BackAnimationController.java
@@ -1501,10 +1501,6 @@
                 int rootIdx = -1;
                 for (int i = info.getChanges().size() - 1; i >= 0; --i) {
                     final TransitionInfo.Change c = info.getChanges().get(i);
-                    if (c.hasFlags(FLAG_IS_WALLPAPER)) {
-                        st.setAlpha(c.getLeash(), 1.0f);
-                        continue;
-                    }
                     if (TransitionUtil.isOpeningMode(c.getMode())) {
                         final Point offset = c.getEndRelOffset();
                         st.setPosition(c.getLeash(), offset.x, offset.y);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
index 2c03059..7293956 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java
@@ -16,6 +16,7 @@
 
 package com.android.wm.shell.dagger;
 
+import static android.window.flags.DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_EXIT_TRANSITIONS;
 import static android.window.flags.DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_TASK_LIMIT;
 
 import android.annotation.Nullable;
@@ -61,8 +62,10 @@
 import com.android.wm.shell.common.TaskStackListenerImpl;
 import com.android.wm.shell.dagger.back.ShellBackAnimationModule;
 import com.android.wm.shell.dagger.pip.PipModule;
+import com.android.wm.shell.desktopmode.CloseDesktopTaskTransitionHandler;
 import com.android.wm.shell.desktopmode.DefaultDragToDesktopTransitionHandler;
 import com.android.wm.shell.desktopmode.DesktopActivityOrientationChangeHandler;
+import com.android.wm.shell.desktopmode.DesktopMixedTransitionHandler;
 import com.android.wm.shell.desktopmode.DesktopModeDragAndDropTransitionHandler;
 import com.android.wm.shell.desktopmode.DesktopModeEventLogger;
 import com.android.wm.shell.desktopmode.DesktopModeLoggerTransitionObserver;
@@ -87,6 +90,8 @@
 import com.android.wm.shell.freeform.FreeformTaskListener;
 import com.android.wm.shell.freeform.FreeformTaskTransitionHandler;
 import com.android.wm.shell.freeform.FreeformTaskTransitionObserver;
+import com.android.wm.shell.freeform.FreeformTaskTransitionStarter;
+import com.android.wm.shell.freeform.FreeformTaskTransitionStarterInitializer;
 import com.android.wm.shell.keyguard.KeyguardTransitionHandler;
 import com.android.wm.shell.onehanded.OneHandedController;
 import com.android.wm.shell.pip.PipTransitionController;
@@ -240,6 +245,7 @@
             IWindowManager windowManager,
             ShellCommandHandler shellCommandHandler,
             ShellTaskOrganizer taskOrganizer,
+            @DynamicOverride DesktopModeTaskRepository desktopRepository,
             DisplayController displayController,
             ShellController shellController,
             DisplayInsetsController displayInsetsController,
@@ -266,6 +272,7 @@
                     shellCommandHandler,
                     windowManager,
                     taskOrganizer,
+                    desktopRepository,
                     displayController,
                     shellController,
                     displayInsetsController,
@@ -330,9 +337,13 @@
     static FreeformComponents provideFreeformComponents(
             FreeformTaskListener taskListener,
             FreeformTaskTransitionHandler transitionHandler,
-            FreeformTaskTransitionObserver transitionObserver) {
+            FreeformTaskTransitionObserver transitionObserver,
+            FreeformTaskTransitionStarterInitializer transitionStarterInitializer) {
         return new FreeformComponents(
-                taskListener, Optional.of(transitionHandler), Optional.of(transitionObserver));
+                taskListener,
+                Optional.of(transitionHandler),
+                Optional.of(transitionObserver),
+                Optional.of(transitionStarterInitializer));
     }
 
     @WMSingleton
@@ -356,27 +367,15 @@
     @WMSingleton
     @Provides
     static FreeformTaskTransitionHandler provideFreeformTaskTransitionHandler(
-            ShellInit shellInit,
             Transitions transitions,
-            Context context,
-            WindowDecorViewModel windowDecorViewModel,
             DisplayController displayController,
             @ShellMainThread ShellExecutor mainExecutor,
-            @ShellAnimationThread ShellExecutor animExecutor,
-            @DynamicOverride DesktopModeTaskRepository desktopModeTaskRepository,
-            InteractionJankMonitor interactionJankMonitor,
-            @ShellMainThread Handler handler) {
+            @ShellAnimationThread ShellExecutor animExecutor) {
         return new FreeformTaskTransitionHandler(
-                shellInit,
                 transitions,
-                context,
-                windowDecorViewModel,
                 displayController,
                 mainExecutor,
-                animExecutor,
-                desktopModeTaskRepository,
-                interactionJankMonitor,
-                handler);
+                animExecutor);
     }
 
     @WMSingleton
@@ -390,6 +389,23 @@
                 context, shellInit, transitions, windowDecorViewModel);
     }
 
+    @WMSingleton
+    @Provides
+    static FreeformTaskTransitionStarterInitializer provideFreeformTaskTransitionStarterInitializer(
+            ShellInit shellInit,
+            WindowDecorViewModel windowDecorViewModel,
+            FreeformTaskTransitionHandler freeformTaskTransitionHandler,
+            Optional<DesktopMixedTransitionHandler> desktopMixedTransitionHandler) {
+        FreeformTaskTransitionStarter transitionStarter;
+        if (desktopMixedTransitionHandler.isPresent()) {
+            transitionStarter = desktopMixedTransitionHandler.get();
+        } else {
+            transitionStarter = freeformTaskTransitionHandler;
+        }
+        return new FreeformTaskTransitionStarterInitializer(shellInit, windowDecorViewModel,
+                transitionStarter);
+    }
+
     //
     // One handed mode
     //
@@ -699,7 +715,17 @@
             InteractionJankMonitor interactionJankMonitor,
             @ShellMainThread Handler handler) {
         return new ExitDesktopTaskTransitionHandler(
-            transitions, context, interactionJankMonitor, handler);
+                transitions, context, interactionJankMonitor, handler);
+    }
+
+    @WMSingleton
+    @Provides
+    static CloseDesktopTaskTransitionHandler provideCloseDesktopTaskTransitionHandler(
+            Context context,
+            @ShellMainThread ShellExecutor mainExecutor,
+            @ShellAnimationThread ShellExecutor animExecutor
+    ) {
+        return new CloseDesktopTaskTransitionHandler(context, mainExecutor, animExecutor);
     }
 
     @WMSingleton
@@ -758,6 +784,32 @@
 
     @WMSingleton
     @Provides
+    static Optional<DesktopMixedTransitionHandler> provideDesktopMixedTransitionHandler(
+            Context context,
+            Transitions transitions,
+            @DynamicOverride DesktopModeTaskRepository desktopModeTaskRepository,
+            FreeformTaskTransitionHandler freeformTaskTransitionHandler,
+            CloseDesktopTaskTransitionHandler closeDesktopTaskTransitionHandler,
+            InteractionJankMonitor interactionJankMonitor,
+            @ShellMainThread Handler handler
+    ) {
+        if (!DesktopModeStatus.canEnterDesktopMode(context)
+                || !ENABLE_DESKTOP_WINDOWING_EXIT_TRANSITIONS.isTrue()) {
+            return Optional.empty();
+        }
+        return Optional.of(
+                new DesktopMixedTransitionHandler(
+                        context,
+                        transitions,
+                        desktopModeTaskRepository,
+                        freeformTaskTransitionHandler,
+                        closeDesktopTaskTransitionHandler,
+                        interactionJankMonitor,
+                        handler));
+    }
+
+    @WMSingleton
+    @Provides
     static DesktopModeLoggerTransitionObserver provideDesktopModeLoggerTransitionObserver(
             Context context,
             ShellInit shellInit,
@@ -799,10 +851,11 @@
     static DesktopWindowingEducationTooltipController
             provideDesktopWindowingEducationTooltipController(
             Context context,
-            AdditionalSystemViewContainer.Factory additionalSystemViewContainerFactory
+            AdditionalSystemViewContainer.Factory additionalSystemViewContainerFactory,
+            DisplayController displayController
     ) {
         return new DesktopWindowingEducationTooltipController(context,
-                additionalSystemViewContainerFactory);
+                additionalSystemViewContainerFactory, displayController);
     }
 
     @OptIn(markerClass = ExperimentalCoroutinesApi.class)
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/CloseDesktopTaskTransitionHandler.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/CloseDesktopTaskTransitionHandler.kt
new file mode 100644
index 0000000..a16c15df
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/CloseDesktopTaskTransitionHandler.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.wm.shell.desktopmode
+
+import android.animation.Animator
+import android.animation.AnimatorSet
+import android.animation.RectEvaluator
+import android.animation.ValueAnimator
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.content.Context
+import android.graphics.Rect
+import android.os.IBinder
+import android.util.TypedValue
+import android.view.SurfaceControl.Transaction
+import android.view.WindowManager
+import android.window.TransitionInfo
+import android.window.TransitionRequestInfo
+import android.window.WindowContainerTransaction
+import androidx.core.animation.addListener
+import com.android.app.animation.Interpolators
+import com.android.wm.shell.common.ShellExecutor
+import com.android.wm.shell.transition.Transitions
+import java.util.function.Supplier
+
+/** The [Transitions.TransitionHandler] that handles transitions for closing desktop mode tasks. */
+class CloseDesktopTaskTransitionHandler
+@JvmOverloads
+constructor(
+    private val context: Context,
+    private val mainExecutor: ShellExecutor,
+    private val animExecutor: ShellExecutor,
+    private val transactionSupplier: Supplier<Transaction> = Supplier { Transaction() },
+) : Transitions.TransitionHandler {
+
+    private val runningAnimations = mutableMapOf<IBinder, List<Animator>>()
+
+    /** Returns null, as it only handles transitions started from Shell. */
+    override fun handleRequest(
+        transition: IBinder,
+        request: TransitionRequestInfo,
+    ): WindowContainerTransaction? = null
+
+    override fun startAnimation(
+        transition: IBinder,
+        info: TransitionInfo,
+        startTransaction: Transaction,
+        finishTransaction: Transaction,
+        finishCallback: Transitions.TransitionFinishCallback,
+    ): Boolean {
+        if (info.type != WindowManager.TRANSIT_CLOSE) return false
+        val animations = mutableListOf<Animator>()
+        val onAnimFinish: (Animator) -> Unit = { animator ->
+            mainExecutor.execute {
+                // Animation completed
+                animations.remove(animator)
+                if (animations.isEmpty()) {
+                    // All animations completed, finish the transition
+                    runningAnimations.remove(transition)
+                    finishCallback.onTransitionFinished(/* wct= */ null)
+                }
+            }
+        }
+        animations +=
+            info.changes
+                .filter {
+                    it.mode == WindowManager.TRANSIT_CLOSE &&
+                        it.taskInfo?.windowingMode == WINDOWING_MODE_FREEFORM
+                }
+                .map { createCloseAnimation(it, finishTransaction, onAnimFinish) }
+        if (animations.isEmpty()) return false
+        runningAnimations[transition] = animations
+        animExecutor.execute { animations.forEach(Animator::start) }
+        return true
+    }
+
+    private fun createCloseAnimation(
+        change: TransitionInfo.Change,
+        finishTransaction: Transaction,
+        onAnimFinish: (Animator) -> Unit,
+    ): Animator {
+        finishTransaction.hide(change.leash)
+        return AnimatorSet().apply {
+            playTogether(createBoundsCloseAnimation(change), createAlphaCloseAnimation(change))
+            addListener(onEnd = onAnimFinish)
+        }
+    }
+
+    private fun createBoundsCloseAnimation(change: TransitionInfo.Change): Animator {
+        val startBounds = change.startAbsBounds
+        val endBounds =
+            Rect(startBounds).apply {
+                // Scale the end bounds of the window down with an anchor in the center
+                inset(
+                    (startBounds.width().toFloat() * (1 - CLOSE_ANIM_SCALE) / 2).toInt(),
+                    (startBounds.height().toFloat() * (1 - CLOSE_ANIM_SCALE) / 2).toInt()
+                )
+                val offsetY =
+                    TypedValue.applyDimension(
+                            TypedValue.COMPLEX_UNIT_DIP,
+                            CLOSE_ANIM_OFFSET_Y,
+                            context.resources.displayMetrics
+                        )
+                        .toInt()
+                offset(/* dx= */ 0, offsetY)
+            }
+        return ValueAnimator.ofObject(RectEvaluator(), startBounds, endBounds).apply {
+            duration = CLOSE_ANIM_DURATION_BOUNDS
+            interpolator = Interpolators.STANDARD_ACCELERATE
+            addUpdateListener { animation ->
+                val animBounds = animation.animatedValue as Rect
+                val animScale = 1 - (1 - CLOSE_ANIM_SCALE) * animation.animatedFraction
+                transactionSupplier
+                    .get()
+                    .setPosition(change.leash, animBounds.left.toFloat(), animBounds.top.toFloat())
+                    .setScale(change.leash, animScale, animScale)
+                    .apply()
+            }
+        }
+    }
+
+    private fun createAlphaCloseAnimation(change: TransitionInfo.Change): Animator =
+        ValueAnimator.ofFloat(1f, 0f).apply {
+            duration = CLOSE_ANIM_DURATION_ALPHA
+            interpolator = Interpolators.LINEAR
+            addUpdateListener { animation ->
+                transactionSupplier
+                    .get()
+                    .setAlpha(change.leash, animation.animatedValue as Float)
+                    .apply()
+            }
+        }
+
+    private companion object {
+        const val CLOSE_ANIM_DURATION_BOUNDS = 200L
+        const val CLOSE_ANIM_DURATION_ALPHA = 100L
+        const val CLOSE_ANIM_SCALE = 0.95f
+        const val CLOSE_ANIM_OFFSET_Y = 36.0f
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt
new file mode 100644
index 0000000..ec3f8c5
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandler.kt
@@ -0,0 +1,157 @@
+/*
+ * 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.wm.shell.desktopmode
+
+import android.app.ActivityTaskManager.INVALID_TASK_ID
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.content.Context
+import android.os.Handler
+import android.os.IBinder
+import android.view.SurfaceControl
+import android.view.WindowManager
+import android.window.TransitionInfo
+import android.window.TransitionRequestInfo
+import android.window.WindowContainerTransaction
+import com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.internal.protolog.ProtoLog
+import com.android.wm.shell.freeform.FreeformTaskTransitionHandler
+import com.android.wm.shell.freeform.FreeformTaskTransitionStarter
+import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE
+import com.android.wm.shell.shared.annotations.ShellMainThread
+import com.android.wm.shell.transition.MixedTransitionHandler
+import com.android.wm.shell.transition.Transitions
+
+/** The [Transitions.TransitionHandler] coordinates transition handlers in desktop windowing. */
+class DesktopMixedTransitionHandler(
+    private val context: Context,
+    private val transitions: Transitions,
+    private val desktopTaskRepository: DesktopModeTaskRepository,
+    private val freeformTaskTransitionHandler: FreeformTaskTransitionHandler,
+    private val closeDesktopTaskTransitionHandler: CloseDesktopTaskTransitionHandler,
+    private val interactionJankMonitor: InteractionJankMonitor,
+    @ShellMainThread private val handler: Handler,
+) : MixedTransitionHandler, FreeformTaskTransitionStarter {
+
+    /** Delegates starting transition to [FreeformTaskTransitionHandler]. */
+    override fun startWindowingModeTransition(
+        targetWindowingMode: Int,
+        wct: WindowContainerTransaction?,
+    ) = freeformTaskTransitionHandler.startWindowingModeTransition(targetWindowingMode, wct)
+
+    /** Delegates starting minimized mode transition to [FreeformTaskTransitionHandler]. */
+    override fun startMinimizedModeTransition(wct: WindowContainerTransaction?): IBinder =
+        freeformTaskTransitionHandler.startMinimizedModeTransition(wct)
+
+    /** Starts close transition and handles or delegates desktop task close animation. */
+    override fun startRemoveTransition(wct: WindowContainerTransaction?) {
+        requireNotNull(wct)
+        transitions.startTransition(WindowManager.TRANSIT_CLOSE, wct, /* handler= */ this)
+    }
+
+    /** Returns null, as it only handles transitions started from Shell. */
+    override fun handleRequest(
+        transition: IBinder,
+        request: TransitionRequestInfo,
+    ): WindowContainerTransaction? = null
+
+    override fun startAnimation(
+        transition: IBinder,
+        info: TransitionInfo,
+        startTransaction: SurfaceControl.Transaction,
+        finishTransaction: SurfaceControl.Transaction,
+        finishCallback: Transitions.TransitionFinishCallback,
+    ): Boolean {
+        val closeChange = findCloseDesktopTaskChange(info)
+        if (closeChange == null) {
+            ProtoLog.w(WM_SHELL_DESKTOP_MODE, "%s: Should have closing desktop task", TAG)
+            return false
+        }
+        if (isLastDesktopTask(closeChange)) {
+            // Dispatch close desktop task animation to the default transition handlers.
+            return dispatchCloseLastDesktopTaskAnimation(
+                transition,
+                info,
+                closeChange,
+                startTransaction,
+                finishTransaction,
+                finishCallback,
+            )
+        }
+        // Animate close desktop task transition with [CloseDesktopTaskTransitionHandler].
+        return closeDesktopTaskTransitionHandler.startAnimation(
+            transition,
+            info,
+            startTransaction,
+            finishTransaction,
+            finishCallback,
+        )
+    }
+
+    /**
+     * Dispatch close desktop task animation to the default transition handlers. Allows delegating
+     * it to Launcher to animate in sync with show Home transition.
+     */
+    private fun dispatchCloseLastDesktopTaskAnimation(
+        transition: IBinder,
+        info: TransitionInfo,
+        change: TransitionInfo.Change,
+        startTransaction: SurfaceControl.Transaction,
+        finishTransaction: SurfaceControl.Transaction,
+        finishCallback: Transitions.TransitionFinishCallback,
+    ): Boolean {
+        // Starting the jank trace if closing the last window in desktop mode.
+        interactionJankMonitor.begin(
+            change.leash,
+            context,
+            handler,
+            CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE,
+        )
+        // Dispatch the last desktop task closing animation.
+        return transitions.dispatchTransition(
+            transition,
+            info,
+            startTransaction,
+            finishTransaction,
+            { wct ->
+                // Finish the jank trace when closing the last window in desktop mode.
+                interactionJankMonitor.end(CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE)
+                finishCallback.onTransitionFinished(wct)
+            },
+            /* skip= */ this
+        ) != null
+    }
+
+    private fun isLastDesktopTask(change: TransitionInfo.Change): Boolean =
+        change.taskInfo?.let {
+            desktopTaskRepository.getActiveNonMinimizedTaskCount(it.displayId) == 1
+        } ?: false
+
+    private fun findCloseDesktopTaskChange(info: TransitionInfo): TransitionInfo.Change? {
+        if (info.type != WindowManager.TRANSIT_CLOSE) return null
+        return info.changes.firstOrNull { change ->
+            change.mode == WindowManager.TRANSIT_CLOSE &&
+                !change.hasFlags(TransitionInfo.FLAG_IS_WALLPAPER) &&
+                change.taskInfo?.taskId != INVALID_TASK_ID &&
+                change.taskInfo?.windowingMode == WINDOWING_MODE_FREEFORM
+        }
+    }
+
+    companion object {
+        private const val TAG = "DesktopMixedTransitionHandler"
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeEventLogger.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeEventLogger.kt
index 02cbe01..b1b7d05 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeEventLogger.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeEventLogger.kt
@@ -19,6 +19,7 @@
 import com.android.internal.annotations.VisibleForTesting
 import com.android.internal.protolog.ProtoLog
 import com.android.internal.util.FrameworkStatsLog
+import com.android.wm.shell.EventLogTags
 import com.android.wm.shell.protolog.ShellProtoLogGroup
 
 /** Event logger for logging desktop mode session events */
@@ -41,6 +42,7 @@
             /* exitReason */ 0,
             /* session_id */ sessionId
         )
+        EventLogTags.writeWmShellEnterDesktopMode(enterReason.reason, sessionId)
     }
 
     /**
@@ -61,6 +63,7 @@
             /* exitReason */ exitReason.reason,
             /* session_id */ sessionId
         )
+        EventLogTags.writeWmShellExitDesktopMode(exitReason.reason, sessionId)
     }
 
     /**
@@ -135,6 +138,28 @@
             /* visible_task_count */
             taskUpdate.visibleTaskCount
         )
+        EventLogTags.writeWmShellDesktopModeTaskUpdate(
+            /* task_event */
+            taskEvent,
+            /* instance_id */
+            taskUpdate.instanceId,
+            /* uid */
+            taskUpdate.uid,
+            /* task_height */
+            taskUpdate.taskHeight,
+            /* task_width */
+            taskUpdate.taskWidth,
+            /* task_x */
+            taskUpdate.taskX,
+            /* task_y */
+            taskUpdate.taskY,
+            /* session_id */
+            sessionId,
+            taskUpdate.minimizeReason?.reason ?: UNSET_MINIMIZE_REASON,
+            taskUpdate.unminimizeReason?.reason ?: UNSET_UNMINIMIZE_REASON,
+            /* visible_task_count */
+            taskUpdate.visibleTaskCount
+        )
     }
 
     companion object {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
index 0e8c4e7..985224e 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepository.kt
@@ -60,6 +60,7 @@
      * @property minimizedTasks task ids for active freeform tasks that are currently minimized.
      * @property closingTasks task ids for tasks that are going to close, but are currently visible.
      * @property freeformTasksInZOrder list of current freeform task ids ordered from top to bottom
+     * @property fullImmersiveTaskId the task id of the desktop task that is in full-immersive mode.
      * (top is at index 0).
      */
     private data class DesktopTaskData(
@@ -69,13 +70,15 @@
         // TODO(b/332682201): Remove when the repository state is updated via TransitionObserver
         val closingTasks: ArraySet<Int> = ArraySet(),
         val freeformTasksInZOrder: ArrayList<Int> = ArrayList(),
+        var fullImmersiveTaskId: Int? = null,
     ) {
         fun deepCopy(): DesktopTaskData = DesktopTaskData(
             activeTasks = ArraySet(activeTasks),
             visibleTasks = ArraySet(visibleTasks),
             minimizedTasks = ArraySet(minimizedTasks),
             closingTasks = ArraySet(closingTasks),
-            freeformTasksInZOrder = ArrayList(freeformTasksInZOrder)
+            freeformTasksInZOrder = ArrayList(freeformTasksInZOrder),
+            fullImmersiveTaskId = fullImmersiveTaskId
         )
     }
 
@@ -300,6 +303,23 @@
         }
     }
 
+    /** Set whether the given task is the full-immersive task in this display. */
+    fun setTaskInFullImmersiveState(displayId: Int, taskId: Int, immersive: Boolean) {
+        val desktopData = desktopTaskDataByDisplayId.getOrCreate(displayId)
+        if (immersive) {
+            desktopData.fullImmersiveTaskId = taskId
+        } else {
+            if (desktopData.fullImmersiveTaskId == taskId) {
+                desktopData.fullImmersiveTaskId = null
+            }
+        }
+    }
+
+    /* Whether the task is in full-immersive state. */
+    fun isTaskInFullImmersiveState(taskId: Int): Boolean {
+        return desktopTaskDataSequence().any { taskId == it.fullImmersiveTaskId }
+    }
+
     private fun notifyVisibleTaskListeners(displayId: Int, visibleTasksCount: Int) {
         visibleTasksListeners.forEach { (listener, executor) ->
             executor.execute { listener.onTasksVisibilityChanged(displayId, visibleTasksCount) }
@@ -330,8 +350,17 @@
 
     /** Minimizes the task for [taskId] and [displayId] */
     fun minimizeTask(displayId: Int, taskId: Int) {
-        logD("Minimize Task: display=%d, task=%d", displayId, taskId)
-        desktopTaskDataByDisplayId.getOrCreate(displayId).minimizedTasks.add(taskId)
+        if (displayId == INVALID_DISPLAY) {
+            // When a task vanishes it doesn't have a displayId. Find the display of the task and
+            // mark it as minimized.
+            getDisplayIdForTask(taskId)?.let {
+                minimizeTask(it, taskId)
+            } ?: logW("Minimize task: No display id found for task: taskId=%d", taskId)
+        } else {
+            logD("Minimize Task: display=%d, task=%d", displayId, taskId)
+            desktopTaskDataByDisplayId.getOrCreate(displayId).minimizedTasks.add(taskId)
+        }
+
         if (Flags.enableDesktopWindowingPersistence()) {
             updatePersistentRepository(displayId)
         }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
index 125805c..fcd2f8c 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
@@ -44,7 +44,6 @@
 import android.view.DragEvent
 import android.view.SurfaceControl
 import android.view.WindowManager.TRANSIT_CHANGE
-import android.view.WindowManager.TRANSIT_CLOSE
 import android.view.WindowManager.TRANSIT_NONE
 import android.view.WindowManager.TRANSIT_OPEN
 import android.view.WindowManager.TRANSIT_TO_FRONT
@@ -407,7 +406,7 @@
         interactionJankMonitor.begin(taskSurface, context, handler,
             CUJ_DESKTOP_MODE_ENTER_APP_HANDLE_DRAG_HOLD)
         dragToDesktopTransitionHandler.startDragToDesktopTransition(
-            taskInfo.taskId,
+            taskInfo,
             dragToDesktopValueAnimator
         )
     }
@@ -550,7 +549,29 @@
 
     /** Move a task to the front */
     fun moveTaskToFront(taskId: Int) {
-        shellTaskOrganizer.getRunningTaskInfo(taskId)?.let { task -> moveTaskToFront(task) }
+        val task = shellTaskOrganizer.getRunningTaskInfo(taskId)
+        if (task == null) moveBackgroundTaskToFront(taskId) else moveTaskToFront(task)
+    }
+
+    /**
+     * Launch a background task in desktop. Note that this should be used when we are already in
+     * desktop. If outside of desktop and want to launch a background task in desktop, use
+     * [moveBackgroundTaskToDesktop] instead.
+     */
+    private fun moveBackgroundTaskToFront(taskId: Int) {
+        logV("moveBackgroundTaskToFront taskId=%s", taskId)
+        val wct = WindowContainerTransaction()
+        // TODO: b/342378842 - Instead of using default display, support multiple displays
+        val taskToMinimize: RunningTaskInfo? =
+            addAndGetMinimizeChangesIfNeeded(DEFAULT_DISPLAY, wct, taskId)
+        wct.startTask(
+            taskId,
+            ActivityOptions.makeBasic().apply {
+                launchWindowingMode = WINDOWING_MODE_FREEFORM
+            }.toBundle(),
+        )
+        val transition = transitions.startTransition(TRANSIT_OPEN, wct, null /* handler */)
+        addPendingMinimizeTransition(transition, taskToMinimize)
     }
 
     /** Move a task to the front */
@@ -558,7 +579,8 @@
         logV("moveTaskToFront taskId=%s", taskInfo.taskId)
         val wct = WindowContainerTransaction()
         wct.reorder(taskInfo.token, true)
-        val taskToMinimize = addAndGetMinimizeChangesIfNeeded(taskInfo.displayId, wct, taskInfo)
+        val taskToMinimize =
+            addAndGetMinimizeChangesIfNeeded(taskInfo.displayId, wct, taskInfo.taskId)
         if (Transitions.ENABLE_SHELL_TRANSITIONS) {
             val transition = transitions.startTransition(TRANSIT_TO_FRONT, wct, null /* handler */)
             addPendingMinimizeTransition(transition, taskToMinimize)
@@ -1254,7 +1276,7 @@
         }
         // Desktop Mode is showing and we're launching a new Task - we might need to minimize
         // a Task.
-        val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task)
+        val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task.taskId)
         if (taskToMinimize != null) {
             addPendingMinimizeTransition(transition, taskToMinimize)
             return wct
@@ -1280,7 +1302,8 @@
 
                 // Desktop Mode is already showing and we're launching a new Task - we might need to
                 // minimize another Task.
-                val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task)
+                val taskToMinimize =
+                    addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task.taskId)
                 addPendingMinimizeTransition(transition, taskToMinimize)
             }
         }
@@ -1313,14 +1336,11 @@
             // Remove wallpaper activity when the last active task is removed
             removeWallpaperActivity(wct)
         }
-        taskRepository.addClosingTask(task.displayId, task.taskId)
-        // If a CLOSE is triggered on a desktop task, remove the task.
-        if (DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue() &&
-            taskRepository.isVisibleTask(task.taskId) &&
-            transitionType == TRANSIT_CLOSE
-        ) {
-            wct.removeTask(task.token)
+
+        if (!DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue()) {
+            taskRepository.addClosingTask(task.displayId, task.taskId)
         }
+
         taskbarDesktopTaskListener?.onTaskbarCornerRoundingUpdate(
             doesAnyTaskRequireTaskbarRounding(
                 task.displayId,
@@ -1425,12 +1445,12 @@
     private fun addAndGetMinimizeChangesIfNeeded(
         displayId: Int,
         wct: WindowContainerTransaction,
-        newTaskInfo: RunningTaskInfo
+        newTaskId: Int
     ): RunningTaskInfo? {
         if (!desktopTasksLimiter.isPresent) return null
         return desktopTasksLimiter
             .get()
-            .addAndGetMinimizeTaskChangesIfNeeded(displayId, wct, newTaskInfo)
+            .addAndGetMinimizeTaskChangesIfNeeded(displayId, wct, newTaskId)
     }
 
     private fun addPendingMinimizeTransition(
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt
index d84349b..37ad0c9 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksLimiter.kt
@@ -24,6 +24,7 @@
 import android.view.WindowManager.TRANSIT_TO_BACK
 import android.window.TransitionInfo
 import android.window.WindowContainerTransaction
+import android.window.flags.DesktopModeFlags
 import androidx.annotation.VisibleForTesting
 import com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_MINIMIZE_WINDOW
 import com.android.internal.jank.InteractionJankMonitor
@@ -161,6 +162,8 @@
     @VisibleForTesting
     inner class LeftoverMinimizedTasksRemover : DesktopModeTaskRepository.ActiveTasksListener {
         override fun onActiveTasksChanged(displayId: Int) {
+            // If back navigation is enabled, we shouldn't remove the leftover tasks
+            if (DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue()) return
             val wct = WindowContainerTransaction()
             removeLeftoverMinimizedTasks(displayId, wct)
             shellTaskOrganizer.applyTransaction(wct)
@@ -208,15 +211,15 @@
     fun addAndGetMinimizeTaskChangesIfNeeded(
             displayId: Int,
             wct: WindowContainerTransaction,
-            newFrontTaskInfo: RunningTaskInfo,
+            newFrontTaskId: Int,
     ): RunningTaskInfo? {
         ProtoLog.v(
                 ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE,
                 "DesktopTasksLimiter: addMinimizeBackTaskChangesIfNeeded, newFrontTask=%d",
-                newFrontTaskInfo.taskId)
+            newFrontTaskId)
         val newTaskListOrderedFrontToBack = createOrderedTaskListWithGivenTaskInFront(
                 taskRepository.getActiveNonMinimizedOrderedTasks(displayId),
-                newFrontTaskInfo.taskId)
+            newFrontTaskId)
         val taskToMinimize = getTaskToMinimizeIfNeeded(newTaskListOrderedFrontToBack)
         if (taskToMinimize != null) {
             wct.reorder(taskToMinimize.token, false /* onTop */)
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserver.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserver.kt
index 4796c4d..b20c9fc 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserver.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserver.kt
@@ -29,6 +29,7 @@
 import com.android.internal.protolog.ProtoLog
 import com.android.wm.shell.ShellTaskOrganizer
 import com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_DESKTOP_MODE
+import com.android.wm.shell.shared.TransitionUtil
 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus
 import com.android.wm.shell.sysui.ShellInit
 import com.android.wm.shell.transition.Transitions
@@ -67,9 +68,29 @@
     ) {
         // TODO: b/332682201 Update repository state
         updateWallpaperToken(info)
-
         if (DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue()) {
             handleBackNavigation(info)
+            removeTaskIfNeeded(info)
+        }
+    }
+
+    private fun removeTaskIfNeeded(info: TransitionInfo) {
+        // Since we are no longer removing all the tasks [onTaskVanished], we need to remove them by
+        // checking the transitions.
+        if (!TransitionUtil.isOpeningType(info.type)) return
+        // Remove a task from the repository if the app is launched outside of desktop.
+        for (change in info.changes) {
+            val taskInfo = change.taskInfo
+            if (taskInfo == null || taskInfo.taskId == -1) continue
+
+            if (desktopModeTaskRepository.isActiveTask(taskInfo.taskId)
+                && taskInfo.windowingMode != WINDOWING_MODE_FREEFORM
+            ) {
+                desktopModeTaskRepository.removeFreeformTask(
+                    taskInfo.displayId,
+                    taskInfo.taskId
+                )
+            }
         }
     }
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt
index 2bc01b2..8e264b2 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandler.kt
@@ -109,8 +109,8 @@
      * after one of the "end" or "cancel" transitions is merged into this transition.
      */
     fun startDragToDesktopTransition(
-        taskId: Int,
-        dragToDesktopAnimator: MoveToDesktopAnimator,
+        taskInfo: RunningTaskInfo,
+        dragToDesktopAnimator: MoveToDesktopAnimator
     ) {
         if (inProgress) {
             ProtoLog.v(
@@ -137,23 +137,26 @@
             )
         val wct = WindowContainerTransaction()
         wct.sendPendingIntent(pendingIntent, launchHomeIntent, Bundle())
+        // The home launch done above will result in an attempt to move the task to pip if
+        // applicable, resulting in a broken state. Prevent that here.
+        wct.setDoNotPip(taskInfo.token)
         val startTransitionToken =
             transitions.startTransition(TRANSIT_DESKTOP_MODE_START_DRAG_TO_DESKTOP, wct, this)
 
         transitionState =
-            if (isSplitTask(taskId)) {
+            if (isSplitTask(taskInfo.taskId)) {
                 val otherTask =
-                    getOtherSplitTask(taskId)
+                    getOtherSplitTask(taskInfo.taskId)
                         ?: throw IllegalStateException("Expected split task to have a counterpart.")
                 TransitionState.FromSplit(
-                    draggedTaskId = taskId,
+                    draggedTaskId = taskInfo.taskId,
                     dragAnimator = dragToDesktopAnimator,
                     startTransitionToken = startTransitionToken,
                     otherSplitTask = otherTask
                 )
             } else {
                 TransitionState.FromFullscreen(
-                    draggedTaskId = taskId,
+                    draggedTaskId = taskInfo.taskId,
                     dragAnimator = dragToDesktopAnimator,
                     startTransitionToken = startTransitionToken
                 )
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt
index a1dfb68..68a250d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/AppHandleEducationController.kt
@@ -45,6 +45,7 @@
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.emptyFlow
 import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
@@ -95,7 +96,25 @@
               }
             }
             .flowOn(backgroundDispatcher)
-            .collectLatest { captionState -> showEducation(captionState) }
+            .collectLatest { captionState ->
+              showEducation(captionState)
+              // After showing first tooltip, mark education as viewed
+              appHandleEducationDatastoreRepository.updateEducationViewedTimestampMillis(true)
+            }
+      }
+
+      applicationCoroutineScope.launch {
+        if (isFeatureUsed()) return@launch
+        windowDecorCaptionHandleRepository.captionStateFlow
+            .filter { captionState ->
+              captionState is CaptionState.AppHandle && captionState.isHandleMenuExpanded
+            }
+            .take(1)
+            .flowOn(backgroundDispatcher)
+            .collect {
+              // If user expands app handle, mark user has used the feature
+              appHandleEducationDatastoreRepository.updateFeatureUsedTimestampMillis(true)
+            }
       }
     }
   }
@@ -272,6 +291,13 @@
           .map { preferences -> preferences.hasEducationViewedTimestampMillis() }
           .distinctUntilChanged()
 
+  /**
+   * Listens to the changes to [WindowingEducationProto#hasFeatureUsedTimestampMillis()] in
+   * datastore proto object.
+   */
+  private suspend fun isFeatureUsed(): Boolean =
+      appHandleEducationDatastoreRepository.dataStoreFlow.first().hasFeatureUsedTimestampMillis()
+
   private fun getSize(@DimenRes resourceId: Int): Int {
     if (resourceId == Resources.ID_NULL) return 0
     return context.resources.getDimensionPixelSize(resourceId)
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppHandleEducationDatastoreRepository.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppHandleEducationDatastoreRepository.kt
index f420c5b..d21b208 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppHandleEducationDatastoreRepository.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/education/data/AppHandleEducationDatastoreRepository.kt
@@ -71,6 +71,37 @@
   suspend fun windowingEducationProto(): WindowingEducationProto = dataStoreFlow.first()
 
   /**
+   * Updates [WindowingEducationProto.educationViewedTimestampMillis_] field in datastore with
+   * current timestamp if [isViewed] is true, if not then clears the field.
+   */
+  suspend fun updateEducationViewedTimestampMillis(isViewed: Boolean) {
+    dataStore.updateData { preferences ->
+      if (isViewed) {
+        preferences
+            .toBuilder()
+            .setEducationViewedTimestampMillis(System.currentTimeMillis())
+            .build()
+      } else {
+        preferences.toBuilder().clearEducationViewedTimestampMillis().build()
+      }
+    }
+  }
+
+  /**
+   * Updates [WindowingEducationProto.featureUsedTimestampMillis_] field in datastore with current
+   * timestamp if [isViewed] is true, if not then clears the field.
+   */
+  suspend fun updateFeatureUsedTimestampMillis(isViewed: Boolean) {
+    dataStore.updateData { preferences ->
+      if (isViewed) {
+        preferences.toBuilder().setFeatureUsedTimestampMillis(System.currentTimeMillis()).build()
+      } else {
+        preferences.toBuilder().clearFeatureUsedTimestampMillis().build()
+      }
+    }
+  }
+
+  /**
    * Updates [AppHandleEducation.appUsageStats] and
    * [AppHandleEducation.appUsageStatsLastUpdateTimestampMillis] fields in datastore with
    * [appUsageStats] and [appUsageStatsLastUpdateTimestamp].
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformComponents.java b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformComponents.java
index eee5aae..3379ff2 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformComponents.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformComponents.java
@@ -35,6 +35,7 @@
     public final ShellTaskOrganizer.TaskListener mTaskListener;
     public final Optional<Transitions.TransitionHandler> mTransitionHandler;
     public final Optional<Transitions.TransitionObserver> mTransitionObserver;
+    public final Optional<FreeformTaskTransitionStarterInitializer> mTransitionStarterInitializer;
 
     /**
      * Creates an instance with the given components.
@@ -42,10 +43,12 @@
     public FreeformComponents(
             ShellTaskOrganizer.TaskListener taskListener,
             Optional<Transitions.TransitionHandler> transitionHandler,
-            Optional<Transitions.TransitionObserver> transitionObserver) {
+            Optional<Transitions.TransitionObserver> transitionObserver,
+            Optional<FreeformTaskTransitionStarterInitializer> transitionStarterInitializer) {
         mTaskListener = taskListener;
         mTransitionHandler = transitionHandler;
         mTransitionObserver = transitionObserver;
+        mTransitionStarterInitializer = transitionStarterInitializer;
     }
 
     /**
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
index 83cc18b..7f7f105 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskListener.java
@@ -24,6 +24,7 @@
 import android.content.Context;
 import android.util.SparseArray;
 import android.view.SurfaceControl;
+import android.window.flags.DesktopModeFlags;
 
 import com.android.internal.protolog.ProtoLog;
 import com.android.wm.shell.ShellTaskOrganizer;
@@ -121,7 +122,16 @@
 
         if (DesktopModeStatus.canEnterDesktopMode(mContext)) {
             mDesktopModeTaskRepository.ifPresent(repository -> {
-                repository.removeFreeformTask(taskInfo.displayId, taskInfo.taskId);
+                // TODO: b/370038902 - Handle Activity#finishAndRemoveTask.
+                if (!DesktopModeFlags.ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION.isTrue()
+                        || repository.isClosingTask(taskInfo.taskId)) {
+                    // A task that's vanishing should be removed:
+                    // - If it's closed by the X button which means it's marked as a closing task.
+                    repository.removeFreeformTask(taskInfo.displayId, taskInfo.taskId);
+                } else {
+                    repository.updateTaskVisibility(taskInfo.displayId, taskInfo.taskId, false);
+                    repository.minimizeTask(taskInfo.displayId, taskInfo.taskId);
+                }
             });
         }
         mWindowDecorationViewModel.onTaskVanished(taskInfo);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionHandler.java
index 517e209..6aaf001 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionHandler.java
@@ -19,16 +19,12 @@
 import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM;
 import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
 
-import static com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE;
-
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
 import android.animation.ValueAnimator;
 import android.app.ActivityManager;
 import android.app.WindowConfiguration;
-import android.content.Context;
 import android.graphics.Rect;
-import android.os.Handler;
 import android.os.IBinder;
 import android.util.ArrayMap;
 import android.view.SurfaceControl;
@@ -40,14 +36,9 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import com.android.internal.jank.InteractionJankMonitor;
 import com.android.wm.shell.common.DisplayController;
 import com.android.wm.shell.common.ShellExecutor;
-import com.android.wm.shell.desktopmode.DesktopModeTaskRepository;
-import com.android.wm.shell.shared.annotations.ShellMainThread;
-import com.android.wm.shell.sysui.ShellInit;
 import com.android.wm.shell.transition.Transitions;
-import com.android.wm.shell.windowdecor.WindowDecorViewModel;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -59,48 +50,24 @@
 public class FreeformTaskTransitionHandler
         implements Transitions.TransitionHandler, FreeformTaskTransitionStarter {
     private static final int CLOSE_ANIM_DURATION = 400;
-    private final Context mContext;
     private final Transitions mTransitions;
-    private final WindowDecorViewModel mWindowDecorViewModel;
-    private final DesktopModeTaskRepository mDesktopModeTaskRepository;
     private final DisplayController mDisplayController;
-    private final InteractionJankMonitor mInteractionJankMonitor;
     private final ShellExecutor mMainExecutor;
     private final ShellExecutor mAnimExecutor;
-    @ShellMainThread
-    private final Handler mHandler;
 
     private final List<IBinder> mPendingTransitionTokens = new ArrayList<>();
 
     private final ArrayMap<IBinder, ArrayList<Animator>> mAnimations = new ArrayMap<>();
 
     public FreeformTaskTransitionHandler(
-            ShellInit shellInit,
             Transitions transitions,
-            Context context,
-            WindowDecorViewModel windowDecorViewModel,
             DisplayController displayController,
             ShellExecutor mainExecutor,
-            ShellExecutor animExecutor,
-            DesktopModeTaskRepository desktopModeTaskRepository,
-            InteractionJankMonitor interactionJankMonitor,
-            @ShellMainThread Handler handler) {
+            ShellExecutor animExecutor) {
         mTransitions = transitions;
-        mContext = context;
-        mWindowDecorViewModel = windowDecorViewModel;
-        mDesktopModeTaskRepository = desktopModeTaskRepository;
         mDisplayController = displayController;
-        mInteractionJankMonitor = interactionJankMonitor;
         mMainExecutor = mainExecutor;
         mAnimExecutor = animExecutor;
-        mHandler = handler;
-        if (Transitions.ENABLE_SHELL_TRANSITIONS) {
-            shellInit.addInitCallback(this::onInit, this);
-        }
-    }
-
-    private void onInit() {
-        mWindowDecorViewModel.setFreeformTaskTransitionStarter(this);
     }
 
     @Override
@@ -269,20 +236,12 @@
                     startBounds.top + (animation.getAnimatedFraction() * screenHeight));
             t.apply();
         });
-        if (mDesktopModeTaskRepository.getActiveNonMinimizedTaskCount(
-                        change.getTaskInfo().displayId) == 1) {
-            // Starting the jank trace if closing the last window in desktop mode.
-            mInteractionJankMonitor.begin(
-                    sc, mContext, mHandler, CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE);
-        }
         animator.addListener(
                 new AnimatorListenerAdapter() {
                     @Override
                     public void onAnimationEnd(Animator animation) {
                         animations.remove(animator);
                         onAnimFinish.run();
-                        mInteractionJankMonitor.end(
-                                CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE);
                     }
                 });
         animations.add(animator);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionStarterInitializer.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionStarterInitializer.kt
new file mode 100644
index 0000000..98bdf05
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/freeform/FreeformTaskTransitionStarterInitializer.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.wm.shell.freeform
+
+import com.android.wm.shell.sysui.ShellInit
+import com.android.wm.shell.windowdecor.WindowDecorViewModel
+
+/**
+ * Sets up [FreeformTaskTransitionStarter] for [WindowDecorViewModel] when shell finishes
+ * initializing.
+ *
+ * Used to extract the setup logic from the starter implementation.
+ */
+class FreeformTaskTransitionStarterInitializer(
+    shellInit: ShellInit,
+    private val windowDecorViewModel: WindowDecorViewModel,
+    private val freeformTaskTransitionStarter: FreeformTaskTransitionStarter
+) {
+    init {
+        shellInit.addInitCallback(::onShellInit, this)
+    }
+
+    /** Sets up [WindowDecorViewModel] transition starter with [FreeformTaskTransitionStarter] */
+    private fun onShellInit() {
+        windowDecorViewModel.setFreeformTaskTransitionStarter(freeformTaskTransitionStarter)
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTransition.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTransition.java
index 1b9bf2a..dc0bc78 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTransition.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip2/phone/PipTransition.java
@@ -304,54 +304,28 @@
         if (pipChange == null) {
             return false;
         }
-        WindowContainerToken pipTaskToken = pipChange.getContainer();
         SurfaceControl pipLeash = pipChange.getLeash();
+        Preconditions.checkNotNull(pipLeash, "Leash is null for swipe-up transition.");
 
-        if (pipTaskToken == null || pipLeash == null) {
-            return false;
-        }
-
-        SurfaceControl overlayLeash = mPipTransitionState.getSwipePipToHomeOverlay();
-        PictureInPictureParams params = pipChange.getTaskInfo().pictureInPictureParams;
-
-        Rect appBounds = mPipTransitionState.getSwipePipToHomeAppBounds();
-        Rect destinationBounds = pipChange.getEndAbsBounds();
-
-        float aspectRatio = pipChange.getTaskInfo().pictureInPictureParams.getAspectRatioFloat();
-
-        // We fake the source rect hint when the one prvided by the app is invalid for
-        // the animation with an app icon overlay.
-        Rect animationSrcRectHint = overlayLeash == null ? params.getSourceRectHint()
-                : PipUtils.getEnterPipWithOverlaySrcRectHint(appBounds, aspectRatio);
-
-        WindowContainerTransaction finishWct = new WindowContainerTransaction();
-        SurfaceControl.Transaction tx = new SurfaceControl.Transaction();
-
-        final float scale = (float) destinationBounds.width() / animationSrcRectHint.width();
-        startTransaction.setWindowCrop(pipLeash, animationSrcRectHint);
-        startTransaction.setPosition(pipLeash,
-                destinationBounds.left - animationSrcRectHint.left * scale,
-                destinationBounds.top - animationSrcRectHint.top * scale);
-        startTransaction.setScale(pipLeash, scale, scale);
-
-        if (overlayLeash != null) {
+        final Rect destinationBounds = pipChange.getEndAbsBounds();
+        final SurfaceControl swipePipToHomeOverlay = mPipTransitionState.getSwipePipToHomeOverlay();
+        if (swipePipToHomeOverlay != null) {
             final int overlaySize = PipContentOverlay.PipAppIconOverlay.getOverlaySize(
                     mPipTransitionState.getSwipePipToHomeAppBounds(), destinationBounds);
-
-            // Overlay needs to be adjusted once a new draw comes in resetting surface transform.
-            tx.setScale(overlayLeash, 1f, 1f);
-            tx.setPosition(overlayLeash, (destinationBounds.width() - overlaySize) / 2f,
-                    (destinationBounds.height() - overlaySize) / 2f);
+            // It is possible we reparent the PIP activity to a new PIP task (in multi-activity
+            // apps), so we should also reparent the overlay to the final PIP task.
+            startTransaction.reparent(swipePipToHomeOverlay, pipLeash)
+                    .setLayer(swipePipToHomeOverlay, Integer.MAX_VALUE)
+                    .setScale(swipePipToHomeOverlay, 1f, 1f)
+                    .setPosition(swipePipToHomeOverlay,
+                            (destinationBounds.width() - overlaySize) / 2f,
+                            (destinationBounds.height() - overlaySize) / 2f);
         }
+
+        startTransaction.merge(finishTransaction);
         startTransaction.apply();
-
-        tx.addTransactionCommittedListener(mPipScheduler.getMainExecutor(),
-                        this::onClientDrawAtTransitionEnd);
-        finishWct.setBoundsChangeTransaction(pipTaskToken, tx);
-
-        // Note that finishWct should be free of any actual WM state changes; we are using
-        // it for syncing with the client draw after delayed configuration changes are dispatched.
-        finishCallback.onTransitionFinished(finishWct.isEmpty() ? null : finishWct);
+        finishCallback.onTransitionFinished(null /* finishWct */);
+        onClientDrawAtTransitionEnd();
         return true;
     }
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
index 03ff1aa..c9c0873 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentTasksController.java
@@ -32,6 +32,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.graphics.Color;
+import android.graphics.Point;
 import android.os.Bundle;
 import android.os.RemoteException;
 import android.util.Slog;
@@ -46,6 +47,7 @@
 import androidx.annotation.VisibleForTesting;
 
 import com.android.internal.protolog.ProtoLog;
+import com.android.window.flags.Flags;
 import com.android.wm.shell.common.ExternalInterfaceBinder;
 import com.android.wm.shell.common.RemoteCallable;
 import com.android.wm.shell.common.ShellExecutor;
@@ -421,6 +423,16 @@
                 if (mostRecentFreeformTaskIndex == Integer.MAX_VALUE) {
                     mostRecentFreeformTaskIndex = recentTasks.size();
                 }
+                // If task has their app bounds set to null which happens after reboot, set the
+                // app bounds to persisted lastFullscreenBounds. Also set the position in parent
+                // to the top left of the bounds.
+                if (Flags.enableDesktopWindowingPersistence()
+                        && taskInfo.configuration.windowConfiguration.getAppBounds() == null) {
+                    taskInfo.configuration.windowConfiguration.setAppBounds(
+                            taskInfo.lastNonFullscreenBounds);
+                    taskInfo.positionInParent = new Point(taskInfo.lastNonFullscreenBounds.left,
+                            taskInfo.lastNonFullscreenBounds.top);
+                }
                 freeformTasks.add(taskInfo);
                 if (mDesktopModeTaskRepository.get().isMinimizedTask(taskInfo.taskId)) {
                     minimizedFreeformTasks.add(taskInfo.taskId);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java
index 8077aee..f7ed1dd 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/recents/RecentsTransitionHandler.java
@@ -52,7 +52,6 @@
 import android.util.IntArray;
 import android.util.Pair;
 import android.util.Slog;
-import android.view.Display;
 import android.view.RemoteAnimationTarget;
 import android.view.SurfaceControl;
 import android.window.PictureInPictureSurfaceTransaction;
@@ -910,6 +909,14 @@
                             "task #" + taskInfo.taskId + " is always_on_top");
                     return;
                 }
+                if (TransitionUtil.isClosingType(change.getMode())
+                        && taskInfo != null && taskInfo.lastParentTaskIdBeforePip > 0) {
+                    // Pinned task is closing as a side effect of the removal of its original Task,
+                    // such transition should be handled by PiP. So cancel the merge here.
+                    cancel(false /* toHome */, false /* withScreenshots */,
+                            "task #" + taskInfo.taskId + " is removed with its original parent");
+                    return;
+                }
                 final boolean isRootTask = taskInfo != null
                         && TransitionInfo.isIndependent(change, info);
                 final boolean isRecentsTask = mRecentsTask != null
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java b/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java
index 6e084d6..2747249 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/startingsurface/TaskSnapshotWindow.java
@@ -146,6 +146,11 @@
             Slog.w(TAG, "Failed to relayout snapshot starting window");
             return null;
         }
+        if (!surfaceControl.isValid()) {
+            snapshotSurface.clearWindowSynced();
+            Slog.w(TAG, "Unable to draw snapshot, no valid surface");
+            return null;
+        }
 
         SnapshotDrawerUtils.drawSnapshotOnSurface(info, layoutParams, surfaceControl, snapshot,
                 info.taskBounds, topWindowInsetsState, true /* releaseAfterDraw */);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java
index 2f5059f..399e39a 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/FocusTransitionObserver.java
@@ -17,13 +17,13 @@
 package com.android.wm.shell.transition;
 
 import static android.view.Display.INVALID_DISPLAY;
+import static android.window.TransitionInfo.FLAG_IS_DISPLAY;
 import static android.window.TransitionInfo.FLAG_MOVED_TO_TOP;
 
 import static com.android.window.flags.Flags.enableDisplayFocusInShellTransitions;
 import static com.android.wm.shell.transition.Transitions.TransitionObserver;
 
 import android.annotation.NonNull;
-import android.app.ActivityManager.RunningTaskInfo;
 import android.os.IBinder;
 import android.os.RemoteException;
 import android.util.Slog;
@@ -62,10 +62,9 @@
         final List<TransitionInfo.Change> changes = info.getChanges();
         for (int i = changes.size() - 1; i >= 0; i--) {
             final TransitionInfo.Change change = changes.get(i);
-            final RunningTaskInfo task = change.getTaskInfo();
-            if (task != null && task.isFocused && change.hasFlags(FLAG_MOVED_TO_TOP)) {
-                if (mFocusedDisplayId != task.displayId) {
-                    mFocusedDisplayId = task.displayId;
+            if (change.hasFlags(FLAG_IS_DISPLAY) && change.hasFlags(FLAG_MOVED_TO_TOP)) {
+                if (mFocusedDisplayId != change.getEndDisplayId()) {
+                    mFocusedDisplayId = change.getEndDisplayId();
                     notifyFocusedDisplayChanged();
                 }
                 return;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
index d280dcd..d5e92e6 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/transition/Transitions.java
@@ -1036,9 +1036,14 @@
      * Gives every handler (in order) a chance to animate until one consumes the transition.
      * @return the handler which consumed the transition.
      */
-    TransitionHandler dispatchTransition(@NonNull IBinder transition, @NonNull TransitionInfo info,
-            @NonNull SurfaceControl.Transaction startT, @NonNull SurfaceControl.Transaction finishT,
-            @NonNull TransitionFinishCallback finishCB, @Nullable TransitionHandler skip) {
+    public TransitionHandler dispatchTransition(
+            @NonNull IBinder transition,
+            @NonNull TransitionInfo info,
+            @NonNull SurfaceControl.Transaction startT,
+            @NonNull SurfaceControl.Transaction finishT,
+            @NonNull TransitionFinishCallback finishCB,
+            @Nullable TransitionHandler skip
+    ) {
         for (int i = mHandlers.size() - 1; i >= 0; --i) {
             if (mHandlers.get(i) == skip) continue;
             ProtoLog.v(ShellProtoLogGroup.WM_SHELL_TRANSITIONS, " try handler %s",
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java
index 05065be..839973f 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/CaptionWindowDecoration.java
@@ -194,6 +194,8 @@
             ActivityManager.RunningTaskInfo taskInfo,
             boolean applyStartTransactionOnDraw,
             boolean setTaskCropAndPosition,
+            boolean isStatusBarVisible,
+            boolean isKeyguardVisibleAndOccluded,
             InsetsState displayInsetsState) {
         relayoutParams.reset();
         relayoutParams.mRunningTaskInfo = taskInfo;
@@ -204,6 +206,8 @@
                 : R.dimen.freeform_decor_shadow_unfocused_thickness;
         relayoutParams.mApplyStartTransactionOnDraw = applyStartTransactionOnDraw;
         relayoutParams.mSetTaskPositionAndCrop = setTaskCropAndPosition;
+        relayoutParams.mIsCaptionVisible = taskInfo.isFreeform()
+                || (isStatusBarVisible && !isKeyguardVisibleAndOccluded);
 
         if (TaskInfoKt.isTransparentCaptionBarAppearance(taskInfo)) {
             // If the app is requesting to customize the caption bar, allow input to fall
@@ -240,7 +244,8 @@
         final WindowContainerTransaction wct = new WindowContainerTransaction();
 
         updateRelayoutParams(mRelayoutParams, taskInfo, applyStartTransactionOnDraw,
-                setTaskCropAndPosition, mDisplayController.getInsetsState(taskInfo.displayId));
+                setTaskCropAndPosition, mIsStatusBarVisible, mIsKeyguardVisibleAndOccluded,
+                mDisplayController.getInsetsState(taskInfo.displayId));
 
         relayout(mRelayoutParams, startT, finishT, wct, oldRootView, mResult);
         // After this line, mTaskInfo is up-to-date and should be used instead of taskInfo
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
index c34a0bc..3330f96 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModel.java
@@ -22,9 +22,6 @@
 import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
 import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
 import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
-import static android.content.Intent.ACTION_MAIN;
-import static android.content.Intent.CATEGORY_APP_BROWSER;
-import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
 import static android.view.InputDevice.SOURCE_TOUCHSCREEN;
 import static android.view.MotionEvent.ACTION_CANCEL;
 import static android.view.MotionEvent.ACTION_HOVER_ENTER;
@@ -58,7 +55,6 @@
 import android.graphics.Rect;
 import android.graphics.Region;
 import android.hardware.input.InputManager;
-import android.net.Uri;
 import android.os.Handler;
 import android.os.IBinder;
 import android.os.Looper;
@@ -107,6 +103,7 @@
 import com.android.wm.shell.common.ShellExecutor;
 import com.android.wm.shell.common.SyncTransactionQueue;
 import com.android.wm.shell.desktopmode.DesktopActivityOrientationChangeHandler;
+import com.android.wm.shell.desktopmode.DesktopModeTaskRepository;
 import com.android.wm.shell.desktopmode.DesktopModeVisualIndicator;
 import com.android.wm.shell.desktopmode.DesktopTasksController;
 import com.android.wm.shell.desktopmode.DesktopTasksController.SnapPosition;
@@ -158,6 +155,7 @@
     private final ActivityTaskManager mActivityTaskManager;
     private final ShellCommandHandler mShellCommandHandler;
     private final ShellTaskOrganizer mTaskOrganizer;
+    private final DesktopModeTaskRepository mDesktopRepository;
     private final ShellController mShellController;
     private final Context mContext;
     private final @ShellMainThread Handler mMainHandler;
@@ -229,6 +227,7 @@
             ShellCommandHandler shellCommandHandler,
             IWindowManager windowManager,
             ShellTaskOrganizer taskOrganizer,
+            DesktopModeTaskRepository desktopRepository,
             DisplayController displayController,
             ShellController shellController,
             DisplayInsetsController displayInsetsController,
@@ -254,6 +253,7 @@
                 shellCommandHandler,
                 windowManager,
                 taskOrganizer,
+                desktopRepository,
                 displayController,
                 shellController,
                 displayInsetsController,
@@ -288,6 +288,7 @@
             ShellCommandHandler shellCommandHandler,
             IWindowManager windowManager,
             ShellTaskOrganizer taskOrganizer,
+            DesktopModeTaskRepository desktopRepository,
             DisplayController displayController,
             ShellController shellController,
             DisplayInsetsController displayInsetsController,
@@ -316,6 +317,7 @@
         mBgExecutor = bgExecutor;
         mActivityTaskManager = mContext.getSystemService(ActivityTaskManager.class);
         mTaskOrganizer = taskOrganizer;
+        mDesktopRepository = desktopRepository;
         mShellController = shellController;
         mDisplayController = displayController;
         mDisplayInsetsController = displayInsetsController;
@@ -560,20 +562,17 @@
         decoration.closeMaximizeMenu();
     }
 
-    private void onOpenInBrowser(int taskId, @NonNull Uri uri) {
+    private void onOpenInBrowser(int taskId, @NonNull Intent intent) {
         final DesktopModeWindowDecoration decoration = mWindowDecorByTaskId.get(taskId);
         if (decoration == null) {
             return;
         }
-        openInBrowser(uri, decoration.getUser());
+        openInBrowser(intent, decoration.getUser());
         decoration.closeHandleMenu();
         decoration.closeMaximizeMenu();
     }
 
-    private void openInBrowser(Uri uri, @NonNull UserHandle userHandle) {
-        final Intent intent = Intent.makeMainSelectorActivity(ACTION_MAIN, CATEGORY_APP_BROWSER)
-                .setData(uri)
-                .addFlags(FLAG_ACTIVITY_NEW_TASK);
+    private void openInBrowser(@NonNull Intent intent, @NonNull UserHandle userHandle) {
         mContext.startActivityAsUser(intent, userHandle);
     }
 
@@ -1421,6 +1420,7 @@
                         mContext.createContextAsUser(UserHandle.of(taskInfo.userId), 0 /* flags */),
                         mDisplayController,
                         mSplitScreenController,
+                        mDesktopRepository,
                         mTaskOrganizer,
                         taskInfo,
                         taskSurface,
@@ -1472,8 +1472,8 @@
             onToSplitScreen(taskInfo.taskId);
             return Unit.INSTANCE;
         });
-        windowDecoration.setOpenInBrowserClickListener((uri) -> {
-            onOpenInBrowser(taskInfo.taskId, uri);
+        windowDecoration.setOpenInBrowserClickListener((intent) -> {
+            onOpenInBrowser(taskInfo.taskId, intent);
         });
         windowDecoration.setOnNewWindowClickListener(() -> {
             onNewWindow(taskInfo.taskId);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java
index 99457d8..5daa3ee 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecoration.java
@@ -44,12 +44,14 @@
 import android.app.assist.AssistContent;
 import android.content.ComponentName;
 import android.content.Context;
+import android.content.Intent;
 import android.content.pm.ActivityInfo;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
+import android.graphics.Insets;
 import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
@@ -62,10 +64,12 @@
 import android.util.Size;
 import android.util.Slog;
 import android.view.Choreographer;
+import android.view.InsetsState;
 import android.view.MotionEvent;
 import android.view.SurfaceControl;
 import android.view.View;
 import android.view.ViewConfiguration;
+import android.view.WindowInsets;
 import android.view.WindowManager;
 import android.widget.ImageButton;
 import android.window.TaskSnapshot;
@@ -89,6 +93,7 @@
 import com.android.wm.shell.common.ShellExecutor;
 import com.android.wm.shell.common.SyncTransactionQueue;
 import com.android.wm.shell.desktopmode.CaptionState;
+import com.android.wm.shell.desktopmode.DesktopModeTaskRepository;
 import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository;
 import com.android.wm.shell.shared.annotations.ShellBackgroundThread;
 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus;
@@ -166,7 +171,7 @@
     private CapturedLink mCapturedLink;
     private Uri mGenericLink;
     private Uri mWebUri;
-    private Consumer<Uri> mOpenInBrowserClickListener;
+    private Consumer<Intent> mOpenInBrowserClickListener;
 
     private ExclusionRegionListener mExclusionRegionListener;
 
@@ -188,12 +193,14 @@
     private final Runnable mCapturedLinkExpiredRunnable = this::onCapturedLinkExpired;
     private final MultiInstanceHelper mMultiInstanceHelper;
     private final WindowDecorCaptionHandleRepository mWindowDecorCaptionHandleRepository;
+    private final DesktopModeTaskRepository mDesktopRepository;
 
     DesktopModeWindowDecoration(
             Context context,
             @NonNull Context userContext,
             DisplayController displayController,
             SplitScreenController splitScreenController,
+            DesktopModeTaskRepository desktopRepository,
             ShellTaskOrganizer taskOrganizer,
             ActivityManager.RunningTaskInfo taskInfo,
             SurfaceControl taskSurface,
@@ -207,8 +214,8 @@
             AssistContentRequester assistContentRequester,
             MultiInstanceHelper multiInstanceHelper,
             WindowDecorCaptionHandleRepository windowDecorCaptionHandleRepository) {
-        this (context, userContext, displayController, splitScreenController, taskOrganizer,
-                taskInfo, taskSurface, handler, bgExecutor, choreographer, syncQueue,
+        this (context, userContext, displayController, splitScreenController, desktopRepository,
+                taskOrganizer, taskInfo, taskSurface, handler, bgExecutor, choreographer, syncQueue,
                 appHeaderViewHolderFactory, rootTaskDisplayAreaOrganizer, genericLinksParser,
                 assistContentRequester,
                 SurfaceControl.Builder::new, SurfaceControl.Transaction::new,
@@ -225,6 +232,7 @@
             @NonNull Context userContext,
             DisplayController displayController,
             SplitScreenController splitScreenController,
+            DesktopModeTaskRepository desktopRepository,
             ShellTaskOrganizer taskOrganizer,
             ActivityManager.RunningTaskInfo taskInfo,
             SurfaceControl taskSurface,
@@ -264,6 +272,7 @@
         mMultiInstanceHelper = multiInstanceHelper;
         mWindowManagerWrapper = windowManagerWrapper;
         mWindowDecorCaptionHandleRepository = windowDecorCaptionHandleRepository;
+        mDesktopRepository = desktopRepository;
     }
 
     /**
@@ -335,7 +344,7 @@
         mDragPositioningCallback = dragPositioningCallback;
     }
 
-    void setOpenInBrowserClickListener(Consumer<Uri> listener) {
+    void setOpenInBrowserClickListener(Consumer<Intent> listener) {
         mOpenInBrowserClickListener = listener;
     }
 
@@ -439,8 +448,11 @@
             mHandleMenu.relayout(startT, mResult.mCaptionX);
         }
 
+        final boolean inFullImmersive = mDesktopRepository
+                .isTaskInFullImmersiveState(taskInfo.taskId);
         updateRelayoutParams(mRelayoutParams, mContext, taskInfo, applyStartTransactionOnDraw,
-                shouldSetTaskPositionAndCrop);
+                shouldSetTaskPositionAndCrop, mIsStatusBarVisible, mIsKeyguardVisibleAndOccluded,
+                inFullImmersive, mDisplayController.getInsetsState(taskInfo.displayId));
 
         final WindowDecorLinearLayout oldRootView = mResult.mRootView;
         final SurfaceControl oldDecorationSurface = mDecorationContainerSurface;
@@ -480,11 +492,17 @@
         if (canEnterDesktopMode(mContext) && Flags.enableDesktopWindowingAppHandleEducation()) {
             notifyCaptionStateChanged();
         }
-        mWindowDecorViewHolder.bindData(mTaskInfo,
-                position,
-                mResult.mCaptionWidth,
-                mResult.mCaptionHeight,
-                isCaptionVisible());
+
+        if (isAppHandle(mWindowDecorViewHolder)) {
+            mWindowDecorViewHolder.bindData(new AppHandleViewHolder.HandleData(
+                    mTaskInfo, position, mResult.mCaptionWidth, mResult.mCaptionHeight,
+                    isCaptionVisible()
+            ));
+        } else {
+            mWindowDecorViewHolder.bindData(new AppHeaderViewHolder.HeaderData(
+                    mTaskInfo, TaskInfoKt.getRequestingImmersive(mTaskInfo), inFullImmersive
+            ));
+        }
         Trace.endSection();
 
         if (!mTaskInfo.isFocused) {
@@ -518,21 +536,28 @@
     }
 
     @Nullable
-    private Uri getBrowserLink() {
+    private Intent getBrowserLink() {
         // Do not show browser link in browser applications
         final ComponentName baseActivity = mTaskInfo.baseActivity;
         if (baseActivity != null && AppToWebUtils.isBrowserApp(mContext,
                 baseActivity.getPackageName(), mUserContext.getUserId())) {
             return null;
         }
+
+        final Uri browserLink;
         // If the captured link is available and has not expired, return the captured link.
         // Otherwise, return the generic link which is set to null if a generic link is unavailable.
         if (mCapturedLink != null && !mCapturedLink.mExpired) {
-            return mCapturedLink.mUri;
+            browserLink = mCapturedLink.mUri;
         } else if (mWebUri != null) {
-            return mWebUri;
+            browserLink = mWebUri;
+        } else {
+            browserLink = mGenericLink;
         }
-        return mGenericLink;
+
+        if (browserLink == null) return null;
+        return AppToWebUtils.getBrowserIntent(browserLink, mContext.getPackageManager());
+
     }
 
     UserHandle getUser() {
@@ -738,7 +763,11 @@
             Context context,
             ActivityManager.RunningTaskInfo taskInfo,
             boolean applyStartTransactionOnDraw,
-            boolean shouldSetTaskPositionAndCrop) {
+            boolean shouldSetTaskPositionAndCrop,
+            boolean isStatusBarVisible,
+            boolean isKeyguardVisibleAndOccluded,
+            boolean inFullImmersiveMode,
+            @NonNull InsetsState displayInsetsState) {
         final int captionLayoutId = getDesktopModeWindowDecorLayoutId(taskInfo.getWindowingMode());
         final boolean isAppHeader =
                 captionLayoutId == R.layout.desktop_mode_app_header;
@@ -749,6 +778,28 @@
         relayoutParams.mCaptionHeightId = getCaptionHeightIdStatic(taskInfo.getWindowingMode());
         relayoutParams.mCaptionWidthId = getCaptionWidthId(relayoutParams.mLayoutResId);
 
+        final boolean showCaption;
+        if (Flags.enableFullyImmersiveInDesktop()) {
+            if (inFullImmersiveMode) {
+                showCaption = isStatusBarVisible && !isKeyguardVisibleAndOccluded;
+            } else {
+                showCaption = taskInfo.isFreeform()
+                        || (isStatusBarVisible && !isKeyguardVisibleAndOccluded);
+            }
+        } else {
+            // Caption should always be visible in freeform mode. When not in freeform,
+            // align with the status bar except when showing over keyguard (where it should not
+            // shown).
+            //  TODO(b/356405803): Investigate how it's possible for the status bar visibility to
+            //   be false while a freeform window is open if the status bar is always
+            //   forcibly-shown. It may be that the InsetsState (from which |mIsStatusBarVisible|
+            //   is set) still contains an invisible insets source in immersive cases even if the
+            //   status bar is shown?
+            showCaption = taskInfo.isFreeform()
+                    || (isStatusBarVisible && !isKeyguardVisibleAndOccluded);
+        }
+        relayoutParams.mIsCaptionVisible = showCaption;
+
         if (isAppHeader) {
             if (TaskInfoKt.isTransparentCaptionBarAppearance(taskInfo)) {
                 // If the app is requesting to customize the caption bar, allow input to fall
@@ -767,6 +818,13 @@
                 // including non-immersive apps that just don't handle caption insets properly.
                 relayoutParams.mInsetSourceFlags |= FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR;
             }
+            if (Flags.enableFullyImmersiveInDesktop() && inFullImmersiveMode) {
+                final Insets systemBarInsets = displayInsetsState.calculateInsets(
+                        taskInfo.getConfiguration().windowConfiguration.getBounds(),
+                        WindowInsets.Type.systemBars() & ~WindowInsets.Type.captionBar(),
+                        false /* ignoreVisibility */);
+                relayoutParams.mCaptionTopPadding = systemBarInsets.top;
+            }
             // Report occluding elements as bounding rects to the insets system so that apps can
             // draw in the empty space in the center:
             //   First, the "app chip" section of the caption bar (+ some extra margins).
@@ -1049,7 +1107,7 @@
     }
 
     /**
-     * Determine the highest y coordinate of a freeform task. Used for restricting drag inputs.
+     * Determine the highest y coordinate of a freeform task. Used for restricting drag inputs.fmdra
      */
     private int determineMaxY(int requiredEmptySpace, Rect stableBounds) {
         return stableBounds.bottom - requiredEmptySpace;
@@ -1172,8 +1230,8 @@
                 /* onToSplitScreenClickListener= */ mOnToSplitscreenClickListener,
                 /* onNewWindowClickListener= */ mOnNewWindowClickListener,
                 /* onManageWindowsClickListener= */ mOnManageWindowsClickListener,
-                /* openInBrowserClickListener= */ (uri) -> {
-                    mOpenInBrowserClickListener.accept(uri);
+                /* openInBrowserClickListener= */ (intent) -> {
+                    mOpenInBrowserClickListener.accept(intent);
                     onCapturedLinkExpired();
                     return Unit.INSTANCE;
                 },
@@ -1532,6 +1590,7 @@
                 @NonNull Context userContext,
                 DisplayController displayController,
                 SplitScreenController splitScreenController,
+                DesktopModeTaskRepository desktopRepository,
                 ShellTaskOrganizer taskOrganizer,
                 ActivityManager.RunningTaskInfo taskInfo,
                 SurfaceControl taskSurface,
@@ -1550,6 +1609,7 @@
                     userContext,
                     displayController,
                     splitScreenController,
+                    desktopRepository,
                     taskOrganizer,
                     taskInfo,
                     taskSurface,
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt
index 9a5b4f5..98fef47 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/HandleMenu.kt
@@ -20,13 +20,13 @@
 import android.annotation.SuppressLint
 import android.app.ActivityManager.RunningTaskInfo
 import android.content.Context
+import android.content.Intent
 import android.content.res.ColorStateList
 import android.content.res.Resources
 import android.graphics.Bitmap
 import android.graphics.Point
 import android.graphics.PointF
 import android.graphics.Rect
-import android.net.Uri
 import android.view.LayoutInflater
 import android.view.MotionEvent
 import android.view.MotionEvent.ACTION_OUTSIDE
@@ -70,7 +70,7 @@
     private val shouldShowWindowingPill: Boolean,
     private val shouldShowNewWindowButton: Boolean,
     private val shouldShowManageWindowsButton: Boolean,
-    private val openInBrowserLink: Uri?,
+    private val openInBrowserIntent: Intent?,
     private val captionWidth: Int,
     private val captionHeight: Int,
     captionX: Int
@@ -107,7 +107,7 @@
     private val globalMenuPosition: Point = Point()
 
     private val shouldShowBrowserPill: Boolean
-        get() = openInBrowserLink != null
+        get() = openInBrowserIntent != null
 
     init {
         updateHandleMenuPillPositions(captionX)
@@ -119,7 +119,7 @@
         onToSplitScreenClickListener: () -> Unit,
         onNewWindowClickListener: () -> Unit,
         onManageWindowsClickListener: () -> Unit,
-        openInBrowserClickListener: (Uri) -> Unit,
+        openInBrowserClickListener: (Intent) -> Unit,
         onCloseMenuClickListener: () -> Unit,
         onOutsideTouchListener: () -> Unit,
     ) {
@@ -152,7 +152,7 @@
         onToSplitScreenClickListener: () -> Unit,
         onNewWindowClickListener: () -> Unit,
         onManageWindowsClickListener: () -> Unit,
-        openInBrowserClickListener: (Uri) -> Unit,
+        openInBrowserClickListener: (Intent) -> Unit,
         onCloseMenuClickListener: () -> Unit,
         onOutsideTouchListener: () -> Unit
     ) {
@@ -172,7 +172,7 @@
             this.onNewWindowClickListener = onNewWindowClickListener
             this.onManageWindowsClickListener = onManageWindowsClickListener
             this.onOpenInBrowserClickListener = {
-                openInBrowserClickListener.invoke(openInBrowserLink!!)
+                openInBrowserClickListener.invoke(openInBrowserIntent!!)
             }
             this.onCloseMenuClickListener = onCloseMenuClickListener
             this.onOutsideTouchListener = onOutsideTouchListener
@@ -661,7 +661,7 @@
         shouldShowWindowingPill: Boolean,
         shouldShowNewWindowButton: Boolean,
         shouldShowManageWindowsButton: Boolean,
-        openInBrowserLink: Uri?,
+        openInBrowserIntent: Intent?,
         captionWidth: Int,
         captionHeight: Int,
         captionX: Int
@@ -680,7 +680,7 @@
         shouldShowWindowingPill: Boolean,
         shouldShowNewWindowButton: Boolean,
         shouldShowManageWindowsButton: Boolean,
-        openInBrowserLink: Uri?,
+        openInBrowserIntent: Intent?,
         captionWidth: Int,
         captionHeight: Int,
         captionX: Int
@@ -695,7 +695,7 @@
             shouldShowWindowingPill,
             shouldShowNewWindowButton,
             shouldShowManageWindowsButton,
-            openInBrowserLink,
+            openInBrowserIntent,
             captionWidth,
             captionHeight,
             captionX
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java
index c1a55b4..000beba1 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/WindowDecoration.java
@@ -144,8 +144,8 @@
     TaskDragResizer mTaskDragResizer;
     boolean mIsCaptionVisible;
 
-    private boolean mIsStatusBarVisible;
-    private boolean mIsKeyguardVisibleAndOccluded;
+    boolean mIsStatusBarVisible;
+    boolean mIsKeyguardVisibleAndOccluded;
 
     /** The most recent set of insets applied to this window decoration. */
     private WindowDecorationInsets mWindowDecorationInsets;
@@ -241,7 +241,7 @@
         }
         rootView = null; // Clear it just in case we use it accidentally
 
-        updateCaptionVisibility(outResult.mRootView);
+        updateCaptionVisibility(outResult.mRootView, params);
 
         final Rect taskBounds = mTaskInfo.getConfiguration().windowConfiguration.getBounds();
         outResult.mWidth = taskBounds.width();
@@ -527,17 +527,10 @@
     }
 
     /**
-     * Checks if task has entered/exited immersive mode and requires a change in caption visibility.
+     * Update caption visibility state and views.
      */
-    private void updateCaptionVisibility(View rootView) {
-        // Caption should always be visible in freeform mode. When not in freeform, align with the
-        // status bar except when showing over keyguard (where it should not shown).
-        //  TODO(b/356405803): Investigate how it's possible for the status bar visibility to be
-        //   false while a freeform window is open if the status bar is always forcibly-shown. It
-        //   may be that the InsetsState (from which |mIsStatusBarVisible| is set) still contains
-        //   an invisible insets source in immersive cases even if the status bar is shown?
-        mIsCaptionVisible = mTaskInfo.isFreeform()
-                || (mIsStatusBarVisible && !mIsKeyguardVisibleAndOccluded);
+    private void updateCaptionVisibility(View rootView, @NonNull RelayoutParams params) {
+        mIsCaptionVisible = params.mIsCaptionVisible;
         setCaptionVisibility(rootView, mIsCaptionVisible);
     }
 
@@ -737,6 +730,7 @@
         int mCornerRadius;
 
         int mCaptionTopPadding;
+        boolean mIsCaptionVisible;
 
         Configuration mWindowDecorConfig;
 
@@ -755,6 +749,7 @@
             mCornerRadius = 0;
 
             mCaptionTopPadding = 0;
+            mIsCaptionVisible = false;
 
             mApplyStartTransactionOnDraw = false;
             mSetTaskPositionAndCrop = false;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt
index 98413ee..a9a16bc 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipController.kt
@@ -30,9 +30,13 @@
 import android.widget.ImageView
 import android.widget.LinearLayout
 import android.widget.TextView
+import android.window.DisplayAreaInfo
+import android.window.WindowContainerTransaction
 import androidx.dynamicanimation.animation.DynamicAnimation
 import androidx.dynamicanimation.animation.SpringForce
 import com.android.wm.shell.R
+import com.android.wm.shell.common.DisplayChangeController.OnDisplayChangingListener
+import com.android.wm.shell.common.DisplayController
 import com.android.wm.shell.shared.animation.PhysicsAnimator
 import com.android.wm.shell.windowdecor.WindowManagerWrapper
 import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer
@@ -44,7 +48,8 @@
 class DesktopWindowingEducationTooltipController(
     private val context: Context,
     private val additionalSystemViewContainerFactory: AdditionalSystemViewContainer.Factory,
-) {
+    private val displayController: DisplayController,
+) : OnDisplayChangingListener {
   // TODO: b/369384567 - Set tooltip color scheme to match LT/DT of app theme
   private var tooltipView: View? = null
   private var animator: PhysicsAnimator<View>? = null
@@ -53,6 +58,20 @@
   }
   private var popupWindow: AdditionalSystemViewContainer? = null
 
+  override fun onDisplayChange(
+      displayId: Int,
+      fromRotation: Int,
+      toRotation: Int,
+      newDisplayAreaInfo: DisplayAreaInfo?,
+      t: WindowContainerTransaction?
+  ) {
+    // Exit if the rotation hasn't changed or is changed by 180 degrees. [fromRotation] and
+    // [toRotation] can be one of the [@Surface.Rotation] values.
+    if ((fromRotation % 2 == toRotation % 2)) return
+    hideEducationTooltip()
+    // TODO: b/370820018 - Update tooltip position on orientation change instead of dismissing
+  }
+
   /**
    * Shows education tooltip.
    *
@@ -64,6 +83,7 @@
     tooltipView = createEducationTooltipView(tooltipViewConfig, taskId)
     animator = createAnimator()
     animateShowTooltipTransition()
+    displayController.addDisplayChangingController(this)
   }
 
   /** Hide the current education view if visible */
@@ -145,6 +165,7 @@
     animator = null
     popupWindow?.releaseView()
     popupWindow = null
+    displayController.removeDisplayChangingController(this)
   }
 
   private fun createTooltipPopupWindow(
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt
index 8c102eb..b5700ff 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHandleViewHolder.kt
@@ -42,6 +42,7 @@
 import com.android.wm.shell.shared.animation.Interpolators
 import com.android.wm.shell.windowdecor.WindowManagerWrapper
 import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer
+import com.android.wm.shell.windowdecor.viewholder.WindowDecorationViewHolder.Data
 
 /**
  * A desktop mode window decoration used when the window is in full "focus" (i.e. fullscreen/split).
@@ -53,11 +54,20 @@
     onCaptionButtonClickListener: OnClickListener,
     private val windowManagerWrapper: WindowManagerWrapper,
     private val handler: Handler
-) : WindowDecorationViewHolder(rootView) {
+) : WindowDecorationViewHolder<AppHandleViewHolder.HandleData>(rootView) {
 
     companion object {
         private const val CAPTION_HANDLE_ANIMATION_DURATION: Long = 100
     }
+
+    data class HandleData(
+        val taskInfo: RunningTaskInfo,
+        val position: Point,
+        val width: Int,
+        val height: Int,
+        val isCaptionVisible: Boolean
+    ) : Data()
+
     private lateinit var taskInfo: RunningTaskInfo
     private val captionView: View = rootView.requireViewById(R.id.desktop_mode_caption)
     private val captionHandle: ImageButton = rootView.requireViewById(R.id.caption_handle)
@@ -89,7 +99,11 @@
         }
     }
 
-    override fun bindData(
+    override fun bindData(data: HandleData) {
+        bindData(data.taskInfo, data.position, data.width, data.height, data.isCaptionVisible)
+    }
+
+    private fun bindData(
         taskInfo: RunningTaskInfo,
         position: Point,
         width: Int,
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt
index 306103c..52bf400 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/AppHeaderViewHolder.kt
@@ -16,12 +16,12 @@
 package com.android.wm.shell.windowdecor.viewholder
 
 import android.annotation.ColorInt
+import android.annotation.DrawableRes
 import android.app.ActivityManager.RunningTaskInfo
 import android.content.res.ColorStateList
 import android.content.res.Configuration
 import android.graphics.Bitmap
 import android.graphics.Color
-import android.graphics.Point
 import android.graphics.Rect
 import android.graphics.drawable.LayerDrawable
 import android.graphics.drawable.RippleDrawable
@@ -60,7 +60,6 @@
 import com.android.wm.shell.windowdecor.common.Theme
 import com.android.wm.shell.windowdecor.extension.isLightCaptionBarAppearance
 import com.android.wm.shell.windowdecor.extension.isTransparentCaptionBarAppearance
-import com.android.wm.shell.windowdecor.extension.requestingImmersive
 
 /**
  * A desktop mode window decoration used when the window is floating (i.e. freeform). It hosts
@@ -76,7 +75,13 @@
         appName: CharSequence,
         appIconBitmap: Bitmap,
         onMaximizeHoverAnimationFinishedListener: () -> Unit
-) : WindowDecorationViewHolder(rootView) {
+) : WindowDecorationViewHolder<AppHeaderViewHolder.HeaderData>(rootView) {
+
+    data class HeaderData(
+        val taskInfo: RunningTaskInfo,
+        val isRequestingImmersive: Boolean,
+        val inFullImmersiveState: Boolean,
+    ) : Data()
 
     private val decorThemeUtil = DecorThemeUtil(context)
     private val lightColors = dynamicLightColorScheme(context)
@@ -153,15 +158,17 @@
                 onMaximizeHoverAnimationFinishedListener
     }
 
-    override fun bindData(
+    override fun bindData(data: HeaderData) {
+        bindData(data.taskInfo, data.isRequestingImmersive, data.inFullImmersiveState)
+    }
+
+    private fun bindData(
         taskInfo: RunningTaskInfo,
-        position: Point,
-        width: Int,
-        height: Int,
-        isCaptionVisible: Boolean
+        isRequestingImmersive: Boolean,
+        inFullImmersiveState: Boolean,
     ) {
         if (DesktopModeFlags.ENABLE_THEMED_APP_HEADERS.isTrue()) {
-            bindDataWithThemedHeaders(taskInfo)
+            bindDataWithThemedHeaders(taskInfo, isRequestingImmersive, inFullImmersiveState)
         } else {
             bindDataLegacy(taskInfo)
         }
@@ -200,7 +207,11 @@
         minimizeWindowButton.isGone = !enableMinimizeButton()
     }
 
-    private fun bindDataWithThemedHeaders(taskInfo: RunningTaskInfo) {
+    private fun bindDataWithThemedHeaders(
+        taskInfo: RunningTaskInfo,
+        requestingImmersive: Boolean,
+        inFullImmersiveState: Boolean
+    ) {
         val header = fillHeaderInfo(taskInfo)
         val headerStyle = getHeaderStyle(header)
 
@@ -254,13 +265,7 @@
                     drawableInsets = maximizeDrawableInsets
                 )
             )
-            setIcon(
-                if (taskInfo.requestingImmersive && Flags.enableFullyImmersiveInDesktop()) {
-                    R.drawable.decor_desktop_mode_immersive_button_dark
-                } else {
-                    R.drawable.decor_desktop_mode_maximize_button_dark
-                }
-            )
+            setIcon(getMaximizeButtonIcon(requestingImmersive, inFullImmersiveState))
         }
         // Close button.
         closeWindowButton.apply {
@@ -331,6 +336,32 @@
         }
     }
 
+    @DrawableRes
+    private fun getMaximizeButtonIcon(
+        requestingImmersive: Boolean,
+        inFullImmersiveState: Boolean
+    ): Int = when {
+        shouldShowEnterFullImmersiveIcon(requestingImmersive, inFullImmersiveState) -> {
+            R.drawable.decor_desktop_mode_immersive_button_dark
+        }
+        shouldShowExitFullImmersiveIcon(requestingImmersive, inFullImmersiveState) -> {
+            R.drawable.decor_desktop_mode_immersive_exit_button_dark
+        }
+        else -> R.drawable.decor_desktop_mode_maximize_button_dark
+    }
+
+    private fun shouldShowEnterFullImmersiveIcon(
+        requestingImmersive: Boolean,
+        inFullImmersiveState: Boolean
+    ): Boolean = Flags.enableFullyImmersiveInDesktop()
+            && requestingImmersive && !inFullImmersiveState
+
+    private fun shouldShowExitFullImmersiveIcon(
+        requestingImmersive: Boolean,
+        inFullImmersiveState: Boolean
+    ): Boolean = Flags.enableFullyImmersiveInDesktop()
+            && requestingImmersive && inFullImmersiveState
+
     private fun getHeaderStyle(header: Header): HeaderStyle {
         return HeaderStyle(
             background = getHeaderBackground(header),
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt
index 5ea55b3..1fe743d 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/windowdecor/viewholder/WindowDecorationViewHolder.kt
@@ -17,31 +17,28 @@
 
 import android.app.ActivityManager.RunningTaskInfo
 import android.content.Context
-import android.graphics.Point
 import android.view.View
+import com.android.wm.shell.windowdecor.viewholder.WindowDecorationViewHolder.Data
 
 /**
  * Encapsulates the root [View] of a window decoration and its children to facilitate looking up
  * children (via findViewById) and updating to the latest data from [RunningTaskInfo].
  */
-abstract class WindowDecorationViewHolder(rootView: View) {
+abstract class WindowDecorationViewHolder<T : Data>(rootView: View) {
   val context: Context = rootView.context
 
   /**
    * A signal to the view holder that new data is available and that the views should be updated to
    * reflect it.
    */
-  abstract fun bindData(
-    taskInfo: RunningTaskInfo,
-    position: Point,
-    width: Int,
-    height: Int,
-    isCaptionVisible: Boolean
-  )
+  abstract fun bindData(data: T)
 
   /** Callback when the handle menu is opened. */
   abstract fun onHandleMenuOpened()
 
   /** Callback when the handle menu is closed. */
   abstract fun onHandleMenuClosed()
+
+  /** Data clas that contains the information needed to update the view holder. */
+  abstract class Data
 }
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/CloseDesktopTaskTransitionHandlerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/CloseDesktopTaskTransitionHandlerTest.kt
new file mode 100644
index 0000000..9b4cc17
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/CloseDesktopTaskTransitionHandlerTest.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.wm.shell.desktopmode
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.app.WindowConfiguration.ACTIVITY_TYPE_STANDARD
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.app.WindowConfiguration.WindowingMode
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper.RunWithLooper
+import android.view.SurfaceControl
+import android.view.WindowManager
+import android.window.TransitionInfo
+import androidx.test.filters.SmallTest
+import com.android.wm.shell.ShellTestCase
+import com.android.wm.shell.TestRunningTaskInfoBuilder
+import com.android.wm.shell.common.ShellExecutor
+import java.util.function.Supplier
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.kotlin.mock
+
+/**
+ * Test class for [CloseDesktopTaskTransitionHandler]
+ *
+ * Usage: atest WMShellUnitTests:CloseDesktopTaskTransitionHandlerTest
+ */
+@SmallTest
+@RunWithLooper
+@RunWith(AndroidTestingRunner::class)
+class CloseDesktopTaskTransitionHandlerTest : ShellTestCase() {
+
+    @Mock lateinit var testExecutor: ShellExecutor
+    @Mock lateinit var closingTaskLeash: SurfaceControl
+
+    private val transactionSupplier = Supplier { mock<SurfaceControl.Transaction>() }
+
+    private lateinit var handler: CloseDesktopTaskTransitionHandler
+
+    @Before
+    fun setUp() {
+        handler =
+            CloseDesktopTaskTransitionHandler(
+                context,
+                testExecutor,
+                testExecutor,
+                transactionSupplier
+            )
+    }
+
+    @Test
+    fun handleRequest_returnsNull() {
+        assertNull(handler.handleRequest(mock(), mock()))
+    }
+
+    @Test
+    fun startAnimation_openTransition_returnsFalse() {
+        val animates =
+            handler.startAnimation(
+                transition = mock(),
+                info =
+                    createTransitionInfo(
+                        type = WindowManager.TRANSIT_OPEN,
+                        task = createTask(WINDOWING_MODE_FREEFORM)
+                    ),
+                startTransaction = mock(),
+                finishTransaction = mock(),
+                finishCallback = {}
+            )
+
+        assertFalse("Should not animate open transition", animates)
+    }
+
+    @Test
+    fun startAnimation_closeTransitionFullscreenTask_returnsFalse() {
+        val animates =
+            handler.startAnimation(
+                transition = mock(),
+                info = createTransitionInfo(task = createTask(WINDOWING_MODE_FULLSCREEN)),
+                startTransaction = mock(),
+                finishTransaction = mock(),
+                finishCallback = {}
+            )
+
+        assertFalse("Should not animate fullscreen task close transition", animates)
+    }
+
+    @Test
+    fun startAnimation_closeTransitionOpeningFreeformTask_returnsFalse() {
+        val animates =
+            handler.startAnimation(
+                transition = mock(),
+                info =
+                    createTransitionInfo(
+                        changeMode = WindowManager.TRANSIT_OPEN,
+                        task = createTask(WINDOWING_MODE_FREEFORM)
+                    ),
+                startTransaction = mock(),
+                finishTransaction = mock(),
+                finishCallback = {}
+            )
+
+        assertFalse("Should not animate opening freeform task close transition", animates)
+    }
+
+    @Test
+    fun startAnimation_closeTransitionClosingFreeformTask_returnsTrue() {
+        val animates =
+            handler.startAnimation(
+                transition = mock(),
+                info = createTransitionInfo(task = createTask(WINDOWING_MODE_FREEFORM)),
+                startTransaction = mock(),
+                finishTransaction = mock(),
+                finishCallback = {}
+            )
+
+        assertTrue("Should animate closing freeform task close transition", animates)
+    }
+
+    private fun createTransitionInfo(
+        type: Int = WindowManager.TRANSIT_CLOSE,
+        changeMode: Int = WindowManager.TRANSIT_CLOSE,
+        task: RunningTaskInfo
+    ): TransitionInfo =
+        TransitionInfo(type, 0 /* flags */).apply {
+            addChange(
+                TransitionInfo.Change(mock(), closingTaskLeash).apply {
+                    mode = changeMode
+                    parent = null
+                    taskInfo = task
+                }
+            )
+        }
+
+    private fun createTask(@WindowingMode windowingMode: Int): RunningTaskInfo =
+        TestRunningTaskInfoBuilder()
+            .setActivityType(ACTIVITY_TYPE_STANDARD)
+            .setWindowingMode(windowingMode)
+            .build()
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt
new file mode 100644
index 0000000..2b60200
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopMixedTransitionHandlerTest.kt
@@ -0,0 +1,220 @@
+/*
+ * 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.wm.shell.desktopmode
+
+import android.app.ActivityManager.RunningTaskInfo
+import android.app.WindowConfiguration.ACTIVITY_TYPE_STANDARD
+import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
+import android.app.WindowConfiguration.WindowingMode
+import android.os.Handler
+import android.os.IBinder
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper.RunWithLooper
+import android.view.SurfaceControl
+import android.view.WindowManager
+import android.window.TransitionInfo
+import android.window.WindowContainerTransaction
+import androidx.test.filters.SmallTest
+import com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.wm.shell.ShellTestCase
+import com.android.wm.shell.TestRunningTaskInfoBuilder
+import com.android.wm.shell.freeform.FreeformTaskTransitionHandler
+import com.android.wm.shell.transition.Transitions
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+/**
+ * Test class for [DesktopMixedTransitionHandler]
+ *
+ * Usage: atest WMShellUnitTests:DesktopMixedTransitionHandlerTest
+ */
+@SmallTest
+@RunWithLooper
+@RunWith(AndroidTestingRunner::class)
+class DesktopMixedTransitionHandlerTest : ShellTestCase() {
+
+    @Mock lateinit var transitions: Transitions
+    @Mock lateinit var desktopTaskRepository: DesktopModeTaskRepository
+    @Mock lateinit var freeformTaskTransitionHandler: FreeformTaskTransitionHandler
+    @Mock lateinit var closeDesktopTaskTransitionHandler: CloseDesktopTaskTransitionHandler
+    @Mock lateinit var interactionJankMonitor: InteractionJankMonitor
+    @Mock lateinit var mockHandler: Handler
+    @Mock lateinit var closingTaskLeash: SurfaceControl
+
+    private lateinit var mixedHandler: DesktopMixedTransitionHandler
+
+    @Before
+    fun setUp() {
+        mixedHandler =
+            DesktopMixedTransitionHandler(
+                context,
+                transitions,
+                desktopTaskRepository,
+                freeformTaskTransitionHandler,
+                closeDesktopTaskTransitionHandler,
+                interactionJankMonitor,
+                mockHandler
+            )
+    }
+
+    @Test
+    fun startWindowingModeTransition_callsFreeformTaskTransitionHandler() {
+        val windowingMode = WINDOWING_MODE_FULLSCREEN
+        val wct = WindowContainerTransaction()
+
+        mixedHandler.startWindowingModeTransition(windowingMode, wct)
+
+        verify(freeformTaskTransitionHandler).startWindowingModeTransition(windowingMode, wct)
+    }
+
+    @Test
+    fun startMinimizedModeTransition_callsFreeformTaskTransitionHandler() {
+        val wct = WindowContainerTransaction()
+        whenever(freeformTaskTransitionHandler.startMinimizedModeTransition(any()))
+            .thenReturn(mock())
+
+        mixedHandler.startMinimizedModeTransition(wct)
+
+        verify(freeformTaskTransitionHandler).startMinimizedModeTransition(wct)
+    }
+
+    @Test
+    fun startRemoveTransition_startsCloseTransition() {
+        val wct = WindowContainerTransaction()
+
+        mixedHandler.startRemoveTransition(wct)
+
+        verify(transitions).startTransition(WindowManager.TRANSIT_CLOSE, wct, mixedHandler)
+    }
+
+    @Test
+    fun handleRequest_returnsNull() {
+        assertNull(mixedHandler.handleRequest(mock(), mock()))
+    }
+
+    @Test
+    fun startAnimation_withoutClosingDesktopTask_returnsFalse() {
+        val transition = mock<IBinder>()
+        val transitionInfo =
+            createTransitionInfo(
+                changeMode = WindowManager.TRANSIT_OPEN,
+                task = createTask(WINDOWING_MODE_FREEFORM)
+            )
+        whenever(freeformTaskTransitionHandler.startAnimation(any(), any(), any(), any(), any()))
+            .thenReturn(true)
+
+        val started = mixedHandler.startAnimation(
+            transition = transition,
+            info = transitionInfo,
+            startTransaction = mock(),
+            finishTransaction = mock(),
+            finishCallback = {}
+        )
+
+        assertFalse("Should not start animation without closing desktop task", started)
+    }
+
+    @Test
+    fun startAnimation_withClosingDesktopTask_callsCloseTaskHandler() {
+        val transition = mock<IBinder>()
+        val transitionInfo = createTransitionInfo(task = createTask(WINDOWING_MODE_FREEFORM))
+        whenever(desktopTaskRepository.getActiveNonMinimizedTaskCount(any())).thenReturn(2)
+        whenever(
+                closeDesktopTaskTransitionHandler.startAnimation(any(), any(), any(), any(), any())
+            )
+            .thenReturn(true)
+
+        val started = mixedHandler.startAnimation(
+            transition = transition,
+            info = transitionInfo,
+            startTransaction = mock(),
+            finishTransaction = mock(),
+            finishCallback = {}
+        )
+
+        assertTrue("Should delegate animation to close transition handler", started)
+        verify(closeDesktopTaskTransitionHandler)
+            .startAnimation(eq(transition), eq(transitionInfo), any(), any(), any())
+    }
+
+    @Test
+    fun startAnimation_withClosingLastDesktopTask_dispatchesTransition() {
+        val transition = mock<IBinder>()
+        val transitionInfo = createTransitionInfo(task = createTask(WINDOWING_MODE_FREEFORM))
+        whenever(desktopTaskRepository.getActiveNonMinimizedTaskCount(any())).thenReturn(1)
+        whenever(transitions.dispatchTransition(any(), any(), any(), any(), any(), any()))
+            .thenReturn(mock())
+
+        mixedHandler.startAnimation(
+            transition = transition,
+            info = transitionInfo,
+            startTransaction = mock(),
+            finishTransaction = mock(),
+            finishCallback = {}
+        )
+
+        verify(transitions)
+            .dispatchTransition(
+                eq(transition),
+                eq(transitionInfo),
+                any(),
+                any(),
+                any(),
+                eq(mixedHandler)
+            )
+        verify(interactionJankMonitor)
+            .begin(
+                closingTaskLeash,
+                context,
+                mockHandler,
+                CUJ_DESKTOP_MODE_EXIT_MODE_ON_LAST_WINDOW_CLOSE
+            )
+    }
+
+    private fun createTransitionInfo(
+        type: Int = WindowManager.TRANSIT_CLOSE,
+        changeMode: Int = WindowManager.TRANSIT_CLOSE,
+        task: RunningTaskInfo
+    ): TransitionInfo =
+        TransitionInfo(type, 0 /* flags */).apply {
+            addChange(
+                TransitionInfo.Change(mock(), closingTaskLeash).apply {
+                    mode = changeMode
+                    parent = null
+                    taskInfo = task
+                }
+            )
+        }
+
+    private fun createTask(@WindowingMode windowingMode: Int): RunningTaskInfo =
+        TestRunningTaskInfoBuilder()
+            .setActivityType(ACTIVITY_TYPE_STANDARD)
+            .setWindowingMode(windowingMode)
+            .build()
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeEventLoggerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeEventLoggerTest.kt
index ca97229..6a5719b 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeEventLoggerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeEventLoggerTest.kt
@@ -19,6 +19,8 @@
 import com.android.dx.mockito.inline.extended.ExtendedMockito.verify
 import com.android.internal.util.FrameworkStatsLog
 import com.android.modules.utils.testing.ExtendedMockitoRule
+import com.android.wm.shell.EventLogTags
+import com.android.wm.shell.ShellTestCase
 import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.EnterReason
 import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.ExitReason
 import com.android.wm.shell.desktopmode.DesktopModeEventLogger.Companion.MinimizeReason
@@ -34,14 +36,15 @@
 /**
  * Tests for [DesktopModeEventLogger].
  */
-class DesktopModeEventLoggerTest {
+class DesktopModeEventLoggerTest : ShellTestCase() {
 
     private val desktopModeEventLogger = DesktopModeEventLogger()
 
     @JvmField
     @Rule
     val extendedMockitoRule = ExtendedMockitoRule.Builder(this)
-            .mockStatic(FrameworkStatsLog::class.java).build()!!
+            .mockStatic(FrameworkStatsLog::class.java)
+            .mockStatic(EventLogTags::class.java).build()!!
 
     @Test
     fun logSessionEnter_enterReason() = runBlocking {
@@ -60,6 +63,11 @@
                 eq(SESSION_ID)
             )
         }
+        verify {
+            EventLogTags.writeWmShellEnterDesktopMode(
+                eq(EnterReason.UNKNOWN_ENTER.reason),
+                eq(SESSION_ID))
+        }
     }
 
     @Test
@@ -79,6 +87,11 @@
                 eq(SESSION_ID)
             )
         }
+        verify {
+            EventLogTags.writeWmShellExitDesktopMode(
+                eq(ExitReason.UNKNOWN_EXIT.reason),
+                eq(SESSION_ID))
+        }
     }
 
     @Test
@@ -108,6 +121,22 @@
                 /* visible_task_count */
                 eq(TASK_COUNT))
         }
+
+        verify {
+            EventLogTags.writeWmShellDesktopModeTaskUpdate(
+                eq(FrameworkStatsLog
+                    .DESKTOP_MODE_SESSION_TASK_UPDATE__TASK_EVENT__TASK_ADDED),
+                eq(TASK_UPDATE.instanceId),
+                eq(TASK_UPDATE.uid),
+                eq(TASK_UPDATE.taskHeight),
+                eq(TASK_UPDATE.taskWidth),
+                eq(TASK_UPDATE.taskX),
+                eq(TASK_UPDATE.taskY),
+                eq(SESSION_ID),
+                eq(UNSET_MINIMIZE_REASON),
+                eq(UNSET_UNMINIMIZE_REASON),
+                eq(TASK_COUNT))
+        }
     }
 
     @Test
@@ -137,6 +166,22 @@
                 /* visible_task_count */
                 eq(TASK_COUNT))
         }
+
+        verify {
+            EventLogTags.writeWmShellDesktopModeTaskUpdate(
+                eq(FrameworkStatsLog
+                    .DESKTOP_MODE_SESSION_TASK_UPDATE__TASK_EVENT__TASK_REMOVED),
+                eq(TASK_UPDATE.instanceId),
+                eq(TASK_UPDATE.uid),
+                eq(TASK_UPDATE.taskHeight),
+                eq(TASK_UPDATE.taskWidth),
+                eq(TASK_UPDATE.taskX),
+                eq(TASK_UPDATE.taskY),
+                eq(SESSION_ID),
+                eq(UNSET_MINIMIZE_REASON),
+                eq(UNSET_UNMINIMIZE_REASON),
+                eq(TASK_COUNT))
+        }
     }
 
     @Test
@@ -167,6 +212,22 @@
                 /* visible_task_count */
                 eq(TASK_COUNT))
         }
+
+        verify {
+            EventLogTags.writeWmShellDesktopModeTaskUpdate(
+                eq(FrameworkStatsLog
+                    .DESKTOP_MODE_SESSION_TASK_UPDATE__TASK_EVENT__TASK_INFO_CHANGED),
+                eq(TASK_UPDATE.instanceId),
+                eq(TASK_UPDATE.uid),
+                eq(TASK_UPDATE.taskHeight),
+                eq(TASK_UPDATE.taskWidth),
+                eq(TASK_UPDATE.taskX),
+                eq(TASK_UPDATE.taskY),
+                eq(SESSION_ID),
+                eq(UNSET_MINIMIZE_REASON),
+                eq(UNSET_UNMINIMIZE_REASON),
+                eq(TASK_COUNT))
+        }
     }
 
     @Test
@@ -200,6 +261,22 @@
                 /* visible_task_count */
                 eq(TASK_COUNT))
         }
+
+        verify {
+            EventLogTags.writeWmShellDesktopModeTaskUpdate(
+                eq(FrameworkStatsLog
+                    .DESKTOP_MODE_SESSION_TASK_UPDATE__TASK_EVENT__TASK_INFO_CHANGED),
+                eq(TASK_UPDATE.instanceId),
+                eq(TASK_UPDATE.uid),
+                eq(TASK_UPDATE.taskHeight),
+                eq(TASK_UPDATE.taskWidth),
+                eq(TASK_UPDATE.taskX),
+                eq(TASK_UPDATE.taskY),
+                eq(SESSION_ID),
+                eq(MinimizeReason.TASK_LIMIT.reason),
+                eq(UNSET_UNMINIMIZE_REASON),
+                eq(TASK_COUNT))
+        }
     }
 
     @Test
@@ -233,6 +310,22 @@
                 /* visible_task_count */
                 eq(TASK_COUNT))
         }
+
+        verify {
+            EventLogTags.writeWmShellDesktopModeTaskUpdate(
+                eq(FrameworkStatsLog
+                    .DESKTOP_MODE_SESSION_TASK_UPDATE__TASK_EVENT__TASK_INFO_CHANGED),
+                eq(TASK_UPDATE.instanceId),
+                eq(TASK_UPDATE.uid),
+                eq(TASK_UPDATE.taskHeight),
+                eq(TASK_UPDATE.taskWidth),
+                eq(TASK_UPDATE.taskX),
+                eq(TASK_UPDATE.taskY),
+                eq(SESSION_ID),
+                eq(UNSET_MINIMIZE_REASON),
+                eq(UnminimizeReason.TASKBAR_TAP.reason),
+                eq(TASK_COUNT))
+        }
     }
 
     companion object {
@@ -256,4 +349,4 @@
         ) = TaskUpdate(TASK_ID, TASK_UID, TASK_HEIGHT, TASK_WIDTH, TASK_X, TASK_Y, minimizeReason,
             unminimizeReason, TASK_COUNT)
     }
-}
\ No newline at end of file
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
index bc40d89..97ceecc 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopModeTaskRepositoryTest.kt
@@ -820,6 +820,18 @@
     }
 
     @Test
+    fun minimizeTask_withInvalidDisplay_minimizesCorrectTask() {
+        repo.addActiveTask(displayId = DEFAULT_DISPLAY, taskId = 0)
+        repo.addOrMoveFreeformTaskToTop(displayId = DEFAULT_DISPLAY, taskId = 0)
+
+        repo.minimizeTask(displayId = INVALID_DISPLAY, taskId = 0)
+
+        assertThat(repo.isMinimizedTask(taskId = 0)).isTrue()
+        assertThat(repo.isMinimizedTask(taskId = 1)).isFalse()
+        assertThat(repo.isMinimizedTask(taskId = 2)).isFalse()
+    }
+
+    @Test
     fun unminimizeTask_unminimizesTask() {
         repo.minimizeTask(displayId = 0, taskId = 0)
 
@@ -882,6 +894,51 @@
         assertThat(tasks).containsExactly(1, 3).inOrder()
     }
 
+    @Test
+    fun setTaskInFullImmersiveState_savedAsInImmersiveState() {
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isFalse()
+
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true)
+
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue()
+    }
+
+    @Test
+    fun removeTaskInFullImmersiveState_removedAsInImmersiveState() {
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true)
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue()
+
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = false)
+
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isFalse()
+    }
+
+    @Test
+    fun removeTaskInFullImmersiveState_otherWasImmersive_otherRemainsImmersive() {
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true)
+
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 2, immersive = false)
+
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue()
+    }
+
+    @Test
+    fun setTaskInFullImmersiveState_sameDisplay_overridesExistingFullImmersiveTask() {
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true)
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 2, immersive = true)
+
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isFalse()
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 2)).isTrue()
+    }
+
+    @Test
+    fun setTaskInFullImmersiveState_differentDisplay_bothAreImmersive() {
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID, taskId = 1, immersive = true)
+        repo.setTaskInFullImmersiveState(DEFAULT_DESKTOP_ID + 1, taskId = 2, immersive = true)
+
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 1)).isTrue()
+        assertThat(repo.isTaskInFullImmersiveState(taskId = 2)).isTrue()
+    }
 
     class TestListener : DesktopModeTaskRepository.ActiveTasksListener {
         var activeChangesOnDefaultDisplay = 0
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
index 8870846..2ddb1ac 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
@@ -1350,6 +1350,32 @@
   }
 
   @Test
+  fun moveTaskToFront_backgroundTask_launchesTask() {
+    val task = createTaskInfo(1)
+    whenever(shellTaskOrganizer.getRunningTaskInfo(anyInt())).thenReturn(null)
+
+    controller.moveTaskToFront(task.taskId)
+
+    val wct = getLatestWct(type = TRANSIT_OPEN)
+    assertThat(wct.hierarchyOps).hasSize(1)
+    wct.assertLaunchTaskAt(0, task.taskId, WINDOWING_MODE_FREEFORM)
+  }
+
+  @Test
+  fun moveTaskToFront_backgroundTaskBringsTasksOverLimit_minimizesBackTask() {
+    val freeformTasks = (1..MAX_TASK_LIMIT).map { _ -> setUpFreeformTask() }
+    val task = createTaskInfo(1001)
+    whenever(shellTaskOrganizer.getRunningTaskInfo(task.taskId)).thenReturn(null)
+
+    controller.moveTaskToFront(task.taskId)
+
+    val wct = getLatestWct(type = TRANSIT_OPEN)
+    assertThat(wct.hierarchyOps.size).isEqualTo(2) // launch + minimize
+    wct.assertReorderAt(0, freeformTasks[0], toTop = false)
+    wct.assertLaunchTaskAt(1, task.taskId, WINDOWING_MODE_FREEFORM)
+  }
+
+  @Test
   fun moveToNextDisplay_noOtherDisplays() {
     whenever(rootTaskDisplayAreaOrganizer.displayIds).thenReturn(intArrayOf(DEFAULT_DISPLAY))
     val task = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
@@ -2075,11 +2101,8 @@
   }
 
   @Test
-  @DisableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION,
-  )
-  fun handleRequest_backTransition_singleTaskNoToken_noWallpaper_noBackNav_doesNotHandle() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,)
+  fun handleRequest_backTransition_singleTaskNoToken_noWallpaper_doesNotHandle() {
     val task = setUpFreeformTask()
 
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK))
@@ -2112,8 +2135,7 @@
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
-  fun handleRequest_backTransition_singleTaskNoToken_noBackNav_doesNotHandle() {
+  fun handleRequest_backTransition_singleTaskNoToken_doesNotHandle() {
     val task = setUpFreeformTask()
 
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_TO_BACK))
@@ -2122,11 +2144,8 @@
   }
 
   @Test
-  @DisableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_backTransition_singleTaskWithToken_noWallpaper_noBackNav_doesNotHandle() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,)
+  fun handleRequest_backTransition_singleTaskWithToken_noWallpaper_doesNotHandle() {
     val task = setUpFreeformTask()
 
     taskRepository.wallpaperActivityToken = MockToken().token()
@@ -2149,11 +2168,8 @@
   }
 
   @Test
-  @DisableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_backTransition_multipleTasks_noWallpaper_noBackNav_doesNotHandle() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,)
+  fun handleRequest_backTransition_multipleTasks_noWallpaper_doesNotHandle() {
     val task1 = setUpFreeformTask()
     setUpFreeformTask()
 
@@ -2165,7 +2181,7 @@
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  fun handleRequest_backTransition_multipleTasks_noBackNav_doesNotHandle() {
+  fun handleRequest_backTransition_multipleTasks_doesNotHandle() {
     val task1 = setUpFreeformTask()
     setUpFreeformTask()
 
@@ -2211,11 +2227,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_backTransition_nonMinimizadTask_withWallpaper_withBackNav_removesWallpaper() {
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,)
+  fun handleRequest_backTransition_nonMinimizadTask_withWallpaper_removesWallpaper() {
     val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val wallpaperToken = MockToken().token()
@@ -2231,11 +2244,8 @@
   }
 
   @Test
-  @DisableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_singleTaskNoToken_noWallpaper_noBackNav_doesNotHandle() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,)
+  fun handleRequest_closeTransition_singleTaskNoToken_noWallpaper_doesNotHandle() {
     val task = setUpFreeformTask()
 
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
@@ -2244,22 +2254,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_singleTaskNoToken_withWallpaper_withBackNav_removesTask() {
-    val task = setUpFreeformTask()
-
-    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
-
-    assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, task.token)
-  }
-
-  @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
-  fun handleRequest_closeTransition_singleTaskNoToken_noBackNav_doesNotHandle() {
+  fun handleRequest_closeTransition_singleTaskNoToken_doesNotHandle() {
     val task = setUpFreeformTask()
 
     val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
@@ -2268,11 +2264,8 @@
   }
 
   @Test
-  @DisableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_singleTaskWithToken_noWallpaper_noBackNav_doesNotHandle() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_singleTaskWithToken_noWallpaper_doesNotHandle() {
     val task = setUpFreeformTask()
 
     taskRepository.wallpaperActivityToken = MockToken().token()
@@ -2282,26 +2275,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_singleTaskWithToken_removesWallpaperAndTask() {
-    val task = setUpFreeformTask()
-    val wallpaperToken = MockToken().token()
-
-    taskRepository.wallpaperActivityToken = wallpaperToken
-    val result = controller.handleRequest(Binder(), createTransition(task, type = TRANSIT_CLOSE))
-
-    // Should create remove wallpaper transaction
-    assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, wallpaperToken)
-    result.assertRemoveAt(index = 1, task.token)
-  }
-
-  @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
-  fun handleRequest_closeTransition_singleTaskWithToken_withWallpaper_noBackNav_removesWallpaper() {
+  fun handleRequest_closeTransition_singleTaskWithToken_withWallpaper_removesWallpaper() {
     val task = setUpFreeformTask()
     val wallpaperToken = MockToken().token()
 
@@ -2313,11 +2288,8 @@
   }
 
   @Test
-  @DisableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_multipleTasks_noWallpaper_noBackNav_doesNotHandle() {
+  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
+  fun handleRequest_closeTransition_multipleTasks_noWallpaper_doesNotHandle() {
     val task1 = setUpFreeformTask()
     setUpFreeformTask()
 
@@ -2328,25 +2300,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_multipleTasks_withWallpaper_withBackNav_removesTask() {
-    val task1 = setUpFreeformTask()
-    setUpFreeformTask()
-
-    taskRepository.wallpaperActivityToken = MockToken().token()
-    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
-
-    assertNotNull(result, "Should handle request")
-    result.assertRemoveAt(index = 0, task1.token)
-  }
-
-  @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
-  fun handleRequest_closeTransition_multipleTasksFlagEnabled_noBackNav_doesNotHandle() {
+  fun handleRequest_closeTransition_multipleTasksFlagEnabled_doesNotHandle() {
     val task1 = setUpFreeformTask()
     setUpFreeformTask()
 
@@ -2357,28 +2312,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_multipleTasksSingleNonClosing_removesWallpaperAndTask() {
-    val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
-    val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
-    val wallpaperToken = MockToken().token()
-
-    taskRepository.wallpaperActivityToken = wallpaperToken
-    taskRepository.addClosingTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
-    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
-
-    // Should create remove wallpaper transaction
-    assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, wallpaperToken)
-    result.assertRemoveAt(index = 1, task1.token)
-  }
-
-  @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
-  fun handleRequest_closeTransition_multipleTasksSingleNonClosing_noBackNav_removesWallpaper() {
+  fun handleRequest_closeTransition_multipleTasksSingleNonClosing_removesWallpaper() {
     val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val wallpaperToken = MockToken().token()
@@ -2392,28 +2327,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_multipleTasksOneNonMinimized_removesWallpaperAndTask() {
-    val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
-    val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
-    val wallpaperToken = MockToken().token()
-
-    taskRepository.wallpaperActivityToken = wallpaperToken
-    taskRepository.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
-    val result = controller.handleRequest(Binder(), createTransition(task1, type = TRANSIT_CLOSE))
-
-    // Should create remove wallpaper transaction
-    assertNotNull(result, "Should handle request").assertRemoveAt(index = 0, wallpaperToken)
-    result.assertRemoveAt(index = 1, task1.token)
-  }
-
-  @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY)
-  @DisableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
-  fun handleRequest_closeTransition_multipleTasksSingleNonMinimized_noBackNav_removesWallpaper() {
+  fun handleRequest_closeTransition_multipleTasksSingleNonMinimized_removesWallpaper() {
     val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val wallpaperToken = MockToken().token()
@@ -2427,11 +2342,8 @@
   }
 
   @Test
-  @EnableFlags(
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,
-    Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
-  )
-  fun handleRequest_closeTransition_minimizadTask_withWallpaper_withBackNav_removesWallpaper() {
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_WALLPAPER_ACTIVITY,)
+  fun handleRequest_closeTransition_minimizadTask_withWallpaper_removesWallpaper() {
     val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
     val wallpaperToken = MockToken().token()
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt
index 045e077..bdcb459 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksLimiterTest.kt
@@ -19,6 +19,8 @@
 import android.app.ActivityManager.RunningTaskInfo
 import android.os.Binder
 import android.os.Handler
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
 import android.platform.test.flag.junit.SetFlagsRule
 import android.testing.AndroidTestingRunner
 import android.view.Display.DEFAULT_DISPLAY
@@ -33,6 +35,7 @@
 import com.android.dx.mockito.inline.extended.StaticMockitoSession
 import com.android.internal.jank.Cuj.CUJ_DESKTOP_MODE_MINIMIZE_WINDOW
 import com.android.internal.jank.InteractionJankMonitor
+import com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION
 import com.android.wm.shell.ShellTaskOrganizer
 import com.android.wm.shell.ShellTestCase
 import com.android.wm.shell.common.ShellExecutor
@@ -243,6 +246,7 @@
     }
 
     @Test
+    @DisableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
     fun removeLeftoverMinimizedTasks_activeNonMinimizedTasksStillAround_doesNothing() {
         desktopTaskRepo.addActiveTask(displayId = DEFAULT_DISPLAY, taskId = 1)
         desktopTaskRepo.addActiveTask(displayId = DEFAULT_DISPLAY, taskId = 2)
@@ -256,6 +260,7 @@
     }
 
     @Test
+    @DisableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
     fun removeLeftoverMinimizedTasks_noMinimizedTasks_doesNothing() {
         val wct = WindowContainerTransaction()
         desktopTasksLimiter.leftoverMinimizedTasksRemover.removeLeftoverMinimizedTasks(
@@ -265,6 +270,7 @@
     }
 
     @Test
+    @DisableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
     fun removeLeftoverMinimizedTasks_onlyMinimizedTasksLeft_removesAllMinimizedTasks() {
         val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
         val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
@@ -283,6 +289,20 @@
     }
 
     @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
+    fun removeLeftoverMinimizedTasks_onlyMinimizedTasksLeft_backNavEnabled_doesNothing() {
+        val task1 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+        val task2 = setUpFreeformTask(displayId = DEFAULT_DISPLAY)
+        desktopTaskRepo.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = task1.taskId)
+        desktopTaskRepo.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = task2.taskId)
+
+        val wct = WindowContainerTransaction()
+        desktopTasksLimiter.leftoverMinimizedTasksRemover.onActiveTasksChanged(DEFAULT_DISPLAY)
+
+        assertThat(wct.hierarchyOps).isEmpty()
+    }
+
+    @Test
     fun addAndGetMinimizeTaskChangesIfNeeded_tasksWithinLimit_noTaskMinimized() {
         (1..<MAX_TASK_LIMIT).forEach { _ -> setUpFreeformTask() }
 
@@ -291,7 +311,7 @@
                 desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded(
                         displayId = DEFAULT_DISPLAY,
                         wct = wct,
-                        newFrontTaskInfo = setUpFreeformTask())
+                        newFrontTaskId = setUpFreeformTask().taskId)
 
         assertThat(minimizedTaskId).isNull()
         assertThat(wct.hierarchyOps).isEmpty() // No reordering operations added
@@ -307,7 +327,7 @@
                 desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded(
                         displayId = DEFAULT_DISPLAY,
                         wct = wct,
-                        newFrontTaskInfo = setUpFreeformTask())
+                        newFrontTaskId = setUpFreeformTask().taskId)
 
         assertThat(minimizedTaskId).isEqualTo(tasks.first())
         assertThat(wct.hierarchyOps.size).isEqualTo(1)
@@ -325,7 +345,7 @@
                 desktopTasksLimiter.addAndGetMinimizeTaskChangesIfNeeded(
                         displayId = 0,
                         wct = wct,
-                        newFrontTaskInfo = setUpFreeformTask())
+                        newFrontTaskId = setUpFreeformTask().taskId)
 
         assertThat(minimizedTaskId).isNull()
         assertThat(wct.hierarchyOps).isEmpty() // No reordering operations added
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserverTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserverTest.kt
index c989d16..42fcc83 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserverTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksTransitionObserverTest.kt
@@ -18,11 +18,13 @@
 
 import android.app.ActivityManager.RunningTaskInfo
 import android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM
+import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN
 import android.content.ComponentName
 import android.content.Context
 import android.content.Intent
 import android.platform.test.annotations.EnableFlags
 import android.view.Display.DEFAULT_DISPLAY
+import android.view.WindowManager.TRANSIT_OPEN
 import android.view.WindowManager.TRANSIT_TO_BACK
 import android.window.IWindowContainerToken
 import android.window.TransitionInfo
@@ -110,6 +112,24 @@
         verify(taskRepository, never()).minimizeTask(task.displayId, task.taskId)
     }
 
+    @Test
+    @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
+    fun removeTasks_onTaskFullscreenLaunch_taskRemovedFromRepo() {
+        val task = createTaskInfo(1, WINDOWING_MODE_FULLSCREEN)
+        whenever(taskRepository.getVisibleTaskCount(any())).thenReturn(1)
+        whenever(taskRepository.isActiveTask(task.taskId)).thenReturn(true)
+
+        transitionObserver.onTransitionReady(
+            transition = mock(),
+            info = createOpenTransition(task),
+            startTransaction = mock(),
+            finishTransaction = mock(),
+        )
+
+        verify(taskRepository, never()).minimizeTask(task.displayId, task.taskId)
+        verify(taskRepository).removeFreeformTask(task.displayId, task.taskId)
+    }
+
     private fun createBackNavigationTransition(
         task: RunningTaskInfo?
     ): TransitionInfo {
@@ -125,11 +145,26 @@
         }
     }
 
-    private fun createTaskInfo(id: Int) =
+    private fun createOpenTransition(
+        task: RunningTaskInfo?
+    ): TransitionInfo {
+        return TransitionInfo(TRANSIT_OPEN, 0 /* flags */).apply {
+            addChange(
+                Change(mock(), mock()).apply {
+                    mode = TRANSIT_OPEN
+                    parent = null
+                    taskInfo = task
+                    flags = flags
+                }
+            )
+        }
+    }
+
+    private fun createTaskInfo(id: Int, windowingMode: Int = WINDOWING_MODE_FREEFORM) =
         RunningTaskInfo().apply {
             taskId = id
             displayId = DEFAULT_DISPLAY
-            configuration.windowConfiguration.windowingMode = WINDOWING_MODE_FREEFORM
+            configuration.windowConfiguration.windowingMode = windowingMode
             token = WindowContainerToken(Mockito.mock(IWindowContainerToken::class.java))
             baseIntent = Intent().apply {
                 component = ComponentName("package", "component.name")
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandlerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandlerTest.kt
index d9387d2..230f7e6 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandlerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DragToDesktopTransitionHandlerTest.kt
@@ -581,7 +581,7 @@
                 )
             )
             .thenReturn(token)
-        handler.startDragToDesktopTransition(task.taskId, dragAnimator)
+        handler.startDragToDesktopTransition(task, dragAnimator)
         return token
     }
 
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt
index aad31a6..5596ad7 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationControllerTest.kt
@@ -54,6 +54,7 @@
 import org.mockito.kotlin.any
 import org.mockito.kotlin.argumentCaptor
 import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.eq
 import org.mockito.kotlin.mock
 import org.mockito.kotlin.never
 import org.mockito.kotlin.times
@@ -190,6 +191,35 @@
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_APP_HANDLE_EDUCATION)
+  fun init_appHandleExpanded_shouldMarkFeatureViewed() =
+      testScope.runTest {
+        setShouldShowAppHandleEducation(false)
+
+        // Simulate app handle visible and expanded.
+        testCaptionStateFlow.value = createAppHandleState(isHandleMenuExpanded = true)
+        // Wait for some time before verifying
+        waitForBufferDelay()
+
+        verify(mockDataStoreRepository, times(1)).updateFeatureUsedTimestampMillis(eq(true))
+      }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_APP_HANDLE_EDUCATION)
+  fun init_showFirstTooltip_shouldMarkEducationViewed() =
+      testScope.runTest {
+        // App handle is visible. Should show education tooltip.
+        setShouldShowAppHandleEducation(true)
+
+        // Simulate app handle visible.
+        testCaptionStateFlow.value = createAppHandleState()
+        // Wait for first tooltip to showup.
+        waitForBufferDelay()
+
+        verify(mockDataStoreRepository, times(1)).updateEducationViewedTimestampMillis(eq(true))
+      }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_DESKTOP_WINDOWING_APP_HANDLE_EDUCATION)
   fun showWindowingImageButtonTooltip_appHandleExpanded_shouldCallShowEducationTooltipTwice() =
       testScope.runTest {
         // After first tooltip is dismissed, app handle is expanded. Should show second education
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationDatastoreRepositoryTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationDatastoreRepositoryTest.kt
index 1c1c650..c286544 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationDatastoreRepositoryTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/education/AppHandleEducationDatastoreRepositoryTest.kt
@@ -109,6 +109,24 @@
         assertThat(result).isEqualTo(windowingEducationProto)
       }
 
+  @Test
+  fun updateEducationViewedTimestampMillis_updatesDatastoreProto() =
+      runTest(StandardTestDispatcher()) {
+        datastoreRepository.updateEducationViewedTimestampMillis(true)
+
+        val result = testDatastore.data.first().hasEducationViewedTimestampMillis()
+        assertThat(result).isEqualTo(true)
+      }
+
+  @Test
+  fun updateFeatureUsedTimestampMillis_updatesDatastoreProto() =
+      runTest(StandardTestDispatcher()) {
+        datastoreRepository.updateFeatureUsedTimestampMillis(true)
+
+        val result = testDatastore.data.first().hasFeatureUsedTimestampMillis()
+        assertThat(result).isEqualTo(true)
+      }
+
   companion object {
     private const val APP_HANDLE_EDUCATION_DATASTORE_TEST_FILE = "app_handle_education_test.pb"
   }
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java
index 763d015..3b2c7e6 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/freeform/FreeformTaskListenerTests.java
@@ -18,15 +18,19 @@
 
 import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM;
 import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
+import static android.view.Display.INVALID_DISPLAY;
 
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
+import static com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import android.app.ActivityManager;
+import android.platform.test.annotations.EnableFlags;
 import android.view.SurfaceControl;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -139,6 +143,40 @@
         verify(mLaunchAdjacentController).setLaunchAdjacentEnabled(true);
     }
 
+    @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
+    public void onTaskVanished_nonClosingTask_isMinimized() {
+        ActivityManager.RunningTaskInfo task = new TestRunningTaskInfoBuilder()
+                .setWindowingMode(WINDOWING_MODE_FREEFORM).build();
+        task.isVisible = true;
+
+        mFreeformTaskListener.onTaskAppeared(task, mMockSurfaceControl);
+
+        task.isVisible = false;
+        task.displayId = INVALID_DISPLAY;
+        mFreeformTaskListener.onTaskVanished(task);
+
+        verify(mDesktopModeTaskRepository).minimizeTask(task.displayId, task.taskId);
+    }
+
+    @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_BACK_NAVIGATION)
+    public void onTaskVanished_closingTask_isNotMinimized() {
+        ActivityManager.RunningTaskInfo task = new TestRunningTaskInfoBuilder()
+                .setWindowingMode(WINDOWING_MODE_FREEFORM).build();
+        task.isVisible = true;
+
+        mFreeformTaskListener.onTaskAppeared(task, mMockSurfaceControl);
+
+        when(mDesktopModeTaskRepository.isClosingTask(task.taskId)).thenReturn(true);
+        task.isVisible = false;
+        task.displayId = INVALID_DISPLAY;
+        mFreeformTaskListener.onTaskVanished(task);
+
+        verify(mDesktopModeTaskRepository, never()).minimizeTask(task.displayId, task.taskId);
+        verify(mDesktopModeTaskRepository).removeFreeformTask(task.displayId, task.taskId);
+    }
+
     @After
     public void tearDown() {
         mMockitoSession.finishMocking();
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
index 753d4cd..0364b51 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/recents/RecentTasksControllerTest.java
@@ -22,6 +22,7 @@
 import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
 
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
+import static com.android.window.flags.Flags.FLAG_ENABLE_DESKTOP_WINDOWING_PERSISTENCE;
 import static com.android.wm.shell.shared.split.SplitScreenConstants.SNAP_TO_2_50_50;
 
 import static org.junit.Assert.assertEquals;
@@ -50,6 +51,7 @@
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.pm.PackageManager;
+import android.graphics.Point;
 import android.graphics.Rect;
 import android.os.Bundle;
 import android.platform.test.annotations.DisableFlags;
@@ -441,6 +443,40 @@
     }
 
     @Test
+    @EnableFlags(FLAG_ENABLE_DESKTOP_WINDOWING_PERSISTENCE)
+    public void testGetRecentTasks_hasDesktopTasks_persistenceEnabled_freeformTaskHaveBoundsSet() {
+        ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
+        ActivityManager.RecentTaskInfo t2 = makeTaskInfo(2);
+
+        t1.lastNonFullscreenBounds = new Rect(100, 200, 300, 400);
+        t2.lastNonFullscreenBounds = new Rect(150, 250, 350, 450);
+        setRawList(t1, t2);
+
+        when(mDesktopModeTaskRepository.isActiveTask(1)).thenReturn(true);
+        when(mDesktopModeTaskRepository.isActiveTask(2)).thenReturn(true);
+
+        ArrayList<GroupedRecentTaskInfo> recentTasks = mRecentTasksController.getRecentTasks(
+                MAX_VALUE, RECENT_IGNORE_UNAVAILABLE, 0);
+
+        assertEquals(1, recentTasks.size());
+        GroupedRecentTaskInfo freeformGroup = recentTasks.get(0);
+
+        // Check bounds
+        assertEquals(t1.lastNonFullscreenBounds, freeformGroup.getTaskInfoList().get(
+                0).configuration.windowConfiguration.getAppBounds());
+        assertEquals(t2.lastNonFullscreenBounds, freeformGroup.getTaskInfoList().get(
+                1).configuration.windowConfiguration.getAppBounds());
+
+        // Check position in parent
+        assertEquals(new Point(t1.lastNonFullscreenBounds.left,
+                        t1.lastNonFullscreenBounds.top),
+                freeformGroup.getTaskInfoList().get(0).positionInParent);
+        assertEquals(new Point(t2.lastNonFullscreenBounds.left,
+                        t2.lastNonFullscreenBounds.top),
+                freeformGroup.getTaskInfoList().get(1).positionInParent);
+    }
+
+    @Test
     public void testRemovedTaskRemovesSplit() {
         ActivityManager.RecentTaskInfo t1 = makeTaskInfo(1);
         ActivityManager.RecentTaskInfo t2 = makeTaskInfo(2);
@@ -623,6 +659,7 @@
     private ActivityManager.RecentTaskInfo makeTaskInfo(int taskId) {
         ActivityManager.RecentTaskInfo info = new ActivityManager.RecentTaskInfo();
         info.taskId = taskId;
+        info.lastNonFullscreenBounds = new Rect();
         return info;
     }
 
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java
index 5f75423..ce482cd 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/startingsurface/StartingSurfaceDrawerTests.java
@@ -30,7 +30,6 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.times;
@@ -56,12 +55,11 @@
 import android.os.Looper;
 import android.os.UserHandle;
 import android.testing.TestableContext;
-import android.view.IWindowSession;
 import android.view.InsetsState;
 import android.view.Surface;
 import android.view.WindowManager;
-import android.view.WindowManagerGlobal;
 import android.view.WindowMetrics;
+import android.window.SnapshotDrawerUtils;
 import android.window.StartingWindowInfo;
 import android.window.StartingWindowRemovalInfo;
 import android.window.TaskSnapshot;
@@ -220,18 +218,10 @@
                 createWindowInfo(taskId, android.R.style.Theme, mBinder);
         TaskSnapshot snapshot = createTaskSnapshot(100, 100, new Point(100, 100),
                 new Rect(0, 0, 0, 50), true /* hasImeSurface */);
-        final IWindowSession session = WindowManagerGlobal.getWindowSession();
-        spyOn(session);
-        doReturn(WindowManagerGlobal.ADD_OKAY).when(session).addToDisplay(
-                any() /* window */, any() /* attrs */,
-                anyInt() /* viewVisibility */, anyInt() /* displayId */,
-                anyInt() /* requestedVisibleTypes */, any() /* outInputChannel */,
-                any() /* outInsetsState */, any() /* outActiveControls */,
-                any() /* outAttachedFrame */, any() /* outSizeCompatScale */);
-        TaskSnapshotWindow mockSnapshotWindow = TaskSnapshotWindow.create(windowInfo,
-                mBinder,
-                snapshot, mTestExecutor, () -> {
-                });
+        final TaskSnapshotWindow mockSnapshotWindow = new TaskSnapshotWindow(
+                snapshot, SnapshotDrawerUtils.getOrCreateTaskDescription(windowInfo.taskInfo),
+                snapshot.getOrientation(),
+                () -> {}, mTestExecutor);
         spyOn(mockSnapshotWindow);
         try (AutoCloseable mockTaskSnapshotSession = new AutoCloseable() {
             MockitoSession mockSession = mockitoSession()
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java
index d37b4cf..d63158c 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/transition/FocusTransitionObserverTest.java
@@ -18,7 +18,7 @@
 
 import static android.view.Display.DEFAULT_DISPLAY;
 import static android.view.WindowManager.TRANSIT_OPEN;
-import static android.view.WindowManager.TRANSIT_TO_FRONT;
+import static android.window.TransitionInfo.FLAG_IS_DISPLAY;
 import static android.window.TransitionInfo.FLAG_MOVED_TO_TOP;
 
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.verify;
@@ -97,50 +97,38 @@
     }
 
     @Test
-    public void testTransitionWithMovedToFrontFlagChangesDisplayFocus() throws RemoteException {
+    public void testOnlyDisplayChangeAffectsDisplayFocus() throws RemoteException {
         final IBinder binder = mock(IBinder.class);
         final SurfaceControl.Transaction tx = mock(SurfaceControl.Transaction.class);
 
-        // Open a task on the default display, which doesn't change display focus because the
-        // default display already has it.
+        // Open a task on the secondary display, but it doesn't change display focus because it only
+        // has a task change.
         TransitionInfo info = mock(TransitionInfo.class);
         final List<TransitionInfo.Change> changes = new ArrayList<>();
-        setupChange(changes, 123 /* taskId */, TRANSIT_OPEN, DEFAULT_DISPLAY,
+        setupTaskChange(changes, 123 /* taskId */, TRANSIT_OPEN, SECONDARY_DISPLAY_ID,
                 true /* focused */);
         when(info.getChanges()).thenReturn(changes);
         mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx);
         verify(mListener, never()).onFocusedDisplayChanged(SECONDARY_DISPLAY_ID);
         clearInvocations(mListener);
 
-        // Open a new task on the secondary display and verify display focus changes to the display.
+        // Moving the secondary display to front must change display focus to it.
         changes.clear();
-        setupChange(changes, 456 /* taskId */, TRANSIT_OPEN, SECONDARY_DISPLAY_ID,
-                true /* focused */);
+        setupDisplayToTopChange(changes, SECONDARY_DISPLAY_ID);
         when(info.getChanges()).thenReturn(changes);
         mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx);
-        verify(mListener, times(1)).onFocusedDisplayChanged(SECONDARY_DISPLAY_ID);
-        clearInvocations(mListener);
+        verify(mListener, times(1))
+                .onFocusedDisplayChanged(SECONDARY_DISPLAY_ID);
 
-        // Open the first task to front and verify display focus goes back to the default display.
+        // Moving the secondary display to front must change display focus back to it.
         changes.clear();
-        setupChange(changes, 123 /* taskId */, TRANSIT_TO_FRONT, DEFAULT_DISPLAY,
-                true /* focused */);
+        setupDisplayToTopChange(changes, DEFAULT_DISPLAY);
         when(info.getChanges()).thenReturn(changes);
         mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx);
         verify(mListener, times(1)).onFocusedDisplayChanged(DEFAULT_DISPLAY);
-        clearInvocations(mListener);
-
-        // Open another task on the default display and verify no display focus switch as it's
-        // already on the default display.
-        changes.clear();
-        setupChange(changes, 789 /* taskId */, TRANSIT_OPEN, DEFAULT_DISPLAY,
-                true /* focused */);
-        when(info.getChanges()).thenReturn(changes);
-        mFocusTransitionObserver.onTransitionReady(binder, info, tx, tx);
-        verify(mListener, never()).onFocusedDisplayChanged(DEFAULT_DISPLAY);
     }
 
-    private void setupChange(List<TransitionInfo.Change> changes, int taskId,
+    private void setupTaskChange(List<TransitionInfo.Change> changes, int taskId,
             @TransitionMode int mode, int displayId, boolean focused) {
         TransitionInfo.Change change = mock(TransitionInfo.Change.class);
         RunningTaskInfo taskInfo = mock(RunningTaskInfo.class);
@@ -152,4 +140,12 @@
         when(change.getMode()).thenReturn(mode);
         changes.add(change);
     }
+
+    private void setupDisplayToTopChange(List<TransitionInfo.Change> changes, int displayId) {
+        TransitionInfo.Change change = mock(TransitionInfo.Change.class);
+        when(change.hasFlags(FLAG_MOVED_TO_TOP)).thenReturn(true);
+        when(change.hasFlags(FLAG_IS_DISPLAY)).thenReturn(true);
+        when(change.getEndDisplayId()).thenReturn(displayId);
+        changes.add(change);
+    }
 }
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt
index d141c2d..0f16b9d 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/CaptionWindowDecorationTests.kt
@@ -47,6 +47,8 @@
             taskInfo,
             true,
             false,
+            true /* isStatusBarVisible */,
+            false /* isKeyguardVisibleAndOccluded */,
             InsetsState()
         )
 
@@ -66,6 +68,8 @@
             taskInfo,
             true,
             false,
+            true /* isStatusBarVisible */,
+            false /* isKeyguardVisibleAndOccluded */,
             InsetsState()
         )
 
@@ -81,6 +85,8 @@
             taskInfo,
             true,
             false,
+            true /* isStatusBarVisible */,
+            false /* isKeyguardVisibleAndOccluded */,
             InsetsState()
         )
         Truth.assertThat(relayoutParams.mOccludingCaptionElements.size).isEqualTo(2)
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt
index 9aa6a52..5ae4ca8 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorViewModelTests.kt
@@ -27,6 +27,7 @@
 import android.content.ComponentName
 import android.content.Context
 import android.content.Intent
+import android.content.Intent.ACTION_MAIN
 import android.content.pm.ActivityInfo
 import android.graphics.Rect
 import android.hardware.display.DisplayManager
@@ -86,6 +87,7 @@
 import com.android.wm.shell.common.ShellExecutor
 import com.android.wm.shell.common.SyncTransactionQueue
 import com.android.wm.shell.desktopmode.DesktopActivityOrientationChangeHandler
+import com.android.wm.shell.desktopmode.DesktopModeTaskRepository
 import com.android.wm.shell.desktopmode.DesktopTasksController
 import com.android.wm.shell.desktopmode.DesktopTasksController.SnapPosition
 import com.android.wm.shell.desktopmode.DesktopTasksLimiter
@@ -159,6 +161,7 @@
     @Mock private lateinit var mockTaskOrganizer: ShellTaskOrganizer
     @Mock private lateinit var mockDisplayController: DisplayController
     @Mock private lateinit var mockSplitScreenController: SplitScreenController
+    @Mock private lateinit var mockDesktopRepository: DesktopModeTaskRepository
     @Mock private lateinit var mockDisplayLayout: DisplayLayout
     @Mock private lateinit var displayInsetsController: DisplayInsetsController
     @Mock private lateinit var mockSyncQueue: SyncTransactionQueue
@@ -230,6 +233,7 @@
                 mockShellCommandHandler,
                 mockWindowManager,
                 mockTaskOrganizer,
+                mockDesktopRepository,
                 mockDisplayController,
                 mockShellController,
                 displayInsetsController,
@@ -930,13 +934,13 @@
     @Test
     fun testDecor_onClickToOpenBrowser_closeMenus() {
         val openInBrowserListenerCaptor = forClass(Consumer::class.java)
-                as ArgumentCaptor<Consumer<Uri>>
+                as ArgumentCaptor<Consumer<Intent>>
         val decor = createOpenTaskDecoration(
             windowingMode = WINDOWING_MODE_FULLSCREEN,
             onOpenInBrowserClickListener = openInBrowserListenerCaptor
         )
 
-        openInBrowserListenerCaptor.value.accept(Uri.EMPTY)
+        openInBrowserListenerCaptor.value.accept(Intent())
 
         verify(decor).closeHandleMenu()
         verify(decor).closeMaximizeMenu()
@@ -946,20 +950,19 @@
     fun testDecor_onClickToOpenBrowser_opensBrowser() {
         doNothing().whenever(spyContext).startActivity(any())
         val uri = Uri.parse("https://www.google.com")
+        val intent = Intent(ACTION_MAIN, uri)
         val openInBrowserListenerCaptor = forClass(Consumer::class.java)
-                as ArgumentCaptor<Consumer<Uri>>
+                as ArgumentCaptor<Consumer<Intent>>
         createOpenTaskDecoration(
             windowingMode = WINDOWING_MODE_FULLSCREEN,
             onOpenInBrowserClickListener = openInBrowserListenerCaptor
         )
 
-        openInBrowserListenerCaptor.value.accept(uri)
+        openInBrowserListenerCaptor.value.accept(intent)
 
         verify(spyContext).startActivityAsUser(argThat { intent ->
-            intent.data == uri
-                    && ((intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK) != 0)
-                    && intent.categories.contains(Intent.CATEGORY_LAUNCHER)
-                    && intent.action == Intent.ACTION_MAIN
+            uri.equals(intent.data)
+                    && intent.action == ACTION_MAIN
         }, eq(mockUserHandle))
     }
 
@@ -1233,8 +1236,8 @@
             forClass(Function0::class.java) as ArgumentCaptor<Function0<Unit>>,
         onToSplitScreenClickListenerCaptor: ArgumentCaptor<Function0<Unit>> =
             forClass(Function0::class.java) as ArgumentCaptor<Function0<Unit>>,
-        onOpenInBrowserClickListener: ArgumentCaptor<Consumer<Uri>> =
-            forClass(Consumer::class.java) as ArgumentCaptor<Consumer<Uri>>,
+        onOpenInBrowserClickListener: ArgumentCaptor<Consumer<Intent>> =
+            forClass(Consumer::class.java) as ArgumentCaptor<Consumer<Intent>>,
         onCaptionButtonClickListener: ArgumentCaptor<View.OnClickListener> =
             forClass(View.OnClickListener::class.java) as ArgumentCaptor<View.OnClickListener>,
         onCaptionButtonTouchListener: ArgumentCaptor<View.OnTouchListener> =
@@ -1296,8 +1299,8 @@
         val decoration = mock(DesktopModeWindowDecoration::class.java)
         whenever(
             mockDesktopModeWindowDecorFactory.create(
-                any(), any(), any(), any(), any(), eq(task), any(), any(), any(), any(), any(),
-                any(), any(), any(), any(), any(), any())
+                any(), any(), any(), any(), any(), any(), eq(task), any(), any(), any(), any(),
+                any(), any(), any(), any(), any(), any(), any())
         ).thenReturn(decoration)
         decoration.mTaskInfo = task
         whenever(decoration.isFocused).thenReturn(task.isFocused)
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java
index f007115..3e7f3bd 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/DesktopModeWindowDecorationTests.java
@@ -23,6 +23,7 @@
 import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT;
 import static android.view.InsetsSource.FLAG_FORCE_CONSUMING;
 import static android.view.InsetsSource.FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR;
+import static android.view.WindowInsets.Type.captionBar;
 import static android.view.WindowInsets.Type.statusBars;
 import static android.view.WindowInsetsController.APPEARANCE_TRANSPARENT_CAPTION_BAR_BACKGROUND;
 
@@ -37,6 +38,7 @@
 
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyInt;
@@ -53,9 +55,11 @@
 import android.app.assist.AssistContent;
 import android.content.ComponentName;
 import android.content.Context;
+import android.content.Intent;
 import android.content.pm.ActivityInfo;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
 import android.content.res.Resources;
 import android.content.res.TypedArray;
 import android.graphics.PointF;
@@ -102,6 +106,7 @@
 import com.android.wm.shell.common.ShellExecutor;
 import com.android.wm.shell.common.SyncTransactionQueue;
 import com.android.wm.shell.desktopmode.CaptionState;
+import com.android.wm.shell.desktopmode.DesktopModeTaskRepository;
 import com.android.wm.shell.desktopmode.WindowDecorCaptionHandleRepository;
 import com.android.wm.shell.shared.desktopmode.DesktopModeStatus;
 import com.android.wm.shell.splitscreen.SplitScreenController;
@@ -124,6 +129,7 @@
 import org.mockito.Mock;
 import org.mockito.quality.Strictness;
 
+import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 
@@ -157,6 +163,8 @@
     @Mock
     private ShellTaskOrganizer mMockShellTaskOrganizer;
     @Mock
+    private DesktopModeTaskRepository mMockDesktopRepository;
+    @Mock
     private Choreographer mMockChoreographer;
     @Mock
     private SyncTransactionQueue mMockSyncQueue;
@@ -187,7 +195,7 @@
     @Mock
     private Handler mMockHandler;
     @Mock
-    private Consumer<Uri> mMockOpenInBrowserClickListener;
+    private Consumer<Intent> mMockOpenInBrowserClickListener;
     @Mock
     private AppToWebGenericLinksParser mMockGenericLinksParser;
     @Mock
@@ -242,9 +250,11 @@
         when(mMockMultiInstanceHelper.supportsMultiInstanceSplit(any()))
                 .thenReturn(false);
         when(mMockPackageManager.getApplicationLabel(any())).thenReturn("applicationLabel");
-        final ActivityInfo activityInfo = new ActivityInfo();
-        activityInfo.applicationInfo = new ApplicationInfo();
+        final ActivityInfo activityInfo = createActivityInfo();
         when(mMockPackageManager.getActivityInfo(any(), anyInt())).thenReturn(activityInfo);
+        final ResolveInfo resolveInfo = new ResolveInfo();
+        resolveInfo.activityInfo = activityInfo;
+        when(mMockPackageManager.resolveActivity(any(), anyInt())).thenReturn(resolveInfo);
         final Display defaultDisplay = mock(Display.class);
         doReturn(defaultDisplay).when(mMockDisplayController).getDisplay(Display.DEFAULT_DISPLAY);
         doReturn(mInsetsState).when(mMockDisplayController).getInsetsState(anyInt());
@@ -284,7 +294,11 @@
 
         DesktopModeWindowDecoration.updateRelayoutParams(
                 relayoutParams, mContext, taskInfo, /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.mShadowRadiusId).isNotEqualTo(Resources.ID_NULL);
     }
@@ -300,7 +314,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.mCornerRadius).isGreaterThan(0);
     }
@@ -321,7 +339,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.mWindowDecorConfig.densityDpi).isEqualTo(customTaskDensity);
     }
@@ -343,7 +365,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.mWindowDecorConfig.densityDpi).isEqualTo(systemDensity);
     }
@@ -361,7 +387,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.hasInputFeatureSpy()).isTrue();
     }
@@ -378,7 +408,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.hasInputFeatureSpy()).isFalse();
     }
@@ -394,7 +428,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(relayoutParams.hasInputFeatureSpy()).isFalse();
     }
@@ -410,7 +448,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(hasNoInputChannelFeature(relayoutParams)).isFalse();
     }
@@ -427,7 +469,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(hasNoInputChannelFeature(relayoutParams)).isTrue();
     }
@@ -444,7 +490,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(hasNoInputChannelFeature(relayoutParams)).isTrue();
     }
@@ -462,7 +512,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat((relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING) != 0).isTrue();
     }
@@ -481,7 +535,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat((relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING) == 0).isTrue();
     }
@@ -498,7 +556,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(
                 (relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR) != 0)
@@ -517,7 +579,11 @@
                 mTestableContext,
                 taskInfo,
                 /* applyStartTransactionOnDraw= */ true,
-                /* shouldSetTaskPositionAndCrop */ false);
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
 
         assertThat(
                 (relayoutParams.mInsetSourceFlags & FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR) == 0)
@@ -525,6 +591,171 @@
     }
 
     @Test
+    @EnableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP)
+    public void updateRelayoutParams_header_addsPaddingInFullImmersive() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM);
+        taskInfo.configuration.windowConfiguration.setBounds(new Rect(0, 0, 1000, 2000));
+        final InsetsState insetsState = createInsetsState(List.of(
+                createInsetsSource(
+                        0 /* id */, statusBars(), true /* visible */, new Rect(0, 0, 1000, 50)),
+                createInsetsSource(
+                        1 /* id */, captionBar(), true /* visible */, new Rect(0, 0, 1000, 100))));
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ true,
+                insetsState);
+
+        // Takes status bar inset as padding, ignores caption bar inset.
+        assertThat(relayoutParams.mCaptionTopPadding).isEqualTo(50);
+    }
+
+    @Test
+    @DisableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP)
+    public void updateRelayoutParams_header_statusBarInvisible_captionVisible() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM);
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ false,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
+
+        // Header is always shown because it's assumed the status bar is always visible.
+        assertThat(relayoutParams.mIsCaptionVisible).isTrue();
+    }
+
+    @Test
+    public void updateRelayoutParams_handle_statusBarVisibleAndNotOverKeyguard_captionVisible() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN);
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
+
+        assertThat(relayoutParams.mIsCaptionVisible).isTrue();
+    }
+
+    @Test
+    public void updateRelayoutParams_handle_statusBarInvisible_captionNotVisible() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN);
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ false,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
+
+        assertThat(relayoutParams.mIsCaptionVisible).isFalse();
+    }
+
+    @Test
+    public void updateRelayoutParams_handle_overKeyguard_captionNotVisible() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN);
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ true,
+                /* inFullImmersiveMode */ false,
+                new InsetsState());
+
+        assertThat(relayoutParams.mIsCaptionVisible).isFalse();
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP)
+    public void updateRelayoutParams_header_fullyImmersive_captionVisFollowsStatusBar() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM);
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ true,
+                new InsetsState());
+
+        assertThat(relayoutParams.mIsCaptionVisible).isTrue();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ false,
+                /* isKeyguardVisibleAndOccluded */ false,
+                /* inFullImmersiveMode */ true,
+                new InsetsState());
+
+        assertThat(relayoutParams.mIsCaptionVisible).isFalse();
+    }
+
+    @Test
+    @EnableFlags(Flags.FLAG_ENABLE_FULLY_IMMERSIVE_IN_DESKTOP)
+    public void updateRelayoutParams_header_fullyImmersive_overKeyguard_captionNotVisible() {
+        final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
+        taskInfo.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM);
+        final RelayoutParams relayoutParams = new RelayoutParams();
+
+        DesktopModeWindowDecoration.updateRelayoutParams(
+                relayoutParams,
+                mTestableContext,
+                taskInfo,
+                /* applyStartTransactionOnDraw= */ true,
+                /* shouldSetTaskPositionAndCrop */ false,
+                /* isStatusBarVisible */ true,
+                /* isKeyguardVisibleAndOccluded */ true,
+                /* inFullImmersiveMode */ true,
+                new InsetsState());
+
+        assertThat(relayoutParams.mIsCaptionVisible).isFalse();
+    }
+
+    @Test
     public void relayout_fullscreenTask_appliesTransactionImmediately() {
         final ActivityManager.RunningTaskInfo taskInfo = createTaskInfo(/* visible= */ true);
         final DesktopModeWindowDecoration spyWindowDecor = spy(createWindowDecoration(taskInfo));
@@ -771,7 +1002,6 @@
 
         // Verify handle menu's browser link is set to captured link since menu was opened before
         // captured link expired
-        createHandleMenu(decor);
         verifyHandleMenuCreated(TEST_URI1);
     }
 
@@ -782,7 +1012,7 @@
         final DesktopModeWindowDecoration decor = createWindowDecoration(
                 taskInfo, TEST_URI1 /* captured link */, null /* web uri */,
                 null /* generic link */);
-        final ArgumentCaptor<Function1<Uri, Unit>> openInBrowserCaptor =
+        final ArgumentCaptor<Function1<Intent, Unit>> openInBrowserCaptor =
                 ArgumentCaptor.forClass(Function1.class);
 
         // Simulate menu opening and clicking open in browser button
@@ -797,7 +1027,7 @@
                 any(),
                 any()
         );
-        openInBrowserCaptor.getValue().invoke(TEST_URI1);
+        openInBrowserCaptor.getValue().invoke(new Intent(Intent.ACTION_MAIN, TEST_URI1));
 
         // Verify handle menu's browser link not set to captured link since link not valid after
         // open in browser clicked
@@ -812,7 +1042,7 @@
         final DesktopModeWindowDecoration decor = createWindowDecoration(
                 taskInfo, TEST_URI1 /* captured link */, null /* web uri */,
                 null /* generic link */);
-        final ArgumentCaptor<Function1<Uri, Unit>> openInBrowserCaptor =
+        final ArgumentCaptor<Function1<Intent, Unit>> openInBrowserCaptor =
                 ArgumentCaptor.forClass(Function1.class);
         createHandleMenu(decor);
         verify(mMockHandleMenu).show(
@@ -826,9 +1056,10 @@
                 any()
         );
 
-        openInBrowserCaptor.getValue().invoke(TEST_URI1);
+        openInBrowserCaptor.getValue().invoke(new Intent(Intent.ACTION_MAIN, TEST_URI1));
 
-        verify(mMockOpenInBrowserClickListener).accept(TEST_URI1);
+        verify(mMockOpenInBrowserClickListener).accept(
+                argThat(intent -> intent.getData() == TEST_URI1));
     }
 
     @Test
@@ -1021,8 +1252,9 @@
 
     private void verifyHandleMenuCreated(@Nullable Uri uri) {
         verify(mMockHandleMenuFactory).create(any(), any(), anyInt(), any(), any(),
-                any(), anyBoolean(), anyBoolean(), anyBoolean(), eq(uri), anyInt(),
-                anyInt(), anyInt());
+                any(), anyBoolean(), anyBoolean(), anyBoolean(),
+                argThat(intent -> (uri == null && intent == null) || intent.getData().equals(uri)),
+                anyInt(), anyInt(), anyInt());
     }
 
     private void createMaximizeMenu(DesktopModeWindowDecoration decoration, MaximizeMenu menu) {
@@ -1086,9 +1318,9 @@
             boolean relayout) {
         final DesktopModeWindowDecoration windowDecor = new DesktopModeWindowDecoration(mContext,
                 mContext, mMockDisplayController, mMockSplitScreenController,
-                mMockShellTaskOrganizer, taskInfo, mMockSurfaceControl, mMockHandler, mBgExecutor,
-                mMockChoreographer, mMockSyncQueue, mMockAppHeaderViewHolderFactory,
-                mMockRootTaskDisplayAreaOrganizer,
+                mMockDesktopRepository, mMockShellTaskOrganizer, taskInfo, mMockSurfaceControl,
+                mMockHandler, mBgExecutor, mMockChoreographer, mMockSyncQueue,
+                mMockAppHeaderViewHolderFactory, mMockRootTaskDisplayAreaOrganizer,
                 mMockGenericLinksParser, mMockAssistContentRequester, SurfaceControl.Builder::new,
                 mMockTransactionSupplier, WindowContainerTransaction::new, SurfaceControl::new,
                 new WindowManagerWrapper(mMockWindowManager), mMockSurfaceControlViewHostFactory,
@@ -1128,19 +1360,39 @@
         decor.onAssistContentReceived(mAssistContent);
     }
 
+    private static ActivityInfo createActivityInfo() {
+        final ApplicationInfo applicationInfo = new ApplicationInfo();
+        applicationInfo.packageName = "DesktopModeWindowDecorationTestPackage";
+        final ActivityInfo activityInfo = new ActivityInfo();
+        activityInfo.applicationInfo = applicationInfo;
+        activityInfo.name = "DesktopModeWindowDecorationTest";
+        return activityInfo;
+    }
+
     private static boolean hasNoInputChannelFeature(RelayoutParams params) {
         return (params.mInputFeatures & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL)
                 != 0;
     }
 
-    private InsetsState createInsetsState(@WindowInsets.Type.InsetsType int type, boolean visible) {
-        final InsetsState state = new InsetsState();
-        final InsetsSource source = new InsetsSource(/* id= */0, type);
+    private InsetsSource createInsetsSource(int id, @WindowInsets.Type.InsetsType int type,
+            boolean visible, @NonNull Rect frame) {
+        final InsetsSource source = new InsetsSource(id, type);
         source.setVisible(visible);
-        state.addSource(source);
+        source.setFrame(frame);
+        return source;
+    }
+
+    private InsetsState createInsetsState(@NonNull List<InsetsSource> sources) {
+        final InsetsState state = new InsetsState();
+        sources.forEach(state::addSource);
         return state;
     }
 
+    private InsetsState createInsetsState(@WindowInsets.Type.InsetsType int type, boolean visible) {
+        final InsetsSource source = createInsetsSource(0 /* id */, type, visible, new Rect());
+        return createInsetsState(List.of(source));
+    }
+
     private static class TestTouchEventListener extends GestureDetector.SimpleOnGestureListener
             implements View.OnClickListener, View.OnTouchListener, View.OnLongClickListener,
             View.OnGenericMotionListener, DragDetector.MotionEventHandler {
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java
index 2e117ac..94cabc4 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/WindowDecorationTests.java
@@ -47,6 +47,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -246,6 +247,7 @@
         // Density is 2. Shadow radius is 10px. Caption height is 64px.
         taskInfo.configuration.densityDpi = DisplayMetrics.DENSITY_DEFAULT * 2;
         final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
+        mRelayoutParams.mIsCaptionVisible = true;
 
         windowDecor.relayout(taskInfo);
 
@@ -319,6 +321,7 @@
         taskInfo.configuration.densityDpi = DisplayMetrics.DENSITY_DEFAULT * 2;
 
         final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
+        mRelayoutParams.mIsCaptionVisible = true;
 
         windowDecor.relayout(taskInfo);
 
@@ -571,11 +574,7 @@
                 .build();
         final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
 
-        assertTrue(mInsetsState.getOrCreateSource(STATUS_BAR_INSET_SOURCE_ID, statusBars())
-                .isVisible());
-        assertTrue(mInsetsState.sourceSize() == 1);
-        assertTrue(mInsetsState.sourceAt(0).getType() == statusBars());
-
+        mRelayoutParams.mIsCaptionVisible = true;
         windowDecor.relayout(taskInfo);
 
         verify(mMockWindowContainerTransaction).addInsetsSource(eq(taskInfo.token), any(),
@@ -623,33 +622,6 @@
     }
 
     @Test
-    public void testRelayout_captionHidden_insetsRemoved() {
-        final Display defaultDisplay = mock(Display.class);
-        doReturn(defaultDisplay).when(mMockDisplayController)
-                .getDisplay(Display.DEFAULT_DISPLAY);
-
-        final ActivityManager.RunningTaskInfo taskInfo = new TestRunningTaskInfoBuilder()
-                .setDisplayId(Display.DEFAULT_DISPLAY)
-                .setVisible(true)
-                .setBounds(new Rect(0, 0, 1000, 1000))
-                .build();
-        taskInfo.isFocused = true;
-        // Caption visible at first.
-        when(mMockDisplayController.getInsetsState(taskInfo.displayId))
-                .thenReturn(createInsetsState(statusBars(), true /* visible */));
-        final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
-        windowDecor.relayout(taskInfo);
-
-        // Hide caption so insets are removed.
-        windowDecor.onInsetsStateChanged(createInsetsState(statusBars(), false /* visible */));
-
-        verify(mMockWindowContainerTransaction).removeInsetsSource(eq(taskInfo.token), any(),
-                eq(0) /* index */, eq(captionBar()));
-        verify(mMockWindowContainerTransaction).removeInsetsSource(eq(taskInfo.token), any(),
-                eq(0) /* index */, eq(mandatorySystemGestures()));
-    }
-
-    @Test
     public void testRelayout_captionHidden_neverWasVisible_insetsNotRemoved() {
         final Display defaultDisplay = mock(Display.class);
         doReturn(defaultDisplay).when(mMockDisplayController)
@@ -661,9 +633,8 @@
                 .setBounds(new Rect(0, 0, 1000, 1000))
                 .build();
         // Hidden from the beginning, so no insets were ever added.
-        when(mMockDisplayController.getInsetsState(taskInfo.displayId))
-                .thenReturn(createInsetsState(statusBars(), false /* visible */));
         final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
+        mRelayoutParams.mIsCaptionVisible = false;
         windowDecor.relayout(taskInfo);
 
         // Never added.
@@ -692,7 +663,7 @@
         final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
 
         // Relayout will add insets.
-        mInsetsState.getOrCreateSource(STATUS_BAR_INSET_SOURCE_ID, captionBar()).setVisible(true);
+        mRelayoutParams.mIsCaptionVisible = true;
         windowDecor.relayout(taskInfo);
         verify(mMockWindowContainerTransaction).addInsetsSource(eq(taskInfo.token), any(),
                 eq(0) /* index */, eq(captionBar()), any(), any(), anyInt());
@@ -740,6 +711,7 @@
         final TestRunningTaskInfoBuilder builder = new TestRunningTaskInfoBuilder()
                 .setDisplayId(Display.DEFAULT_DISPLAY)
                 .setVisible(true);
+        mRelayoutParams.mIsCaptionVisible = true;
 
         // Relayout twice with different bounds.
         final ActivityManager.RunningTaskInfo firstTaskInfo =
@@ -767,6 +739,7 @@
         final TestRunningTaskInfoBuilder builder = new TestRunningTaskInfoBuilder()
                 .setDisplayId(Display.DEFAULT_DISPLAY)
                 .setVisible(true);
+        mRelayoutParams.mIsCaptionVisible = true;
 
         // Relayout twice with the same bounds.
         final ActivityManager.RunningTaskInfo firstTaskInfo =
@@ -797,6 +770,7 @@
         final ActivityManager.RunningTaskInfo taskInfo =
                 builder.setToken(token).setBounds(new Rect(0, 0, 1000, 1000)).build();
         final TestWindowDecoration windowDecor = createWindowDecoration(taskInfo);
+        mRelayoutParams.mIsCaptionVisible = true;
         mRelayoutParams.mInsetSourceFlags =
                 FLAG_FORCE_CONSUMING | FLAG_FORCE_CONSUMING_OPAQUE_CAPTION_BAR;
         windowDecor.relayout(taskInfo);
@@ -901,76 +875,61 @@
     }
 
     @Test
-    public void onStatusBarVisibilityChange_fullscreen_shownToHidden_hidesCaption() {
+    public void onStatusBarVisibilityChange() {
         final ActivityManager.RunningTaskInfo task = createTaskInfo();
         task.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN);
         when(mMockDisplayController.getInsetsState(task.displayId))
                 .thenReturn(createInsetsState(statusBars(), true /* visible */));
-        final TestWindowDecoration decor = createWindowDecoration(task);
+        final TestWindowDecoration decor = spy(createWindowDecoration(task));
         decor.relayout(task);
-        assertTrue(decor.mIsCaptionVisible);
+        assertTrue(decor.mIsStatusBarVisible);
 
         decor.onInsetsStateChanged(createInsetsState(statusBars(), false /* visible */));
 
-        assertFalse(decor.mIsCaptionVisible);
+        verify(decor, times(2)).relayout(task);
     }
 
     @Test
-    public void onStatusBarVisibilityChange_fullscreen_hiddenToShown_showsCaption() {
+    public void onStatusBarVisibilityChange_noChange_doesNotRelayout() {
         final ActivityManager.RunningTaskInfo task = createTaskInfo();
         task.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FULLSCREEN);
         when(mMockDisplayController.getInsetsState(task.displayId))
-                .thenReturn(createInsetsState(statusBars(), false /* visible */));
-        final TestWindowDecoration decor = createWindowDecoration(task);
+                .thenReturn(createInsetsState(statusBars(), true /* visible */));
+        final TestWindowDecoration decor = spy(createWindowDecoration(task));
         decor.relayout(task);
-        assertFalse(decor.mIsCaptionVisible);
 
         decor.onInsetsStateChanged(createInsetsState(statusBars(), true /* visible */));
 
-        assertTrue(decor.mIsCaptionVisible);
+        verify(decor, times(1)).relayout(task);
     }
 
     @Test
-    public void onStatusBarVisibilityChange_freeform_shownToHidden_keepsCaption() {
-        final ActivityManager.RunningTaskInfo task = createTaskInfo();
-        task.configuration.windowConfiguration.setWindowingMode(WINDOWING_MODE_FREEFORM);
-        when(mMockDisplayController.getInsetsState(task.displayId))
-                .thenReturn(createInsetsState(statusBars(), true /* visible */));
-        final TestWindowDecoration decor = createWindowDecoration(task);
-        decor.relayout(task);
-        assertTrue(decor.mIsCaptionVisible);
-
-        decor.onInsetsStateChanged(createInsetsState(statusBars(), false /* visible */));
-
-        assertTrue(decor.mIsCaptionVisible);
-    }
-
-    @Test
-    public void onKeyguardStateChange_hiddenToShownAndOccluding_hidesCaption() {
+    public void onKeyguardStateChange() {
         final ActivityManager.RunningTaskInfo task = createTaskInfo();
         when(mMockDisplayController.getInsetsState(task.displayId))
                 .thenReturn(createInsetsState(statusBars(), true /* visible */));
-        final TestWindowDecoration decor = createWindowDecoration(task);
+        final TestWindowDecoration decor = spy(createWindowDecoration(task));
         decor.relayout(task);
-        assertTrue(decor.mIsCaptionVisible);
+        assertFalse(decor.mIsKeyguardVisibleAndOccluded);
 
         decor.onKeyguardStateChanged(true /* visible */, true /* occluding */);
 
-        assertFalse(decor.mIsCaptionVisible);
+        assertTrue(decor.mIsKeyguardVisibleAndOccluded);
+        verify(decor, times(2)).relayout(task);
     }
 
     @Test
-    public void onKeyguardStateChange_showingAndOccludingToHidden_showsCaption() {
+    public void onKeyguardStateChange_noChange_doesNotRelayout() {
         final ActivityManager.RunningTaskInfo task = createTaskInfo();
         when(mMockDisplayController.getInsetsState(task.displayId))
                 .thenReturn(createInsetsState(statusBars(), true /* visible */));
-        final TestWindowDecoration decor = createWindowDecoration(task);
-        decor.onKeyguardStateChanged(true /* visible */, true /* occluding */);
-        assertFalse(decor.mIsCaptionVisible);
+        final TestWindowDecoration decor = spy(createWindowDecoration(task));
+        decor.relayout(task);
+        assertFalse(decor.mIsKeyguardVisibleAndOccluded);
 
-        decor.onKeyguardStateChanged(false /* visible */, false /* occluding */);
+        decor.onKeyguardStateChanged(false /* visible */, true /* occluding */);
 
-        assertTrue(decor.mIsCaptionVisible);
+        verify(decor, times(1)).relayout(task);
     }
 
     private ActivityManager.RunningTaskInfo createTaskInfo() {
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt
index 5594981..6749776 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/windowdecor/education/DesktopWindowingEducationTooltipControllerTest.kt
@@ -24,12 +24,16 @@
 import android.testing.TestableLooper
 import android.testing.TestableResources
 import android.view.MotionEvent
+import android.view.Surface.ROTATION_180
+import android.view.Surface.ROTATION_90
 import android.view.View
 import android.view.WindowManager
 import android.widget.TextView
+import android.window.WindowContainerTransaction
 import androidx.test.filters.SmallTest
 import com.android.wm.shell.R
 import com.android.wm.shell.ShellTestCase
+import com.android.wm.shell.common.DisplayController
 import com.android.wm.shell.windowdecor.additionalviewcontainer.AdditionalSystemViewContainer
 import com.android.wm.shell.windowdecor.education.DesktopWindowingEducationTooltipController.TooltipArrowDirection
 import com.google.common.truth.Truth.assertThat
@@ -42,9 +46,11 @@
 import org.mockito.kotlin.any
 import org.mockito.kotlin.anyOrNull
 import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeastOnce
 import org.mockito.kotlin.mock
 import org.mockito.kotlin.times
 import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
 
 @SmallTest
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
@@ -52,6 +58,8 @@
 class DesktopWindowingEducationTooltipControllerTest : ShellTestCase() {
   @Mock private lateinit var mockWindowManager: WindowManager
   @Mock private lateinit var mockViewContainerFactory: AdditionalSystemViewContainer.Factory
+  @Mock private lateinit var mockDisplayController: DisplayController
+  @Mock private lateinit var mockPopupWindow: AdditionalSystemViewContainer
   private lateinit var testableResources: TestableResources
   private lateinit var testableContext: TestableContext
   private lateinit var tooltipController: DesktopWindowingEducationTooltipController
@@ -69,7 +77,8 @@
         Context.LAYOUT_INFLATER_SERVICE, context.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
     testableContext.addMockSystemService(WindowManager::class.java, mockWindowManager)
     tooltipController =
-        DesktopWindowingEducationTooltipController(testableContext, mockViewContainerFactory)
+        DesktopWindowingEducationTooltipController(
+            testableContext, mockViewContainerFactory, mockDisplayController)
   }
 
   @Test
@@ -218,6 +227,25 @@
     verify(mockLambda).invoke()
   }
 
+  @Test
+  fun showEducationTooltip_displayRotationChanged_hidesTooltip() {
+    whenever(
+            mockViewContainerFactory.create(any(), any(), any(), any(), any(), any(), any(), any()))
+        .thenReturn(mockPopupWindow)
+    val tooltipViewConfig = createTooltipConfig()
+
+    tooltipController.showEducationTooltip(tooltipViewConfig = tooltipViewConfig, taskId = 123)
+    tooltipController.onDisplayChange(
+        /* displayId= */ 123,
+        /* fromRotation= */ ROTATION_90,
+        /* toRotation= */ ROTATION_180,
+        /* newDisplayAreaInfo= */ null,
+        WindowContainerTransaction())
+
+    verify(mockPopupWindow, times(1)).releaseView()
+    verify(mockDisplayController, atLeastOnce()).removeDisplayChangingController(any())
+  }
+
   private fun createTooltipConfig(
       @LayoutRes tooltipViewLayout: Int = R.layout.desktop_windowing_education_top_arrow_tooltip,
       tooltipViewGlobalCoordinates: Point = Point(0, 0),
diff --git a/libs/appfunctions/api/current.txt b/libs/appfunctions/api/current.txt
index bb0fc41..bc269fe 100644
--- a/libs/appfunctions/api/current.txt
+++ b/libs/appfunctions/api/current.txt
@@ -16,7 +16,7 @@
     ctor public AppFunctionService();
     method @NonNull public final android.os.IBinder onBind(@Nullable android.content.Intent);
     method @MainThread public void onExecuteFunction(@NonNull com.google.android.appfunctions.sidecar.ExecuteAppFunctionRequest, @NonNull android.os.CancellationSignal, @NonNull java.util.function.Consumer<com.google.android.appfunctions.sidecar.ExecuteAppFunctionResponse>);
-    method @Deprecated @MainThread public abstract void onExecuteFunction(@NonNull com.google.android.appfunctions.sidecar.ExecuteAppFunctionRequest, @NonNull java.util.function.Consumer<com.google.android.appfunctions.sidecar.ExecuteAppFunctionResponse>);
+    method @Deprecated @MainThread public void onExecuteFunction(@NonNull com.google.android.appfunctions.sidecar.ExecuteAppFunctionRequest, @NonNull java.util.function.Consumer<com.google.android.appfunctions.sidecar.ExecuteAppFunctionResponse>);
     field @NonNull public static final String BIND_APP_FUNCTION_SERVICE = "android.permission.BIND_APP_FUNCTION_SERVICE";
     field @NonNull public static final String SERVICE_INTERFACE = "android.app.appfunctions.AppFunctionService";
   }
@@ -45,12 +45,12 @@
     method @NonNull public static com.google.android.appfunctions.sidecar.ExecuteAppFunctionResponse newSuccess(@NonNull android.app.appsearch.GenericDocument, @Nullable android.os.Bundle);
     field public static final String PROPERTY_RETURN_VALUE = "returnValue";
     field public static final int RESULT_APP_UNKNOWN_ERROR = 2; // 0x2
+    field public static final int RESULT_CANCELLED = 6; // 0x6
     field public static final int RESULT_DENIED = 1; // 0x1
-    field public static final int RESULT_DISABLED = 6; // 0x6
+    field public static final int RESULT_DISABLED = 5; // 0x5
     field public static final int RESULT_INTERNAL_ERROR = 3; // 0x3
     field public static final int RESULT_INVALID_ARGUMENT = 4; // 0x4
     field public static final int RESULT_OK = 0; // 0x0
-    field public static final int RESULT_TIMED_OUT = 5; // 0x5
   }
 
 }
diff --git a/libs/appfunctions/java/com/google/android/appfunctions/sidecar/AppFunctionService.java b/libs/appfunctions/java/com/google/android/appfunctions/sidecar/AppFunctionService.java
index 6023c97..6e91de6 100644
--- a/libs/appfunctions/java/com/google/android/appfunctions/sidecar/AppFunctionService.java
+++ b/libs/appfunctions/java/com/google/android/appfunctions/sidecar/AppFunctionService.java
@@ -26,6 +26,7 @@
 import android.os.Binder;
 import android.os.IBinder;
 import android.os.CancellationSignal;
+import android.util.Log;
 
 import java.util.function.Consumer;
 
@@ -143,7 +144,11 @@
      */
     @MainThread
     @Deprecated
-    public abstract void onExecuteFunction(
+    public void onExecuteFunction(
             @NonNull ExecuteAppFunctionRequest request,
-            @NonNull Consumer<ExecuteAppFunctionResponse> callback);
+            @NonNull Consumer<ExecuteAppFunctionResponse> callback) {
+        Log.w(
+                "AppFunctionService",
+                "Calling deprecated default implementation of onExecuteFunction");
+    }
 }
diff --git a/libs/appfunctions/java/com/google/android/appfunctions/sidecar/ExecuteAppFunctionResponse.java b/libs/appfunctions/java/com/google/android/appfunctions/sidecar/ExecuteAppFunctionResponse.java
index c7ce95b..d87fec79 100644
--- a/libs/appfunctions/java/com/google/android/appfunctions/sidecar/ExecuteAppFunctionResponse.java
+++ b/libs/appfunctions/java/com/google/android/appfunctions/sidecar/ExecuteAppFunctionResponse.java
@@ -73,11 +73,14 @@
      */
     public static final int RESULT_INVALID_ARGUMENT = 4;
 
-    /** The operation was timed out. */
-    public static final int RESULT_TIMED_OUT = 5;
-
     /** The caller tried to execute a disabled app function. */
-    public static final int RESULT_DISABLED = 6;
+    public static final int RESULT_DISABLED = 5;
+
+    /**
+     * The operation was cancelled. Use this error code to report that a cancellation is done after
+     * receiving a cancellation signal.
+     */
+    public static final int RESULT_CANCELLED = 6;
 
     /** The result code of the app function execution. */
     @ResultCode private final int mResultCode;
@@ -236,7 +239,6 @@
                     RESULT_APP_UNKNOWN_ERROR,
                     RESULT_INTERNAL_ERROR,
                     RESULT_INVALID_ARGUMENT,
-                    RESULT_TIMED_OUT,
                     RESULT_DISABLED
             })
     @Retention(RetentionPolicy.SOURCE)
diff --git a/libs/hwui/AutoBackendTextureRelease.cpp b/libs/hwui/AutoBackendTextureRelease.cpp
index 27add35..4305196 100644
--- a/libs/hwui/AutoBackendTextureRelease.cpp
+++ b/libs/hwui/AutoBackendTextureRelease.cpp
@@ -141,6 +141,13 @@
         return;
     }
 
+    if (!RenderThread::isCurrent()) {
+        // releaseQueueOwnership needs to run on RenderThread to prevent multithread calling
+        // setBackendTextureState will operate skia resource cache which need single owner
+        RenderThread::getInstance().queue().post([this, context]() { releaseQueueOwnership(context); });
+        return;
+    }
+
     LOG_ALWAYS_FATAL_IF(Properties::getRenderPipelineType() != RenderPipelineType::SkiaVulkan);
     if (mBackendTexture.isValid()) {
         // Passing in VK_IMAGE_LAYOUT_UNDEFINED means we keep the old layout.
diff --git a/libs/hwui/Gainmap.cpp b/libs/hwui/Gainmap.cpp
index 30f401e..ea955e2 100644
--- a/libs/hwui/Gainmap.cpp
+++ b/libs/hwui/Gainmap.cpp
@@ -15,12 +15,37 @@
  */
 #include "Gainmap.h"
 
+#include <SkBitmap.h>
+#include <SkCanvas.h>
+#include <SkColorFilter.h>
+#include <SkImagePriv.h>
+#include <SkPaint.h>
+
+#include "HardwareBitmapUploader.h"
+
 namespace android::uirenderer {
 
 sp<Gainmap> Gainmap::allocateHardwareGainmap(const sp<Gainmap>& srcGainmap) {
     auto gainmap = sp<Gainmap>::make();
     gainmap->info = srcGainmap->info;
-    const SkBitmap skSrcBitmap = srcGainmap->bitmap->getSkBitmap();
+    SkBitmap skSrcBitmap = srcGainmap->bitmap->getSkBitmap();
+    if (skSrcBitmap.info().colorType() == kAlpha_8_SkColorType &&
+        !HardwareBitmapUploader::hasAlpha8Support()) {
+        // The regular Bitmap::allocateHardwareBitmap will do a conversion that preserves channels,
+        // so alpha8 maps to the alpha channel of rgba. However, for gainmaps we will interpret
+        // the data of an rgba buffer differently as we'll only look at the rgb channels
+        // So we need to map alpha8 to rgbx_8888 essentially
+        SkBitmap bitmap;
+        bitmap.allocPixels(skSrcBitmap.info().makeColorType(kN32_SkColorType));
+        SkCanvas canvas(bitmap);
+        SkPaint paint;
+        const float alphaToOpaque[] = {0, 0, 0, 1, 0, 0, 0, 0, 1, 0,
+                                       0, 0, 0, 1, 0, 0, 0, 0, 0, 255};
+        paint.setColorFilter(SkColorFilters::Matrix(alphaToOpaque, SkColorFilters::Clamp::kNo));
+        canvas.drawImage(SkMakeImageFromRasterBitmap(skSrcBitmap, kNever_SkCopyPixelsMode), 0, 0,
+                         SkSamplingOptions{}, &paint);
+        skSrcBitmap = bitmap;
+    }
     sk_sp<Bitmap> skBitmap(Bitmap::allocateHardwareBitmap(skSrcBitmap));
     if (!skBitmap.get()) {
         return nullptr;
diff --git a/libs/hwui/hwui/Paint.h b/libs/hwui/hwui/Paint.h
index 708f96e..7eb849f 100644
--- a/libs/hwui/hwui/Paint.h
+++ b/libs/hwui/hwui/Paint.h
@@ -159,6 +159,14 @@
         return SkSamplingOptions(this->filterMode());
     }
 
+    void setVariationOverride(minikin::VariationSettings&& varSettings) {
+        mFontVariationOverride = std::move(varSettings);
+    }
+
+    const minikin::VariationSettings& getFontVariationOverride() const {
+        return mFontVariationOverride;
+    }
+
     // The Java flags (Paint.java) no longer fit into the native apis directly.
     // These methods handle converting to and from them and the native representations
     // in android::Paint.
@@ -179,6 +187,7 @@
     float mLetterSpacing = 0;
     float mWordSpacing = 0;
     std::vector<minikin::FontFeature> mFontFeatureSettings;
+    minikin::VariationSettings mFontVariationOverride;
     uint32_t mMinikinLocaleListId;
     std::optional<minikin::FamilyVariant> mFamilyVariant;
     uint32_t mHyphenEdit = 0;
diff --git a/libs/hwui/hwui/PaintImpl.cpp b/libs/hwui/hwui/PaintImpl.cpp
index c32ea01..6dfcedc 100644
--- a/libs/hwui/hwui/PaintImpl.cpp
+++ b/libs/hwui/hwui/PaintImpl.cpp
@@ -39,6 +39,7 @@
         , mLetterSpacing(paint.mLetterSpacing)
         , mWordSpacing(paint.mWordSpacing)
         , mFontFeatureSettings(paint.mFontFeatureSettings)
+        , mFontVariationOverride(paint.mFontVariationOverride)
         , mMinikinLocaleListId(paint.mMinikinLocaleListId)
         , mFamilyVariant(paint.mFamilyVariant)
         , mHyphenEdit(paint.mHyphenEdit)
@@ -59,6 +60,7 @@
     mLetterSpacing = other.mLetterSpacing;
     mWordSpacing = other.mWordSpacing;
     mFontFeatureSettings = other.mFontFeatureSettings;
+    mFontVariationOverride = other.mFontVariationOverride;
     mMinikinLocaleListId = other.mMinikinLocaleListId;
     mFamilyVariant = other.mFamilyVariant;
     mHyphenEdit = other.mHyphenEdit;
@@ -76,6 +78,7 @@
     return static_cast<const SkPaint&>(a) == static_cast<const SkPaint&>(b) && a.mFont == b.mFont &&
            a.mLooper == b.mLooper && a.mLetterSpacing == b.mLetterSpacing &&
            a.mWordSpacing == b.mWordSpacing && a.mFontFeatureSettings == b.mFontFeatureSettings &&
+           a.mFontVariationOverride == b.mFontVariationOverride &&
            a.mMinikinLocaleListId == b.mMinikinLocaleListId &&
            a.mFamilyVariant == b.mFamilyVariant && a.mHyphenEdit == b.mHyphenEdit &&
            a.mTypeface == b.mTypeface && a.mAlign == b.mAlign &&
diff --git a/libs/hwui/jni/Paint.cpp b/libs/hwui/jni/Paint.cpp
index 286f06a..da23792 100644
--- a/libs/hwui/jni/Paint.cpp
+++ b/libs/hwui/jni/Paint.cpp
@@ -1127,6 +1127,36 @@
         return leftMinikinPaint == rightMinikinPaint;
     }
 
+    struct VariationBuilder {
+        std::vector<minikin::FontVariation> varSettings;
+    };
+
+    static jlong createFontVariationBuilder(CRITICAL_JNI_PARAMS_COMMA jint size) {
+        VariationBuilder* builder = new VariationBuilder();
+        builder->varSettings.reserve(size);
+        return reinterpret_cast<jlong>(builder);
+    }
+
+    static void addFontVariationToBuilder(CRITICAL_JNI_PARAMS_COMMA jlong builderPtr, jint tag,
+                                          jfloat value) {
+        VariationBuilder* builder = reinterpret_cast<VariationBuilder*>(builderPtr);
+        builder->varSettings.emplace_back(static_cast<minikin::AxisTag>(tag), value);
+    }
+
+    static void setFontVariationOverride(CRITICAL_JNI_PARAMS_COMMA jlong paintHandle,
+                                         jlong builderPtr) {
+        Paint* paint = reinterpret_cast<Paint*>(paintHandle);
+        if (builderPtr == 0) {
+            paint->setVariationOverride(minikin::VariationSettings());
+            return;
+        }
+
+        VariationBuilder* builder = reinterpret_cast<VariationBuilder*>(builderPtr);
+        paint->setVariationOverride(
+                minikin::VariationSettings(builder->varSettings, false /* sorted */));
+        delete builder;
+    }
+
 }; // namespace PaintGlue
 
 static const JNINativeMethod methods[] = {
@@ -1235,6 +1265,9 @@
         {"nSetShadowLayer", "(JFFFJJ)V", (void*)PaintGlue::setShadowLayer},
         {"nHasShadowLayer", "(J)Z", (void*)PaintGlue::hasShadowLayer},
         {"nEqualsForTextMeasurement", "(JJ)Z", (void*)PaintGlue::equalsForTextMeasurement},
+        {"nCreateFontVariationBuilder", "(I)J", (void*)PaintGlue::createFontVariationBuilder},
+        {"nAddFontVariationToBuilder", "(JIF)V", (void*)PaintGlue::addFontVariationToBuilder},
+        {"nSetFontVariationOverride", "(JJ)V", (void*)PaintGlue::setFontVariationOverride},
 };
 
 int register_android_graphics_Paint(JNIEnv* env) {
diff --git a/media/java/android/media/RingtoneManager.java b/media/java/android/media/RingtoneManager.java
index f0ab6ec..0f24654 100644
--- a/media/java/android/media/RingtoneManager.java
+++ b/media/java/android/media/RingtoneManager.java
@@ -1089,7 +1089,24 @@
         defaultRingtoneUri = ContentProvider.getUriWithoutUserId(defaultRingtoneUri);
         if (defaultRingtoneUri == null) {
             return -1;
-        } else if (defaultRingtoneUri.equals(Settings.System.DEFAULT_RINGTONE_URI)) {
+        }
+
+        if (Flags.enableRingtoneHapticsCustomization()
+                && Utils.hasVibration(defaultRingtoneUri)) {
+            // skip to check TYPE_ALARM because the customized haptic hasn't enabled in alarm
+            if (defaultRingtoneUri.toString()
+                    .contains(Settings.System.DEFAULT_RINGTONE_URI.toString())) {
+                return TYPE_RINGTONE;
+            } else if (defaultRingtoneUri.toString()
+                    .contains(Settings.System.DEFAULT_NOTIFICATION_URI.toString())) {
+                return TYPE_NOTIFICATION;
+            } else if (defaultRingtoneUri.toString()
+                    .contains(Settings.System.DEFAULT_ALARM_ALERT_URI.toString())) {
+                return TYPE_ALARM;
+            }
+        }
+
+        if (defaultRingtoneUri.equals(Settings.System.DEFAULT_RINGTONE_URI)) {
             return TYPE_RINGTONE;
         } else if (defaultRingtoneUri.equals(Settings.System.DEFAULT_NOTIFICATION_URI)) {
             return TYPE_NOTIFICATION;
diff --git a/media/java/android/media/RoutingSessionInfo.java b/media/java/android/media/RoutingSessionInfo.java
index 9899e4e..83a4dd5 100644
--- a/media/java/android/media/RoutingSessionInfo.java
+++ b/media/java/android/media/RoutingSessionInfo.java
@@ -22,7 +22,6 @@
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.content.res.Resources;
 import android.os.Bundle;
 import android.os.Parcel;
 import android.os.Parcelable;
@@ -57,8 +56,6 @@
                 }
             };
 
-    private static final String TAG = "RoutingSessionInfo";
-
     private static final String KEY_GROUP_ROUTE = "androidx.mediarouter.media.KEY_GROUP_ROUTE";
     private static final String KEY_VOLUME_HANDLING = "volumeHandling";
 
@@ -142,15 +139,7 @@
         mVolume = builder.mVolume;
 
         mIsSystemSession = builder.mIsSystemSession;
-
-        boolean volumeAdjustmentForRemoteGroupSessions = Resources.getSystem().getBoolean(
-                com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions);
-        mVolumeHandling =
-                defineVolumeHandling(
-                        mIsSystemSession,
-                        builder.mVolumeHandling,
-                        mSelectedRoutes,
-                        volumeAdjustmentForRemoteGroupSessions);
+        mVolumeHandling = builder.mVolumeHandling;
 
         mControlHints = updateVolumeHandlingInHints(builder.mControlHints, mVolumeHandling);
         mTransferReason = builder.mTransferReason;
@@ -207,20 +196,6 @@
         return controlHints;
     }
 
-    @MediaRoute2Info.PlaybackVolume
-    private static int defineVolumeHandling(
-            boolean isSystemSession,
-            @MediaRoute2Info.PlaybackVolume int volumeHandling,
-            List<String> selectedRoutes,
-            boolean volumeAdjustmentForRemoteGroupSessions) {
-        if (!isSystemSession
-                && !volumeAdjustmentForRemoteGroupSessions
-                && selectedRoutes.size() > 1) {
-            return MediaRoute2Info.PLAYBACK_VOLUME_FIXED;
-        }
-        return volumeHandling;
-    }
-
     @NonNull
     private static String ensureString(@Nullable String str) {
         return str != null ? str : "";
diff --git a/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java b/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java
index 3955ff0..5f5058d 100644
--- a/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java
+++ b/media/tests/MediaRouter/src/com/android/mediaroutertest/RoutingSessionInfoTest.java
@@ -18,8 +18,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import android.content.res.Resources;
-import android.media.MediaRoute2Info;
 import android.media.RoutingSessionInfo;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -95,24 +93,4 @@
         assertThat(sessionInfoWithProviderId2.getTransferableRoutes())
                 .isEqualTo(sessionInfoWithProviderId.getTransferableRoutes());
     }
-
-    @Test
-    public void testGetVolumeHandlingGroupSession() {
-        RoutingSessionInfo sessionInfo = new RoutingSessionInfo.Builder(
-                TEST_ID, TEST_CLIENT_PACKAGE_NAME)
-                .setName(TEST_NAME)
-                .addSelectedRoute(TEST_ROUTE_ID_0)
-                .addSelectedRoute(TEST_ROUTE_ID_2)
-                .setVolumeHandling(MediaRoute2Info.PLAYBACK_VOLUME_VARIABLE)
-                .build();
-
-        boolean volumeAdjustmentForRemoteGroupSessions = Resources.getSystem().getBoolean(
-                com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions);
-
-        int expectedResult = volumeAdjustmentForRemoteGroupSessions
-                ? MediaRoute2Info.PLAYBACK_VOLUME_VARIABLE :
-                MediaRoute2Info.PLAYBACK_VOLUME_FIXED;
-
-        assertThat(sessionInfo.getVolumeHandling()).isEqualTo(expectedResult);
-    }
 }
diff --git a/nfc/api/current.txt b/nfc/api/current.txt
index e7cb76c..96b7c13 100644
--- a/nfc/api/current.txt
+++ b/nfc/api/current.txt
@@ -223,6 +223,7 @@
     field public static final String CATEGORY_PAYMENT = "payment";
     field public static final String EXTRA_CATEGORY = "category";
     field public static final String EXTRA_SERVICE_COMPONENT = "component";
+    field @FlaggedApi("android.nfc.nfc_override_recover_routing_table") public static final int PROTOCOL_AND_TECHNOLOGY_ROUTE_DEFAULT = 3; // 0x3
     field @FlaggedApi("android.nfc.nfc_override_recover_routing_table") public static final int PROTOCOL_AND_TECHNOLOGY_ROUTE_DH = 0; // 0x0
     field @FlaggedApi("android.nfc.nfc_override_recover_routing_table") public static final int PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE = 1; // 0x1
     field @FlaggedApi("android.nfc.nfc_override_recover_routing_table") public static final int PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC = 2; // 0x2
diff --git a/nfc/api/system-current.txt b/nfc/api/system-current.txt
index 94231b0..4428ade 100644
--- a/nfc/api/system-current.txt
+++ b/nfc/api/system-current.txt
@@ -58,12 +58,16 @@
   @FlaggedApi("android.nfc.nfc_oem_extension") public final class NfcOemExtension {
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void clearPreference();
     method @FlaggedApi("android.nfc.nfc_oem_extension") @NonNull public java.util.List<java.lang.String> getActiveNfceeList();
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @NonNull @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public android.nfc.RoutingStatus getRoutingStatus();
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public boolean hasUserEnabledNfc();
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public boolean isAutoChangeEnabled();
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public boolean isTagPresent();
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void maybeTriggerFirmwareUpdate();
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void overwriteRoutingTable(int, int, int);
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void pausePolling(int);
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void registerCallback(@NonNull java.util.concurrent.Executor, @NonNull android.nfc.NfcOemExtension.Callback);
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void resumePolling();
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void setAutoChangeEnabled(boolean);
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.NFC_SET_CONTROLLER_ALWAYS_ON) public void setControllerAlwaysOnMode(int);
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void synchronizeScreenState();
     method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public void triggerInitialization();
@@ -90,7 +94,11 @@
     method public void onEnable(@NonNull java.util.function.Consumer<java.lang.Boolean>);
     method public void onEnableFinished(int);
     method public void onEnableStarted();
+    method public void onGetOemAppSearchIntent(@NonNull java.util.List<java.lang.String>, @NonNull java.util.function.Consumer<android.content.Intent>);
     method public void onHceEventReceived(int);
+    method public void onLaunchHceAppChooserActivity(@NonNull String, @NonNull java.util.List<android.nfc.cardemulation.ApduServiceInfo>, @NonNull android.content.ComponentName, @NonNull String);
+    method public void onLaunchHceTapAgainDialog(@NonNull android.nfc.cardemulation.ApduServiceInfo, @NonNull String);
+    method public void onNdefMessage(@NonNull android.nfc.Tag, @NonNull android.nfc.NdefMessage, @NonNull java.util.function.Consumer<java.lang.Boolean>);
     method public void onNdefRead(@NonNull java.util.function.Consumer<java.lang.Boolean>);
     method public void onReaderOptionChanged(boolean);
     method public void onRfDiscoveryStarted(boolean);
@@ -101,6 +109,12 @@
     method public void onTagDispatch(@NonNull java.util.function.Consumer<java.lang.Boolean>);
   }
 
+  @FlaggedApi("android.nfc.nfc_oem_extension") public class RoutingStatus {
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public int getDefaultIsoDepRoute();
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public int getDefaultOffHostRoute();
+    method @FlaggedApi("android.nfc.nfc_oem_extension") @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public int getDefaultRoute();
+  }
+
 }
 
 package android.nfc.cardemulation {
diff --git a/nfc/java/android/nfc/INfcCardEmulation.aidl b/nfc/java/android/nfc/INfcCardEmulation.aidl
index 19b9e0f..1eae3c6 100644
--- a/nfc/java/android/nfc/INfcCardEmulation.aidl
+++ b/nfc/java/android/nfc/INfcCardEmulation.aidl
@@ -51,4 +51,8 @@
     void overrideRoutingTable(int userHandle, String protocol, String technology, in String pkg);
     void recoverRoutingTable(int userHandle);
     boolean isEuiccSupported();
+    void setAutoChangeStatus(boolean state);
+    boolean isAutoChangeEnabled();
+    List<String> getRoutingStatus();
+    void overwriteRoutingTable(int userHandle, String emptyAid, String protocol, String tech);
 }
diff --git a/nfc/java/android/nfc/INfcOemExtensionCallback.aidl b/nfc/java/android/nfc/INfcOemExtensionCallback.aidl
index e49ef7e..48c7ee6 100644
--- a/nfc/java/android/nfc/INfcOemExtensionCallback.aidl
+++ b/nfc/java/android/nfc/INfcOemExtensionCallback.aidl
@@ -15,9 +15,14 @@
  */
 package android.nfc;
 
+import android.content.ComponentName;
+import android.nfc.cardemulation.ApduServiceInfo;
+import android.nfc.NdefMessage;
 import android.nfc.Tag;
 import android.os.ResultReceiver;
 
+import java.util.List;
+
 /**
  * @hide
  */
@@ -41,4 +46,8 @@
    void onCardEmulationActivated(boolean isActivated);
    void onRfFieldActivated(boolean isActivated);
    void onRfDiscoveryStarted(boolean isDiscoveryStarted);
+   void onGetOemAppSearchIntent(in List<String> firstPackage, in ResultReceiver intentConsumer);
+   void onNdefMessage(in Tag tag, in NdefMessage message, in ResultReceiver hasOemExecutableContent);
+   void onLaunchHceAppChooserActivity(in String selectedAid, in List<ApduServiceInfo> services, in ComponentName failedComponent, in String category);
+   void onLaunchHceTapAgainActivity(in ApduServiceInfo service, in String category);
 }
diff --git a/nfc/java/android/nfc/NfcOemExtension.java b/nfc/java/android/nfc/NfcOemExtension.java
index 6d5c069..fb63b5c 100644
--- a/nfc/java/android/nfc/NfcOemExtension.java
+++ b/nfc/java/android/nfc/NfcOemExtension.java
@@ -16,6 +16,12 @@
 
 package android.nfc;
 
+import static android.nfc.cardemulation.CardEmulation.PROTOCOL_AND_TECHNOLOGY_ROUTE_DH;
+import static android.nfc.cardemulation.CardEmulation.PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE;
+import static android.nfc.cardemulation.CardEmulation.PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC;
+import static android.nfc.cardemulation.CardEmulation.routeIntToString;
+
+import android.Manifest;
 import android.annotation.CallbackExecutor;
 import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
@@ -23,8 +29,14 @@
 import android.annotation.RequiresPermission;
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
+import android.content.ComponentName;
 import android.content.Context;
+import android.content.Intent;
+import android.nfc.cardemulation.ApduServiceInfo;
+import android.nfc.cardemulation.CardEmulation;
+import android.nfc.cardemulation.CardEmulation.ProtocolAndTechnologyRoute;
 import android.os.Binder;
+import android.os.Bundle;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
 import android.util.Log;
@@ -306,6 +318,60 @@
         * @param isDiscoveryStarted true, if RF discovery started, else RF state is Idle.
         */
         void onRfDiscoveryStarted(boolean isDiscoveryStarted);
+
+        /**
+         * Gets the intent to find the OEM package in the OEM App market. If the consumer returns
+         * {@code null} or a timeout occurs, the intent from the first available package will be
+         * used instead.
+         *
+         * @param packages the OEM packages name stored in the tag
+         * @param intentConsumer The {@link Consumer} to be completed.
+         *                       The {@link Consumer#accept(Object)} should be called with
+         *                       the Intent required.
+         *
+         */
+        void onGetOemAppSearchIntent(@NonNull List<String> packages,
+                                     @NonNull Consumer<Intent> intentConsumer);
+
+        /**
+         * Checks if the NDEF message contains any specific OEM package executable content
+         *
+         * @param tag        the {@link android.nfc.Tag Tag}
+         * @param message NDEF Message to read from tag
+         * @param hasOemExecutableContent The {@link Consumer} to be completed. If there is
+         *                                OEM package executable content, the
+         *                                {@link Consumer#accept(Object)} should be called with
+         *                                {@link Boolean#TRUE}, otherwise call with
+         *                                {@link Boolean#FALSE}.
+         */
+        void onNdefMessage(@NonNull Tag tag, @NonNull NdefMessage message,
+                           @NonNull Consumer<Boolean> hasOemExecutableContent);
+
+        /**
+         * Callback to indicate the app chooser activity should be launched for handling CE
+         * transaction. This is invoked for example when there are more than 1 app installed that
+         * can handle the HCE transaction. OEMs can launch the Activity based
+         * on their requirement.
+         *
+         * @param selectedAid the selected AID from APDU
+         * @param services {@link ApduServiceInfo} of the service triggering the activity
+         * @param failedComponent the component failed to be resolved
+         * @param category the category of the service
+         */
+        void onLaunchHceAppChooserActivity(@NonNull String selectedAid,
+                                           @NonNull List<ApduServiceInfo> services,
+                                           @NonNull ComponentName failedComponent,
+                                           @NonNull String category);
+
+        /**
+         * Callback to indicate tap again dialog should be launched for handling HCE transaction.
+         * This is invoked for example when a CE service needs the device to unlocked before
+         * handling the transaction. OEMs can launch the Activity based on their requirement.
+         *
+         * @param service {@link ApduServiceInfo} of the service triggering the dialog
+         * @param category the category of the service
+         */
+        void onLaunchHceTapAgainDialog(@NonNull ApduServiceInfo service, @NonNull String category);
     }
 
 
@@ -523,6 +589,85 @@
         NfcAdapter.callService(() -> NfcAdapter.sService.resumePolling());
     }
 
+    /**
+     * Set whether to enable auto routing change or not (enabled by default).
+     * If disabled, routing targets are limited to a single off-host destination.
+     *
+     * @param state status of auto routing change, true if enable, otherwise false
+     */
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
+    public void setAutoChangeEnabled(boolean state) {
+        NfcAdapter.callService(() ->
+                NfcAdapter.sCardEmulationService.setAutoChangeStatus(state));
+    }
+
+    /**
+     * Check if auto routing change is enabled or not.
+     *
+     * @return true if enabled, otherwise false
+     */
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
+    public boolean isAutoChangeEnabled() {
+        return NfcAdapter.callServiceReturn(() ->
+                NfcAdapter.sCardEmulationService.isAutoChangeEnabled(), false);
+    }
+
+    /**
+     * Get current routing status
+     *
+     * @return {@link RoutingStatus} indicating the default route, default ISO-DEP
+     * route and default off-host route.
+     */
+    @NonNull
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
+    public RoutingStatus getRoutingStatus() {
+        List<String> status = NfcAdapter.callServiceReturn(() ->
+                NfcAdapter.sCardEmulationService.getRoutingStatus(), new ArrayList<>());
+        return new RoutingStatus(routeStringToInt(status.get(0)),
+                routeStringToInt(status.get(1)),
+                routeStringToInt(status.get(2)));
+    }
+
+    /**
+     * Overwrites NFC controller routing table, which includes Protocol Route, Technology Route,
+     * and Empty AID Route.
+     *
+     * The parameter set to
+     * {@link ProtocolAndTechnologyRoute#PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET}
+     * can be used to keep current values for that entry. At least one route should be overridden
+     * when calling this API, otherwise throw {@link IllegalArgumentException}.
+     *
+     * @param protocol ISO-DEP route destination, where the possible inputs are defined in
+     *                 {@link ProtocolAndTechnologyRoute}.
+     * @param technology Tech-A, Tech-B and Tech-F route destination, where the possible inputs
+     *                   are defined in
+     *                   {@link ProtocolAndTechnologyRoute}
+     * @param emptyAid Zero-length AID route destination, where the possible inputs are defined in
+     *                 {@link ProtocolAndTechnologyRoute}
+     */
+    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    public void overwriteRoutingTable(
+            @CardEmulation.ProtocolAndTechnologyRoute int protocol,
+            @CardEmulation.ProtocolAndTechnologyRoute int technology,
+            @CardEmulation.ProtocolAndTechnologyRoute int emptyAid) {
+
+        String protocolRoute = routeIntToString(protocol);
+        String technologyRoute = routeIntToString(technology);
+        String emptyAidRoute = routeIntToString(emptyAid);
+
+        NfcAdapter.callService(() ->
+                NfcAdapter.sCardEmulationService.overwriteRoutingTable(
+                        mContext.getUser().getIdentifier(),
+                        emptyAidRoute,
+                        protocolRoute,
+                        technologyRoute
+                ));
+    }
+
     private final class NfcOemExtensionCallback extends INfcOemExtensionCallback.Stub {
 
         @Override
@@ -562,25 +707,25 @@
         public void onApplyRouting(ResultReceiver isSkipped) throws RemoteException {
             mCallbackMap.forEach((cb, ex) ->
                     handleVoidCallback(
-                        new ReceiverWrapper(isSkipped), cb::onApplyRouting, ex));
+                        new ReceiverWrapper<>(isSkipped), cb::onApplyRouting, ex));
         }
         @Override
         public void onNdefRead(ResultReceiver isSkipped) throws RemoteException {
             mCallbackMap.forEach((cb, ex) ->
                     handleVoidCallback(
-                        new ReceiverWrapper(isSkipped), cb::onNdefRead, ex));
+                        new ReceiverWrapper<>(isSkipped), cb::onNdefRead, ex));
         }
         @Override
         public void onEnable(ResultReceiver isAllowed) throws RemoteException {
             mCallbackMap.forEach((cb, ex) ->
                     handleVoidCallback(
-                        new ReceiverWrapper(isAllowed), cb::onEnable, ex));
+                        new ReceiverWrapper<>(isAllowed), cb::onEnable, ex));
         }
         @Override
         public void onDisable(ResultReceiver isAllowed) throws RemoteException {
             mCallbackMap.forEach((cb, ex) ->
                     handleVoidCallback(
-                        new ReceiverWrapper(isAllowed), cb::onDisable, ex));
+                        new ReceiverWrapper<>(isAllowed), cb::onDisable, ex));
         }
         @Override
         public void onBootStarted() throws RemoteException {
@@ -616,7 +761,7 @@
         public void onTagDispatch(ResultReceiver isSkipped) throws RemoteException {
             mCallbackMap.forEach((cb, ex) ->
                     handleVoidCallback(
-                        new ReceiverWrapper(isSkipped), cb::onTagDispatch, ex));
+                        new ReceiverWrapper<>(isSkipped), cb::onTagDispatch, ex));
         }
         @Override
         public void onRoutingChanged() throws RemoteException {
@@ -635,6 +780,59 @@
                     handleVoidCallback(enabled, cb::onReaderOptionChanged, ex));
         }
 
+        @Override
+        public void onGetOemAppSearchIntent(List<String> packages, ResultReceiver intentConsumer)
+                throws RemoteException {
+            mCallbackMap.forEach((cb, ex) ->
+                    handleVoid2ArgCallback(packages, new ReceiverWrapper<>(intentConsumer),
+                            cb::onGetOemAppSearchIntent, ex));
+        }
+
+        @Override
+        public void onNdefMessage(Tag tag, NdefMessage message,
+                                  ResultReceiver hasOemExecutableContent) throws RemoteException {
+            mCallbackMap.forEach((cb, ex) -> {
+                synchronized (mLock) {
+                    final long identity = Binder.clearCallingIdentity();
+                    try {
+                        ex.execute(() -> cb.onNdefMessage(
+                                tag, message, new ReceiverWrapper<>(hasOemExecutableContent)));
+                    } catch (RuntimeException exception) {
+                        throw exception;
+                    } finally {
+                        Binder.restoreCallingIdentity(identity);
+                    }
+                }
+            });
+        }
+
+        @Override
+        public void onLaunchHceAppChooserActivity(String selectedAid,
+                                                  List<ApduServiceInfo> services,
+                                                  ComponentName failedComponent, String category)
+                throws RemoteException {
+            mCallbackMap.forEach((cb, ex) -> {
+                synchronized (mLock) {
+                    final long identity = Binder.clearCallingIdentity();
+                    try {
+                        ex.execute(() -> cb.onLaunchHceAppChooserActivity(
+                                selectedAid, services, failedComponent, category));
+                    } catch (RuntimeException exception) {
+                        throw exception;
+                    } finally {
+                        Binder.restoreCallingIdentity(identity);
+                    }
+                }
+            });
+        }
+
+        @Override
+        public void onLaunchHceTapAgainActivity(ApduServiceInfo service, String category)
+                throws RemoteException {
+            mCallbackMap.forEach((cb, ex) ->
+                    handleVoid2ArgCallback(service, category, cb::onLaunchHceTapAgainDialog, ex));
+        }
+
         private <T> void handleVoidCallback(
                 T input, Consumer<T> callbackMethod, Executor executor) {
             synchronized (mLock) {
@@ -718,7 +916,16 @@
         }
     }
 
-    private class ReceiverWrapper implements Consumer<Boolean> {
+    private @CardEmulation.ProtocolAndTechnologyRoute int routeStringToInt(String route) {
+        return switch (route) {
+            case "DH" -> PROTOCOL_AND_TECHNOLOGY_ROUTE_DH;
+            case "eSE" -> PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE;
+            case "SIM" -> PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC;
+            default -> throw new IllegalStateException("Unexpected value: " + route);
+        };
+    }
+
+    private class ReceiverWrapper<T> implements Consumer<T> {
         private final ResultReceiver mResultReceiver;
 
         ReceiverWrapper(ResultReceiver resultReceiver) {
@@ -726,12 +933,19 @@
         }
 
         @Override
-        public void accept(Boolean result) {
-            mResultReceiver.send(result ? 1 : 0, null);
+        public void accept(T result) {
+            if (result instanceof Boolean) {
+                mResultReceiver.send((Boolean) result ? 1 : 0, null);
+            } else if (result instanceof Intent) {
+                Bundle bundle = new Bundle();
+                bundle.putParcelable("intent", (Intent) result);
+                mResultReceiver.send(0, bundle);
+            }
+
         }
 
         @Override
-        public Consumer<Boolean> andThen(Consumer<? super Boolean> after) {
+        public Consumer<T> andThen(Consumer<? super T> after) {
             return Consumer.super.andThen(after);
         }
     }
diff --git a/nfc/java/android/nfc/RoutingStatus.java b/nfc/java/android/nfc/RoutingStatus.java
new file mode 100644
index 0000000..4a1b1f3
--- /dev/null
+++ b/nfc/java/android/nfc/RoutingStatus.java
@@ -0,0 +1,79 @@
+/*
+ * 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 android.nfc;
+
+import android.annotation.FlaggedApi;
+import android.annotation.RequiresPermission;
+import android.annotation.SystemApi;
+import android.nfc.cardemulation.CardEmulation;
+
+/**
+ * A class indicating default route, ISO-DEP route and off-host route.
+ *
+ * @hide
+ */
+@SystemApi
+@FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+public class RoutingStatus {
+    private final @CardEmulation.ProtocolAndTechnologyRoute int mDefaultRoute;
+    private final @CardEmulation.ProtocolAndTechnologyRoute int mDefaultIsoDepRoute;
+    private final @CardEmulation.ProtocolAndTechnologyRoute int mDefaultOffHostRoute;
+
+    RoutingStatus(@CardEmulation.ProtocolAndTechnologyRoute int mDefaultRoute,
+                  @CardEmulation.ProtocolAndTechnologyRoute int mDefaultIsoDepRoute,
+                  @CardEmulation.ProtocolAndTechnologyRoute int mDefaultOffHostRoute) {
+        this.mDefaultRoute = mDefaultRoute;
+        this.mDefaultIsoDepRoute = mDefaultIsoDepRoute;
+        this.mDefaultOffHostRoute = mDefaultOffHostRoute;
+    }
+
+    /**
+     * Getter of the default route.
+     * @return an integer defined in
+     * {@link android.nfc.cardemulation.CardEmulation.ProtocolAndTechnologyRoute}
+     */
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS)
+    @CardEmulation.ProtocolAndTechnologyRoute
+    public int getDefaultRoute() {
+        return mDefaultRoute;
+    }
+
+    /**
+     * Getter of the default ISO-DEP route.
+     * @return an integer defined in
+     * {@link android.nfc.cardemulation.CardEmulation.ProtocolAndTechnologyRoute}
+     */
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS)
+    @CardEmulation.ProtocolAndTechnologyRoute
+    public int getDefaultIsoDepRoute() {
+        return mDefaultIsoDepRoute;
+    }
+
+    /**
+     * Getter of the default off-host route.
+     * @return an integer defined in
+     * {@link android.nfc.cardemulation.CardEmulation.ProtocolAndTechnologyRoute}
+     */
+    @FlaggedApi(Flags.FLAG_NFC_OEM_EXTENSION)
+    @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS)
+    @CardEmulation.ProtocolAndTechnologyRoute
+    public int getDefaultOffHostRoute() {
+        return mDefaultOffHostRoute;
+    }
+
+}
diff --git a/nfc/java/android/nfc/cardemulation/CardEmulation.java b/nfc/java/android/nfc/cardemulation/CardEmulation.java
index 4be082c..d8f04c5 100644
--- a/nfc/java/android/nfc/cardemulation/CardEmulation.java
+++ b/nfc/java/android/nfc/cardemulation/CardEmulation.java
@@ -168,6 +168,12 @@
     public static final int PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC = 2;
 
     /**
+     * Route to the default value in config file.
+     */
+    @FlaggedApi(Flags.FLAG_NFC_OVERRIDE_RECOVER_ROUTING_TABLE)
+    public static final int PROTOCOL_AND_TECHNOLOGY_ROUTE_DEFAULT = 3;
+
+    /**
      * Route unset.
      */
     @FlaggedApi(Flags.FLAG_NFC_OVERRIDE_RECOVER_ROUTING_TABLE)
@@ -895,45 +901,47 @@
                     PROTOCOL_AND_TECHNOLOGY_ROUTE_DH,
                     PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE,
                     PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC,
-                    PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET
+                    PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET,
+                    PROTOCOL_AND_TECHNOLOGY_ROUTE_DEFAULT
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface ProtocolAndTechnologyRoute {}
 
-     /**
-      * Setting NFC controller routing table, which includes Protocol Route and Technology Route,
-      * while this Activity is in the foreground.
-      *
-      * The parameter set to {@link #PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET}
-      * can be used to keep current values for that entry. Either
-      * Protocol Route or Technology Route should be override when calling this API, otherwise
-      * throw {@link IllegalArgumentException}.
-      * <p>
-      * Example usage in an Activity that requires to set proto route to "ESE" and keep tech route:
-      * <pre>
-      * protected void onResume() {
-      *     mNfcAdapter.overrideRoutingTable(
-      *         this, {@link #PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE}, null);
-      * }</pre>
-      * </p>
-      * Also activities must call {@link #recoverRoutingTable(Activity)}
-      * when it goes to the background. Only the package of the
-      * currently preferred service (the service set as preferred by the current foreground
-      * application via {@link CardEmulation#setPreferredService(Activity, ComponentName)} or the
-      * current Default Wallet Role Holder {@link RoleManager#ROLE_WALLET}),
-      * otherwise a call to this method will fail and throw {@link SecurityException}.
-      * @param activity The Activity that requests NFC controller routing table to be changed.
-      * @param protocol ISO-DEP route destination, where the possible inputs are defined
-      *                 in {@link ProtocolAndTechnologyRoute}.
-      * @param technology Tech-A, Tech-B and Tech-F route destination, where the possible inputs
-      *                   are defined in {@link ProtocolAndTechnologyRoute}
-      * @throws SecurityException if the caller is not the preferred NFC service
-      * @throws IllegalArgumentException if the activity is not resumed or the caller is not in the
-      * foreground.
-      * <p>
-      * This is a high risk API and only included to support mainline effort
-      * @hide
-      */
+    /**
+     * Setting NFC controller routing table, which includes Protocol Route and Technology Route,
+     * while this Activity is in the foreground.
+     *
+     * The parameter set to {@link #PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET}
+     * can be used to keep current values for that entry. Either
+     * Protocol Route or Technology Route should be override when calling this API, otherwise
+     * throw {@link IllegalArgumentException}.
+     * <p>
+     * Example usage in an Activity that requires to set proto route to "ESE" and keep tech route:
+     * <pre>
+     * protected void onResume() {
+     *     mNfcAdapter.overrideRoutingTable(
+     *         this, {@link #PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE},
+     *         {@link #PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET});
+     * }</pre>
+     * </p>
+     * Also activities must call {@link #recoverRoutingTable(Activity)}
+     * when it goes to the background. Only the package of the
+     * currently preferred service (the service set as preferred by the current foreground
+     * application via {@link CardEmulation#setPreferredService(Activity, ComponentName)} or the
+     * current Default Wallet Role Holder {@link RoleManager#ROLE_WALLET}),
+     * otherwise a call to this method will fail and throw {@link SecurityException}.
+     * @param activity The Activity that requests NFC controller routing table to be changed.
+     * @param protocol ISO-DEP route destination, where the possible inputs are defined
+     *                 in {@link ProtocolAndTechnologyRoute}.
+     * @param technology Tech-A, Tech-B and Tech-F route destination, where the possible inputs
+     *                   are defined in {@link ProtocolAndTechnologyRoute}
+     * @throws SecurityException if the caller is not the preferred NFC service
+     * @throws IllegalArgumentException if the activity is not resumed or the caller is not in the
+     * foreground.
+     * <p>
+     * This is a high risk API and only included to support mainline effort
+     * @hide
+     */
     @SystemApi
     @FlaggedApi(Flags.FLAG_NFC_OVERRIDE_RECOVER_ROUTING_TABLE)
     public void overrideRoutingTable(
@@ -942,26 +950,14 @@
         if (!activity.isResumed()) {
             throw new IllegalArgumentException("Activity must be resumed.");
         }
-        String protocolRoute = switch (protocol) {
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_DH -> "DH";
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE -> "ESE";
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC -> "UICC";
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET -> null;
-            default -> throw new IllegalStateException("Unexpected value: " + protocol);
-        };
-        String technologyRoute = switch (technology) {
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_DH -> "DH";
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE -> "ESE";
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC -> "UICC";
-            case PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET -> null;
-            default -> throw new IllegalStateException("Unexpected value: " + protocol);
-        };
+        String protocolRoute = routeIntToString(protocol);
+        String technologyRoute = routeIntToString(technology);
         callService(() ->
                 sService.overrideRoutingTable(
-                    mContext.getUser().getIdentifier(),
-                    protocolRoute,
-                    technologyRoute,
-                    mContext.getPackageName()));
+                        mContext.getUser().getIdentifier(),
+                        protocolRoute,
+                        technologyRoute,
+                        mContext.getPackageName()));
     }
 
     /**
@@ -1068,4 +1064,16 @@
         }
         return defaultReturn;
     }
+
+    /** @hide */
+    public static String routeIntToString(@ProtocolAndTechnologyRoute int route) {
+        return switch (route) {
+            case PROTOCOL_AND_TECHNOLOGY_ROUTE_DH -> "DH";
+            case PROTOCOL_AND_TECHNOLOGY_ROUTE_ESE -> "eSE";
+            case PROTOCOL_AND_TECHNOLOGY_ROUTE_UICC -> "SIM";
+            case PROTOCOL_AND_TECHNOLOGY_ROUTE_UNSET -> null;
+            case PROTOCOL_AND_TECHNOLOGY_ROUTE_DEFAULT -> "default";
+            default -> throw new IllegalStateException("Unexpected value: " + route);
+        };
+    }
 }
diff --git a/packages/CompanionDeviceManager/res/values/strings.xml b/packages/CompanionDeviceManager/res/values/strings.xml
index a57d6eb..b266912 100644
--- a/packages/CompanionDeviceManager/res/values/strings.xml
+++ b/packages/CompanionDeviceManager/res/values/strings.xml
@@ -50,13 +50,13 @@
     <!-- ================= DEVICE_PROFILE_APP_STREAMING ================= -->
 
     <!-- Confirmation for associating an application with a companion device of APP_STREAMING profile (type) [CHAR LIMIT=NONE] -->
-    <string name="title_app_streaming">Allow &lt;strong&gt;<xliff:g id="app_name" example="Exo">%1$s</xliff:g>&lt;/strong&gt; to stream your <xliff:g id="device_type" example="phone">%2$s</xliff:g>\u2019s apps to &lt;strong&gt;<xliff:g id="device_name" example="Chromebook">%3$s</xliff:g>&lt;/strong&gt;?</string>
+    <string name="title_app_streaming">Allow &lt;strong&gt;<xliff:g id="app_name" example="Exo">%1$s</xliff:g>&lt;/strong&gt; to stream your <xliff:g id="device_type" example="phone">%2$s</xliff:g>\u2019s apps and system features to &lt;strong&gt;<xliff:g id="device_name" example="Chromebook">%3$s</xliff:g>&lt;/strong&gt;?</string>
 
     <!-- Summary for associating an application with a companion device of APP_STREAMING profile [CHAR LIMIT=NONE] -->
-    <string name="summary_app_streaming"><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will have access to anything that’s visible or played on the <xliff:g id="device_type" example="phone">%2$s</xliff:g>, including audio, photos, passwords, and messages.&lt;br/>&lt;br/><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will be able to stream apps to <xliff:g id="device_name" example="Chromebook">%3$s</xliff:g> until you remove access to this permission.</string>
+    <string name="summary_app_streaming"><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will have access to anything that’s visible or played on your <xliff:g id="device_type" example="phone">%2$s</xliff:g>, including audio, photos, payment info, passwords, and messages.&lt;br/>&lt;br/><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will be able to stream apps to <xliff:g id="device_name" example="Chromebook">%3$s</xliff:g> until you remove access to this permission.</string>
 
     <!-- Description of the helper dialog for APP_STREAMING profile. [CHAR LIMIT=NONE] -->
-    <string name="helper_summary_app_streaming"><xliff:g id="app_name" example="GMS">%1$s</xliff:g> is requesting permission on behalf of your <xliff:g id="device_name" example="Chromebook">%2$s</xliff:g> to display and stream apps between your devices</string>
+    <string name="helper_summary_app_streaming"><xliff:g id="app_name" example="Exo">%1$s</xliff:g> is requesting permission on behalf of <xliff:g id="device_name" example="Chromebook">%2$s</xliff:g> to stream apps and system features from your <xliff:g id="device_type" example="phone">%3$s</xliff:g></string>
 
     <!-- ================= DEVICE_PROFILE_AUTOMOTIVE_PROJECTION ================= -->
 
@@ -80,13 +80,13 @@
     <!-- ================= DEVICE_PROFILE_NEARBY_DEVICE_STREAMING ================= -->
 
     <!-- Confirmation for associating an application with a companion device of NEARBY_DEVICE_STREAMING profile (type) [CHAR LIMIT=NONE] -->
-    <string name="title_nearby_device_streaming">Allow &lt;strong&gt;<xliff:g id="device_name" example="NearbyStreamer">%1$s</xliff:g>&lt;/strong&gt; to stream your <xliff:g id="device_type" example="phone">%2$s</xliff:g>\u2019s apps and system features to &lt;strong&gt;<xliff:g id="device_name" example="Chromebook">%3$s</xliff:g>&lt;/strong&gt;?</string>
+    <string name="title_nearby_device_streaming">Allow &lt;strong&gt;<xliff:g id="app_name" example="Exo">%1$s</xliff:g>&lt;/strong&gt; to stream your <xliff:g id="device_type" example="phone">%2$s</xliff:g>\u2019s apps to &lt;strong&gt;<xliff:g id="device_name" example="Chromebook">%3$s</xliff:g>&lt;/strong&gt;?</string>
 
     <!-- Summary for associating an application with a companion device of NEARBY_DEVICE_STREAMING profile [CHAR LIMIT=NONE] -->
-    <string name="summary_nearby_device_streaming"><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will have access to anything that’s visible or played on your <xliff:g id="device_type" example="phone">%2$s</xliff:g>, including audio, photos, payment info, passwords, and messages.&lt;br/>&lt;br/><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will be able to stream apps and system features to <xliff:g id="device_name" example="Chromebook">%3$s</xliff:g> until you remove access to this permission.</string>
+    <string name="summary_nearby_device_streaming"><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will have access to anything that’s visible or played on <xliff:g id="device_name" example="Chromebook">%3$s</xliff:g>, including audio, photos, payment info, passwords, and messages.&lt;br/>&lt;br/><xliff:g id="app_name" example="Exo">%1$s</xliff:g> will be able to stream apps to <xliff:g id="device_name" example="Chromebook">%3$s</xliff:g> until you remove access to this permission.</string>
 
     <!-- Description of the helper dialog for NEARBY_DEVICE_STREAMING profile. [CHAR LIMIT=NONE] -->
-    <string name="helper_summary_nearby_device_streaming"><xliff:g id="app_name" example="NearbyStreamerApp">%1$s</xliff:g> is requesting permission on behalf of your <xliff:g id="device_name" example="NearbyDevice">%2$s</xliff:g> to stream apps and other system features between your devices</string>
+    <string name="helper_summary_nearby_device_streaming"><xliff:g id="app_name" example="Exo">%1$s</xliff:g> is requesting permission on behalf of <xliff:g id="device_name" example="Chromebook">%2$s</xliff:g> to stream apps from your <xliff:g id="device_type" example="phone">%3$s</xliff:g></string>
 
     <!-- ================= null profile ================= -->
 
diff --git a/packages/PackageInstaller/TEST_MAPPING b/packages/PackageInstaller/TEST_MAPPING
index 91882fd..50db501 100644
--- a/packages/PackageInstaller/TEST_MAPPING
+++ b/packages/PackageInstaller/TEST_MAPPING
@@ -1,6 +1,17 @@
 {
   "presubmit": [
     {
+      "name": "CtsPackageInstallerCUJDeviceAdminTestCases",
+      "options":[
+        {
+          "exclude-annotation":"androidx.test.filters.FlakyTest"
+        },
+        {
+          "exclude-annotation":"org.junit.Ignore"
+        }
+      ]
+    },
+    {
       "name": "CtsPackageInstallerCUJInstallationTestCases",
       "options":[
         {
@@ -12,6 +23,17 @@
       ]
     },
     {
+      "name": "CtsPackageInstallerCUJMultiUsersTestCases",
+      "options":[
+        {
+          "exclude-annotation":"androidx.test.filters.FlakyTest"
+        },
+        {
+          "exclude-annotation":"org.junit.Ignore"
+        }
+      ]
+    },
+    {
       "name": "CtsPackageInstallerCUJUninstallationTestCases",
       "options":[
         {
diff --git a/packages/SettingsLib/ButtonPreference/res/values-v35/attrs_expressive.xml b/packages/SettingsLib/ButtonPreference/res/values-v35/attrs_expressive.xml
deleted file mode 100644
index a1761e5..0000000
--- a/packages/SettingsLib/ButtonPreference/res/values-v35/attrs_expressive.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-<?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.
--->
-
-<resources>
-    <declare-styleable name="ButtonPreference">
-        <attr name="buttonType" format="enum">
-            <enum name="filled" value="0"/>
-            <enum name="tonal" value="1"/>
-            <enum name="outline" value="2"/>
-        </attr>
-        <attr name="buttonSize" format="enum">
-            <enum name="normal" value="0"/>
-            <enum name="large" value="1"/>
-            <enum name="extra" value="2"/>
-        </attr>
-    </declare-styleable>
-</resources>
\ No newline at end of file
diff --git a/packages/SettingsLib/ButtonPreference/res/values/attrs.xml b/packages/SettingsLib/ButtonPreference/res/values/attrs.xml
index 9c1e503f..970eeb2 100644
--- a/packages/SettingsLib/ButtonPreference/res/values/attrs.xml
+++ b/packages/SettingsLib/ButtonPreference/res/values/attrs.xml
@@ -18,12 +18,12 @@
 <resources>
     <declare-styleable name="ButtonPreference">
         <attr name="android:gravity" />
-        <attr name="buttonType" format="enum">
+        <attr name="buttonPreferenceType" format="enum">
             <enum name="filled" value="0"/>
             <enum name="tonal" value="1"/>
             <enum name="outline" value="2"/>
         </attr>
-        <attr name="buttonSize" format="enum">
+        <attr name="buttonPreferenceSize" format="enum">
             <enum name="normal" value="0"/>
             <enum name="large" value="1"/>
             <enum name="extra" value="2"/>
diff --git a/packages/SettingsLib/ButtonPreference/src/com/android/settingslib/widget/ButtonPreference.java b/packages/SettingsLib/ButtonPreference/src/com/android/settingslib/widget/ButtonPreference.java
index 0041eb2..979ff96 100644
--- a/packages/SettingsLib/ButtonPreference/src/com/android/settingslib/widget/ButtonPreference.java
+++ b/packages/SettingsLib/ButtonPreference/src/com/android/settingslib/widget/ButtonPreference.java
@@ -137,8 +137,8 @@
             mGravity = a.getInt(R.styleable.ButtonPreference_android_gravity, Gravity.START);
 
             if (SettingsThemeHelper.isExpressiveTheme(context)) {
-                int type = a.getInt(R.styleable.ButtonPreference_buttonType, 0);
-                int size = a.getInt(R.styleable.ButtonPreference_buttonSize, 0);
+                int type = a.getInt(R.styleable.ButtonPreference_buttonPreferenceType, 0);
+                int size = a.getInt(R.styleable.ButtonPreference_buttonPreferenceSize, 0);
                 resId = ButtonStyle.getLayoutId(type, size);
             }
             a.recycle();
diff --git a/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java b/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java
index 34de5c4..e6726dc 100644
--- a/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java
+++ b/packages/SettingsLib/SelectorWithWidgetPreference/src/com/android/settingslib/widget/SelectorWithWidgetPreference.java
@@ -68,6 +68,7 @@
 
     private View mExtraWidgetContainer;
     private ImageView mExtraWidget;
+    @Nullable private String mExtraWidgetContentDescription;
     private boolean mIsCheckBox = false;  // whether to display this button as a checkbox
 
     private View.OnClickListener mExtraWidgetOnClickListener;
@@ -173,6 +174,12 @@
 
         setExtraWidgetOnClickListener(mExtraWidgetOnClickListener);
 
+        if (mExtraWidget != null) {
+            mExtraWidget.setContentDescription(mExtraWidgetContentDescription != null
+                    ? mExtraWidgetContentDescription
+                    : getContext().getString(R.string.settings_label));
+        }
+
         if (Flags.allowSetTitleMaxLines()) {
             TextView title = (TextView) holder.findViewById(android.R.id.title);
             title.setMaxLines(mTitleMaxLines);
@@ -210,6 +217,17 @@
     }
 
     /**
+     * Sets the content description of the extra widget. If {@code null}, a default content
+     * description will be used ("Settings").
+     */
+    public void setExtraWidgetContentDescription(@Nullable String contentDescription) {
+        if (!TextUtils.equals(mExtraWidgetContentDescription, contentDescription)) {
+            mExtraWidgetContentDescription = contentDescription;
+            notifyChanged();
+        }
+    }
+
+    /**
      * Returns whether this preference is a checkbox.
      */
     public boolean isCheckBox() {
diff --git a/packages/SettingsLib/res/values-en-rGB/strings.xml b/packages/SettingsLib/res/values-en-rGB/strings.xml
index d4e01de..2fd84f3 100644
--- a/packages/SettingsLib/res/values-en-rGB/strings.xml
+++ b/packages/SettingsLib/res/values-en-rGB/strings.xml
@@ -585,7 +585,6 @@
     <string name="media_transfer_this_device_name_desktop" msgid="7912386128141470452">"This computer (internal)"</string>
     <!-- no translation found for media_transfer_this_device_name_tv (5285685336836896535) -->
     <skip />
-    <string name="media_transfer_internal_mic" msgid="797333824290228595">"Microphone (internal)"</string>
     <string name="media_transfer_dock_speaker_device_name" msgid="2856219597113881950">"Dock speaker"</string>
     <string name="media_transfer_external_device_name" msgid="2588672258721846418">"External device"</string>
     <string name="media_transfer_default_device_name" msgid="4315604017399871828">"Connected device"</string>
@@ -688,9 +687,11 @@
     <string name="cached_apps_freezer_reboot_dialog_text" msgid="695330563489230096">"Your device must be rebooted for this change to apply. Reboot now or cancel."</string>
     <string name="media_transfer_wired_headphone_name" msgid="8698668536022665254">"Wired headphones"</string>
     <string name="media_transfer_headphone_name" msgid="1131962659136578852">"Headphone"</string>
-    <string name="media_transfer_usb_speaker_name" msgid="4736537022543593896">"USB speaker"</string>
+    <!-- no translation found for media_transfer_usb_audio_name (1789292056757821355) -->
+    <skip />
     <string name="media_transfer_wired_device_mic_name" msgid="7417067197803840965">"Mic jack"</string>
-    <string name="media_transfer_usb_device_mic_name" msgid="9189914846215516322">"USB mic"</string>
+    <!-- no translation found for media_transfer_usb_device_mic_name (7171789543226269822) -->
+    <skip />
     <string name="wifi_hotspot_switch_on_text" msgid="9212273118217786155">"On"</string>
     <string name="wifi_hotspot_switch_off_text" msgid="7245567251496959764">"Off"</string>
     <string name="carrier_network_change_mode" msgid="4257621815706644026">"Operator network changing"</string>
diff --git a/packages/SettingsLib/res/values-pt-rBR/strings.xml b/packages/SettingsLib/res/values-pt-rBR/strings.xml
index 7f5bb0f..e286643 100644
--- a/packages/SettingsLib/res/values-pt-rBR/strings.xml
+++ b/packages/SettingsLib/res/values-pt-rBR/strings.xml
@@ -585,7 +585,6 @@
     <string name="media_transfer_this_device_name_desktop" msgid="7912386128141470452">"Este computador (interno)"</string>
     <!-- no translation found for media_transfer_this_device_name_tv (5285685336836896535) -->
     <skip />
-    <string name="media_transfer_internal_mic" msgid="797333824290228595">"Microfone (interno)"</string>
     <string name="media_transfer_dock_speaker_device_name" msgid="2856219597113881950">"Alto-falante da base"</string>
     <string name="media_transfer_external_device_name" msgid="2588672258721846418">"Dispositivo externo"</string>
     <string name="media_transfer_default_device_name" msgid="4315604017399871828">"Dispositivo conectado"</string>
@@ -688,9 +687,11 @@
     <string name="cached_apps_freezer_reboot_dialog_text" msgid="695330563489230096">"É necessário reinicializar o dispositivo para que a mudança seja aplicada. Faça isso agora ou cancele."</string>
     <string name="media_transfer_wired_headphone_name" msgid="8698668536022665254">"Fones de ouvido com fio"</string>
     <string name="media_transfer_headphone_name" msgid="1131962659136578852">"Fone de ouvido"</string>
-    <string name="media_transfer_usb_speaker_name" msgid="4736537022543593896">"Alto-falante USB"</string>
+    <!-- no translation found for media_transfer_usb_audio_name (1789292056757821355) -->
+    <skip />
     <string name="media_transfer_wired_device_mic_name" msgid="7417067197803840965">"Entrada para microfone"</string>
-    <string name="media_transfer_usb_device_mic_name" msgid="9189914846215516322">"Microfone USB"</string>
+    <!-- no translation found for media_transfer_usb_device_mic_name (7171789543226269822) -->
+    <skip />
     <string name="wifi_hotspot_switch_on_text" msgid="9212273118217786155">"Ativado"</string>
     <string name="wifi_hotspot_switch_off_text" msgid="7245567251496959764">"Desativado"</string>
     <string name="carrier_network_change_mode" msgid="4257621815706644026">"Alteração de rede da operadora"</string>
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java
index 243ce85..2b8b3b7 100644
--- a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/widget/SelectorWithWidgetPreferenceTest.java
@@ -19,8 +19,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertEquals;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
 
 import android.app.Application;
 import android.platform.test.annotations.DisableFlags;
@@ -68,7 +66,7 @@
         mPreference = new SelectorWithWidgetPreference(mContext);
 
         View view = LayoutInflater.from(mContext)
-                .inflate(R.layout.preference_selector_with_widget, null /* root */);
+                .inflate(mPreference.getLayoutResource(), null /* root */);
         PreferenceViewHolder preferenceViewHolder =
                 PreferenceViewHolder.createInstanceForTests(view);
         mPreference.onBindViewHolder(preferenceViewHolder);
@@ -104,28 +102,28 @@
     @Test
     public void onBindViewHolder_withSummary_containerShouldBeVisible() {
         mPreference.setSummary("some summary");
-        View summaryContainer = new View(mContext);
-        View view = mock(View.class);
-        when(view.findViewById(R.id.summary_container)).thenReturn(summaryContainer);
+        View view = LayoutInflater.from(mContext)
+                .inflate(mPreference.getLayoutResource(), null /* root */);
         PreferenceViewHolder preferenceViewHolder =
                 PreferenceViewHolder.createInstanceForTests(view);
 
         mPreference.onBindViewHolder(preferenceViewHolder);
 
+        View summaryContainer = view.findViewById(R.id.summary_container);
         assertEquals(View.VISIBLE, summaryContainer.getVisibility());
     }
 
     @Test
     public void onBindViewHolder_emptySummary_containerShouldBeGone() {
         mPreference.setSummary("");
-        View summaryContainer = new View(mContext);
-        View view = mock(View.class);
-        when(view.findViewById(R.id.summary_container)).thenReturn(summaryContainer);
+        View view = LayoutInflater.from(mContext)
+                .inflate(mPreference.getLayoutResource(), null /* root */);
         PreferenceViewHolder preferenceViewHolder =
                 PreferenceViewHolder.createInstanceForTests(view);
 
         mPreference.onBindViewHolder(preferenceViewHolder);
 
+        View summaryContainer = view.findViewById(R.id.summary_container);
         assertEquals(View.GONE, summaryContainer.getVisibility());
     }
 
@@ -184,25 +182,49 @@
     }
 
     @Test
-    public void nullSummary_containerShouldBeGone() {
-        mPreference.setSummary(null);
-        View summaryContainer = new View(mContext);
-        View view = mock(View.class);
-        when(view.findViewById(R.id.summary_container)).thenReturn(summaryContainer);
+    public void onBindViewHolder_appliesWidgetContentDescription() {
+        mPreference = new SelectorWithWidgetPreference(mContext);
+        View view = LayoutInflater.from(mContext)
+                .inflate(mPreference.getLayoutResource(), /* root= */ null);
         PreferenceViewHolder preferenceViewHolder =
                 PreferenceViewHolder.createInstanceForTests(view);
+
+        mPreference.setExtraWidgetContentDescription("this is clearer");
         mPreference.onBindViewHolder(preferenceViewHolder);
+
+        View widget = preferenceViewHolder.findViewById(R.id.selector_extra_widget);
+        assertThat(widget.getContentDescription().toString()).isEqualTo("this is clearer");
+
+        mPreference.setExtraWidgetContentDescription(null);
+        mPreference.onBindViewHolder(preferenceViewHolder);
+
+        assertThat(widget.getContentDescription().toString()).isEqualTo("Settings");
+    }
+
+    @Test
+    public void nullSummary_containerShouldBeGone() {
+        mPreference.setSummary(null);
+        View view = LayoutInflater.from(mContext)
+                .inflate(mPreference.getLayoutResource(), null /* root */);
+        PreferenceViewHolder preferenceViewHolder =
+                PreferenceViewHolder.createInstanceForTests(view);
+
+        mPreference.onBindViewHolder(preferenceViewHolder);
+
+        View summaryContainer = view.findViewById(R.id.summary_container);
         assertEquals(View.GONE, summaryContainer.getVisibility());
     }
 
     @Test
     public void setAppendixVisibility_setGone_shouldBeGone() {
         mPreference.setAppendixVisibility(View.GONE);
-
         View view = LayoutInflater.from(mContext)
-                .inflate(R.layout.preference_selector_with_widget, null /* root */);
-        PreferenceViewHolder holder = PreferenceViewHolder.createInstanceForTests(view);
+                .inflate(mPreference.getLayoutResource(), null /* root */);
+        PreferenceViewHolder holder =
+                PreferenceViewHolder.createInstanceForTests(view);
+
         mPreference.onBindViewHolder(holder);
+
         assertThat(holder.findViewById(R.id.appendix).getVisibility()).isEqualTo(View.GONE);
     }
 
diff --git a/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java b/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java
index 5e31da4..4dc8424 100644
--- a/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java
+++ b/packages/SettingsProvider/src/android/provider/settings/backup/SecureSettings.java
@@ -168,10 +168,6 @@
         Settings.Secure.SHOW_NOTIFICATION_SNOOZE,
         Settings.Secure.NOTIFICATION_HISTORY_ENABLED,
         Settings.Secure.ZEN_DURATION,
-        Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION,
-        Settings.Secure.SHOW_ZEN_SETTINGS_SUGGESTION,
-        Settings.Secure.ZEN_SETTINGS_UPDATED,
-        Settings.Secure.ZEN_SETTINGS_SUGGESTION_VIEWED,
         Settings.Secure.CHARGING_SOUNDS_ENABLED,
         Settings.Secure.CHARGING_VIBRATION_ENABLED,
         Settings.Secure.ACCESSIBILITY_NON_INTERACTIVE_UI_TIMEOUT_MS,
diff --git a/packages/SettingsProvider/src/android/provider/settings/validators/GlobalSettingsValidators.java b/packages/SettingsProvider/src/android/provider/settings/validators/GlobalSettingsValidators.java
index f6e1057..0773bd7 100644
--- a/packages/SettingsProvider/src/android/provider/settings/validators/GlobalSettingsValidators.java
+++ b/packages/SettingsProvider/src/android/provider/settings/validators/GlobalSettingsValidators.java
@@ -186,7 +186,7 @@
         VALIDATORS.put(
                 Global.STEM_PRIMARY_BUTTON_SHORT_PRESS, new InclusiveIntegerRangeValidator(0, 1));
         VALIDATORS.put(
-                Global.STEM_PRIMARY_BUTTON_DOUBLE_PRESS, new InclusiveIntegerRangeValidator(0, 1));
+                Global.STEM_PRIMARY_BUTTON_DOUBLE_PRESS, new InclusiveIntegerRangeValidator(0, 2));
         VALIDATORS.put(
                 Global.STEM_PRIMARY_BUTTON_TRIPLE_PRESS, new InclusiveIntegerRangeValidator(0, 1));
         VALIDATORS.put(
diff --git a/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java b/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java
index b3f7374..688676d 100644
--- a/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java
+++ b/packages/SettingsProvider/src/android/provider/settings/validators/SecureSettingsValidators.java
@@ -247,10 +247,6 @@
         VALIDATORS.put(Secure.SHOW_NOTIFICATION_SNOOZE, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.NOTIFICATION_HISTORY_ENABLED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.ZEN_DURATION, ANY_INTEGER_VALIDATOR);
-        VALIDATORS.put(Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, BOOLEAN_VALIDATOR);
-        VALIDATORS.put(Secure.SHOW_ZEN_SETTINGS_SUGGESTION, BOOLEAN_VALIDATOR);
-        VALIDATORS.put(Secure.ZEN_SETTINGS_UPDATED, BOOLEAN_VALIDATOR);
-        VALIDATORS.put(Secure.ZEN_SETTINGS_SUGGESTION_VIEWED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.CHARGING_SOUNDS_ENABLED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(Secure.CHARGING_VIBRATION_ENABLED, BOOLEAN_VALIDATOR);
         VALIDATORS.put(
diff --git a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java
index 3c24f5c..2034f36 100644
--- a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java
+++ b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProtoDumpUtil.java
@@ -2734,18 +2734,6 @@
                 Settings.Secure.ZEN_DURATION,
                 SecureSettingsProto.Zen.DURATION);
         dumpSetting(s, p,
-                Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION,
-                SecureSettingsProto.Zen.SHOW_ZEN_UPGRADE_NOTIFICATION);
-        dumpSetting(s, p,
-                Settings.Secure.SHOW_ZEN_SETTINGS_SUGGESTION,
-                SecureSettingsProto.Zen.SHOW_ZEN_SETTINGS_SUGGESTION);
-        dumpSetting(s, p,
-                Settings.Secure.ZEN_SETTINGS_UPDATED,
-                SecureSettingsProto.Zen.SETTINGS_UPDATED);
-        dumpSetting(s, p,
-                Settings.Secure.ZEN_SETTINGS_SUGGESTION_VIEWED,
-                SecureSettingsProto.Zen.SETTINGS_SUGGESTION_VIEWED);
-        dumpSetting(s, p,
                 Settings.Secure.CHARGE_OPTIMIZATION_MODE,
                 SecureSettingsProto.CHARGE_OPTIMIZATION_MODE);
         p.end(zenToken);
diff --git a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java
index 749ad0a..a8af43f5 100644
--- a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java
+++ b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java
@@ -4771,9 +4771,9 @@
                 }
 
                 if (currentVersion == 169) {
-                    // Version 169: Set the default value for Secure Settings ZEN_DURATION,
-                    // SHOW_ZEN_SETTINGS_SUGGESTION, ZEN_SETTINGS_UPDATE and
-                    // ZEN_SETTINGS_SUGGESTION_VIEWED
+                    // Version 169: Set the default value for Secure Settings ZEN_DURATION.
+                    // Also used to update SHOW_ZEN_SETTINGS_SUGGESTION, ZEN_SETTINGS_UPDATE and
+                    // ZEN_SETTINGS_SUGGESTION_VIEWED, but those properties are gone now.
 
                     final SettingsState globalSettings = getGlobalSettingsLocked();
                     final Setting globalZenDuration = globalSettings.getSettingLocked(
@@ -4801,33 +4801,6 @@
                                 SettingsState.SYSTEM_PACKAGE_NAME);
                     }
 
-                    // SHOW_ZEN_SETTINGS_SUGGESTION
-                    final Setting currentShowZenSettingSuggestion = secureSettings.getSettingLocked(
-                            Secure.SHOW_ZEN_SETTINGS_SUGGESTION);
-                    if (currentShowZenSettingSuggestion.isNull()) {
-                        secureSettings.insertSettingOverrideableByRestoreLocked(
-                                Secure.SHOW_ZEN_SETTINGS_SUGGESTION, "1",
-                                null, true, SettingsState.SYSTEM_PACKAGE_NAME);
-                    }
-
-                    // ZEN_SETTINGS_UPDATED
-                    final Setting currentUpdatedSetting = secureSettings.getSettingLocked(
-                            Secure.ZEN_SETTINGS_UPDATED);
-                    if (currentUpdatedSetting.isNull()) {
-                        secureSettings.insertSettingOverrideableByRestoreLocked(
-                                Secure.ZEN_SETTINGS_UPDATED, "0",
-                                null, true, SettingsState.SYSTEM_PACKAGE_NAME);
-                    }
-
-                    // ZEN_SETTINGS_SUGGESTION_VIEWED
-                    final Setting currentSettingSuggestionViewed = secureSettings.getSettingLocked(
-                            Secure.ZEN_SETTINGS_SUGGESTION_VIEWED);
-                    if (currentSettingSuggestionViewed.isNull()) {
-                        secureSettings.insertSettingOverrideableByRestoreLocked(
-                                Secure.ZEN_SETTINGS_SUGGESTION_VIEWED, "0",
-                                null, true, SettingsState.SYSTEM_PACKAGE_NAME);
-                    }
-
                     currentVersion = 170;
                 }
 
diff --git a/packages/SystemUI/aconfig/systemui.aconfig b/packages/SystemUI/aconfig/systemui.aconfig
index b57629f..6c3ba68 100644
--- a/packages/SystemUI/aconfig/systemui.aconfig
+++ b/packages/SystemUI/aconfig/systemui.aconfig
@@ -630,6 +630,20 @@
 }
 
 flag {
+    name: "screenshot_multidisplay_focus_change"
+    namespace: "systemui"
+    description: "Only capture a single display when screenshotting"
+    bug: "362720389"
+}
+
+flag {
+    name: "screenshot_policy_split_and_desktop_mode"
+    namespace: "systemui"
+    description: "Improves screenshot policy handling for split screen and desktop mode."
+    bug: "365597999"
+}
+
+flag {
    name: "run_fingerprint_detect_on_dismissible_keyguard"
    namespace: "systemui"
    description: "Run fingerprint detect instead of authenticate if the keyguard is dismissible."
@@ -1067,6 +1081,16 @@
 }
 
 flag {
+    name: "dream_overlay_updated_font"
+    namespace: "systemui"
+    description: "Flag to enable updated font settings for dream overlay"
+    bug: "349656117"
+    metadata {
+        purpose: PURPOSE_BUGFIX
+    }
+}
+
+flag {
   name: "app_clips_backlinks"
   namespace: "systemui"
   description: "Enables Backlinks improvement feature in App Clips"
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt
index dc9e267..56de096 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainer.kt
@@ -132,7 +132,6 @@
             state = state,
             modifier = modifier.fillMaxSize(),
             swipeSourceDetector = viewModel.edgeDetector,
-            gestureFilter = viewModel::shouldFilterGesture,
         ) {
             sceneByKey.forEach { (sceneKey, scene) ->
                 scene(
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt
index ac58ab5..9d5252e 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/scene/ui/composable/SceneContainerTransitions.kt
@@ -1,5 +1,6 @@
 package com.android.systemui.scene.ui.composable
 
+import androidx.compose.animation.core.spring
 import androidx.compose.foundation.gestures.Orientation
 import com.android.compose.animation.scene.ProgressConverter
 import com.android.compose.animation.scene.TransitionKey
@@ -44,6 +45,7 @@
 
     // Overscroll progress starts linearly with some resistance (3f) and slowly approaches 0.2f
     defaultOverscrollProgressConverter = ProgressConverter.tanh(maxProgress = 0.2f, tilt = 3f)
+    defaultSwipeSpec = spring(stiffness = 300f, dampingRatio = 0.8f, visibilityThreshold = 0.5f)
 
     // Scene transitions
 
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
index 5e6f88e..5fa5db8 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
@@ -124,10 +124,6 @@
         overSlop: Float,
         pointersDown: Int,
     ): DragController {
-        if (startedPosition != null && layoutImpl.gestureFilter(startedPosition)) {
-            return NoOpDragController
-        }
-
         if (overSlop == 0f) {
             val oldDragController = dragController
             check(oldDragController != null && oldDragController.isDrivingTransition) {
@@ -189,7 +185,7 @@
         return createSwipeAnimation(layoutImpl, result, isUpOrLeft, orientation)
     }
 
-    private fun resolveSwipeSource(startedPosition: Offset?): SwipeSource.Resolved? {
+    internal fun resolveSwipeSource(startedPosition: Offset?): SwipeSource.Resolved? {
         if (startedPosition == null) return null
         return layoutImpl.swipeSourceDetector.source(
             layoutSize = layoutImpl.lastSize,
@@ -199,7 +195,7 @@
         )
     }
 
-    private fun resolveSwipe(
+    internal fun resolveSwipe(
         pointersDown: Int,
         fromSource: SwipeSource.Resolved?,
         isUpOrLeft: Boolean,
@@ -559,6 +555,14 @@
 
     val connection: PriorityNestedScrollConnection = nestedScrollConnection()
 
+    private fun PointersInfo.resolveSwipe(isUpOrLeft: Boolean): Swipe.Resolved {
+        return draggableHandler.resolveSwipe(
+            pointersDown = pointersDown,
+            fromSource = draggableHandler.resolveSwipeSource(startedPosition),
+            isUpOrLeft = isUpOrLeft,
+        )
+    }
+
     private fun nestedScrollConnection(): PriorityNestedScrollConnection {
         // If we performed a long gesture before entering priority mode, we would have to avoid
         // moving on to the next scene.
@@ -575,36 +579,19 @@
             val transitionState = layoutState.transitionState
             val scene = transitionState.currentScene
             val fromScene = layoutImpl.scene(scene)
-            val nextScene =
+            val resolvedSwipe =
                 when {
-                    amount < 0f -> {
-                        val actionUpOrLeft =
-                            Swipe.Resolved(
-                                direction =
-                                    when (orientation) {
-                                        Orientation.Horizontal -> SwipeDirection.Resolved.Left
-                                        Orientation.Vertical -> SwipeDirection.Resolved.Up
-                                    },
-                                pointerCount = pointersInfo().pointersDown,
-                                fromSource = null,
-                            )
-                        fromScene.userActions[actionUpOrLeft]
-                    }
-                    amount > 0f -> {
-                        val actionDownOrRight =
-                            Swipe.Resolved(
-                                direction =
-                                    when (orientation) {
-                                        Orientation.Horizontal -> SwipeDirection.Resolved.Right
-                                        Orientation.Vertical -> SwipeDirection.Resolved.Down
-                                    },
-                                pointerCount = pointersInfo().pointersDown,
-                                fromSource = null,
-                            )
-                        fromScene.userActions[actionDownOrRight]
-                    }
+                    amount < 0f -> pointersInfo().resolveSwipe(isUpOrLeft = true)
+                    amount > 0f -> pointersInfo().resolveSwipe(isUpOrLeft = false)
                     else -> null
                 }
+            val nextScene =
+                resolvedSwipe?.let {
+                    fromScene.userActions[it]
+                        ?: if (it.fromSource != null) {
+                            fromScene.userActions[it.copy(fromSource = null)]
+                        } else null
+                }
             if (nextScene != null) return true
 
             if (transitionState !is TransitionState.Idle) return false
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
index 4ae9718..dc3135d 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
@@ -213,8 +213,9 @@
 
     internal fun pointersInfo(): PointersInfo {
         return PointersInfo(
+            // This may be null, i.e. when the user uses TalkBack
             startedPosition = startedPosition,
-            // Note: We could have 0 pointers during fling or for other reasons.
+            // We could have 0 pointers during fling or for other reasons.
             pointersDown = pointersDown.coerceAtLeast(1),
         )
     }
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index 6e89814..cec8883 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -47,9 +47,6 @@
  * @param state the state of this layout.
  * @param swipeSourceDetector the edge detector used to detect which edge a swipe is started from,
  *   if any.
- * @param gestureFilter decides whether a drag gesture that started at the given start position
- *   should be filtered. If the lambda returns `true`, the drag gesture will be ignored. If it
- *   returns `false`, the drag gesture will be handled.
  * @param transitionInterceptionThreshold used during a scene transition. For the scene to be
  *   intercepted, the progress value must be above the threshold, and below (1 - threshold).
  * @param builder the configuration of the different scenes and overlays of this layout.
@@ -60,7 +57,6 @@
     modifier: Modifier = Modifier,
     swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
     swipeDetector: SwipeDetector = DefaultSwipeDetector,
-    gestureFilter: (startedPosition: Offset) -> Boolean = DefaultGestureFilter,
     @FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0.05f,
     builder: SceneTransitionLayoutScope.() -> Unit,
 ) {
@@ -69,7 +65,6 @@
         modifier,
         swipeSourceDetector,
         swipeDetector,
-        gestureFilter,
         transitionInterceptionThreshold,
         onLayoutImpl = null,
         builder,
@@ -621,7 +616,6 @@
     modifier: Modifier = Modifier,
     swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
     swipeDetector: SwipeDetector = DefaultSwipeDetector,
-    gestureFilter: (startedPosition: Offset) -> Boolean = DefaultGestureFilter,
     transitionInterceptionThreshold: Float = 0f,
     onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null,
     builder: SceneTransitionLayoutScope.() -> Unit,
@@ -638,7 +632,6 @@
                 transitionInterceptionThreshold = transitionInterceptionThreshold,
                 builder = builder,
                 animationScope = animationScope,
-                gestureFilter = gestureFilter,
             )
             .also { onLayoutImpl?.invoke(it) }
     }
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
index 9e7be37..65c4043 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -31,7 +31,6 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.layout.ApproachLayoutModifierNode
 import androidx.compose.ui.layout.ApproachMeasureScope
 import androidx.compose.ui.layout.LookaheadScope
@@ -71,7 +70,6 @@
      * animations.
      */
     internal val animationScope: CoroutineScope,
-    internal val gestureFilter: (startedPosition: Offset) -> Boolean,
 ) {
     /**
      * The map of [Scene]s.
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt
index b358faf..879dc54 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt
@@ -152,6 +152,7 @@
         internal val DefaultSwipeSpec =
             spring(
                 stiffness = Spring.StiffnessMediumLow,
+                dampingRatio = Spring.DampingRatioLowBouncy,
                 visibilityThreshold = OffsetVisibilityThreshold,
             )
 
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt
index f758102..54ee783 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeDetector.kt
@@ -17,7 +17,6 @@
 package com.android.compose.animation.scene
 
 import androidx.compose.runtime.Stable
-import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.input.pointer.PointerInputChange
 
 /** {@link SwipeDetector} helps determine whether a swipe gestured has occurred. */
@@ -32,8 +31,6 @@
 
 val DefaultSwipeDetector = PassthroughSwipeDetector()
 
-val DefaultGestureFilter = { _: Offset -> false }
-
 /** An {@link SwipeDetector} implementation that recognizes a swipe on any input. */
 class PassthroughSwipeDetector : SwipeDetector {
     override fun detectSwipe(change: PointerInputChange): Boolean {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt
index a6ebb0e..a3641e6 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/nestedscroll/PriorityNestedScrollConnection.kt
@@ -34,71 +34,76 @@
  * Note: Call [reset] before destroying this object to make sure you always get a call to [onStop]
  * after [onStart].
  *
- * @sample com.android.compose.animation.scene.rememberSwipeToSceneNestedScrollConnection
+ * @sample LargeTopAppBarNestedScrollConnection
+ * @sample com.android.compose.animation.scene.NestedScrollHandlerImpl.nestedScrollConnection
  */
 class PriorityNestedScrollConnection(
-    private val canStartPreScroll: (offsetAvailable: Offset, offsetBeforeStart: Offset) -> Boolean,
-    private val canStartPostScroll: (offsetAvailable: Offset, offsetBeforeStart: Offset) -> Boolean,
-    private val canStartPostFling: (velocityAvailable: Velocity) -> Boolean,
+    orientation: Orientation,
+    private val canStartPreScroll: (offsetAvailable: Float, offsetBeforeStart: Float) -> Boolean,
+    private val canStartPostScroll: (offsetAvailable: Float, offsetBeforeStart: Float) -> Boolean,
+    private val canStartPostFling: (velocityAvailable: Float) -> Boolean,
     private val canContinueScroll: (source: NestedScrollSource) -> Boolean,
     private val canScrollOnFling: Boolean,
-    private val onStart: (offsetAvailable: Offset) -> Unit,
-    private val onScroll: (offsetAvailable: Offset) -> Offset,
-    private val onStop: (velocityAvailable: Velocity) -> SuspendedValue<Velocity>,
-) : NestedScrollConnection {
+    private val onStart: (offsetAvailable: Float) -> Unit,
+    private val onScroll: (offsetAvailable: Float) -> Float,
+    private val onStop: (velocityAvailable: Float) -> SuspendedValue<Float>,
+) : NestedScrollConnection, SpaceVectorConverter by SpaceVectorConverter(orientation) {
 
     /** In priority mode [onPreScroll] events are first consumed by the parent, via [onScroll]. */
     private var isPriorityMode = false
 
-    private var offsetScrolledBeforePriorityMode = Offset.Zero
+    private var offsetScrolledBeforePriorityMode = 0f
 
     override fun onPostScroll(
         consumed: Offset,
         available: Offset,
         source: NestedScrollSource,
     ): Offset {
+        val availableFloat = available.toFloat()
         // The offset before the start takes into account the up and down movements, starting from
         // the beginning or from the last fling gesture.
-        val offsetBeforeStart = offsetScrolledBeforePriorityMode - available
+        val offsetBeforeStart = offsetScrolledBeforePriorityMode - availableFloat
 
         if (
             isPriorityMode ||
                 (source == NestedScrollSource.SideEffect && !canScrollOnFling) ||
-                !canStartPostScroll(available, offsetBeforeStart)
+                !canStartPostScroll(availableFloat, offsetBeforeStart)
         ) {
             // The priority mode cannot start so we won't consume the available offset.
             return Offset.Zero
         }
 
-        return onPriorityStart(available)
+        return onPriorityStart(availableFloat).toOffset()
     }
 
     override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
         if (!isPriorityMode) {
             if (source == NestedScrollSource.UserInput || canScrollOnFling) {
-                if (canStartPreScroll(available, offsetScrolledBeforePriorityMode)) {
-                    return onPriorityStart(available)
+                val availableFloat = available.toFloat()
+                if (canStartPreScroll(availableFloat, offsetScrolledBeforePriorityMode)) {
+                    return onPriorityStart(availableFloat).toOffset()
                 }
                 // We want to track the amount of offset consumed before entering priority mode
-                offsetScrolledBeforePriorityMode += available
+                offsetScrolledBeforePriorityMode += availableFloat
             }
 
             return Offset.Zero
         }
 
+        val availableFloat = available.toFloat()
         if (!canContinueScroll(source)) {
             // Step 3a: We have lost priority and we no longer need to intercept scroll events.
-            onPriorityStop(velocity = Velocity.Zero)
+            onPriorityStop(velocity = 0f)
 
-            // We've just reset offsetScrolledBeforePriorityMode to Offset.Zero
+            // We've just reset offsetScrolledBeforePriorityMode to 0f
             // We want to track the amount of offset consumed before entering priority mode
-            offsetScrolledBeforePriorityMode += available
+            offsetScrolledBeforePriorityMode += availableFloat
 
             return Offset.Zero
         }
 
         // Step 2: We have the priority and can consume the scroll events.
-        return onScroll(available)
+        return onScroll(availableFloat).toOffset()
     }
 
     override suspend fun onPreFling(available: Velocity): Velocity {
@@ -108,15 +113,16 @@
         }
         // Step 3b: The finger is lifted, we can stop intercepting scroll events and use the speed
         // of the fling gesture.
-        return onPriorityStop(velocity = available).invoke()
+        return onPriorityStop(velocity = available.toFloat()).invoke().toVelocity()
     }
 
     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+        val availableFloat = available.toFloat()
         if (isPriorityMode) {
-            return onPriorityStop(velocity = available).invoke()
+            return onPriorityStop(velocity = availableFloat).invoke().toVelocity()
         }
 
-        if (!canStartPostFling(available)) {
+        if (!canStartPostFling(availableFloat)) {
             return Velocity.Zero
         }
 
@@ -124,11 +130,11 @@
         // given the available velocity.
         // TODO(b/291053278): Remove canStartPostFling() and instead make it possible to define the
         // overscroll behavior on the Scene level.
-        val smallOffset = Offset(available.x.sign, available.y.sign)
-        onPriorityStart(available = smallOffset)
+        val smallOffset = availableFloat.sign
+        onPriorityStart(availableOffset = smallOffset)
 
         // This is the last event of a scroll gesture.
-        return onPriorityStop(available).invoke()
+        return onPriorityStop(availableFloat).invoke().toVelocity()
     }
 
     /**
@@ -138,10 +144,10 @@
      */
     fun reset() {
         // Step 3c: To ensure that an onStop is always called for every onStart.
-        onPriorityStop(velocity = Velocity.Zero)
+        onPriorityStop(velocity = 0f)
     }
 
-    private fun onPriorityStart(available: Offset): Offset {
+    private fun onPriorityStart(availableOffset: Float): Float {
         if (isPriorityMode) {
             error("This should never happen, onPriorityStart() was called when isPriorityMode")
         }
@@ -152,17 +158,17 @@
 
         // Note: onStop will be called if we cannot continue to scroll (step 3a), or the finger is
         // lifted (step 3b), or this object has been destroyed (step 3c).
-        onStart(available)
+        onStart(availableOffset)
 
-        return onScroll(available)
+        return onScroll(availableOffset)
     }
 
-    private fun onPriorityStop(velocity: Velocity): SuspendedValue<Velocity> {
+    private fun onPriorityStop(velocity: Float): SuspendedValue<Float> {
         // We can restart tracking the consumed offsets from scratch.
-        offsetScrolledBeforePriorityMode = Offset.Zero
+        offsetScrolledBeforePriorityMode = 0f
 
         if (!isPriorityMode) {
-            return { Velocity.Zero }
+            return { 0f }
         }
 
         isPriorityMode = false
@@ -170,38 +176,3 @@
         return onStop(velocity)
     }
 }
-
-fun PriorityNestedScrollConnection(
-    orientation: Orientation,
-    canStartPreScroll: (offsetAvailable: Float, offsetBeforeStart: Float) -> Boolean,
-    canStartPostScroll: (offsetAvailable: Float, offsetBeforeStart: Float) -> Boolean,
-    canStartPostFling: (velocityAvailable: Float) -> Boolean,
-    canContinueScroll: (source: NestedScrollSource) -> Boolean,
-    canScrollOnFling: Boolean,
-    onStart: (offsetAvailable: Float) -> Unit,
-    onScroll: (offsetAvailable: Float) -> Float,
-    onStop: (velocityAvailable: Float) -> SuspendedValue<Float>,
-) =
-    with(SpaceVectorConverter(orientation)) {
-        PriorityNestedScrollConnection(
-            canStartPreScroll = { offsetAvailable: Offset, offsetBeforeStart: Offset ->
-                canStartPreScroll(offsetAvailable.toFloat(), offsetBeforeStart.toFloat())
-            },
-            canStartPostScroll = { offsetAvailable: Offset, offsetBeforeStart: Offset ->
-                canStartPostScroll(offsetAvailable.toFloat(), offsetBeforeStart.toFloat())
-            },
-            canStartPostFling = { velocityAvailable: Velocity ->
-                canStartPostFling(velocityAvailable.toFloat())
-            },
-            canContinueScroll = canContinueScroll,
-            canScrollOnFling = canScrollOnFling,
-            onStart = { offsetAvailable -> onStart(offsetAvailable.toFloat()) },
-            onScroll = { offsetAvailable: Offset ->
-                onScroll(offsetAvailable.toFloat()).toOffset()
-            },
-            onStop = { velocityAvailable: Velocity ->
-                val consumedVelocity = onStop(velocityAvailable.toFloat())
-                suspend { consumedVelocity.invoke().toVelocity() }
-            },
-        )
-    }
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
index 2c41b35..302f207 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
@@ -109,8 +109,6 @@
 
         val transitionInterceptionThreshold = 0.05f
 
-        var gestureFilter: (startedPosition: Offset) -> Boolean = DefaultGestureFilter
-
         private val layoutImpl =
             SceneTransitionLayoutImpl(
                     state = layoutState,
@@ -123,13 +121,16 @@
                     // Use testScope and not backgroundScope here because backgroundScope does not
                     // work well with advanceUntilIdle(), which is used by some tests.
                     animationScope = testScope,
-                    gestureFilter = { startedPosition -> gestureFilter.invoke(startedPosition) },
                 )
                 .apply { setContentsAndLayoutTargetSizeForTest(LAYOUT_SIZE) }
 
         val draggableHandler = layoutImpl.draggableHandler(Orientation.Vertical)
         val horizontalDraggableHandler = layoutImpl.draggableHandler(Orientation.Horizontal)
 
+        var pointerInfoOwner: () -> PointersInfo = {
+            PointersInfo(startedPosition = Offset.Zero, pointersDown = 1)
+        }
+
         fun nestedScrollConnection(
             nestedScrollBehavior: NestedScrollBehavior,
             isExternalOverscrollGesture: Boolean = false,
@@ -140,9 +141,7 @@
                     topOrLeftBehavior = nestedScrollBehavior,
                     bottomOrRightBehavior = nestedScrollBehavior,
                     isExternalOverscrollGesture = { isExternalOverscrollGesture },
-                    pointersInfoOwner = {
-                        PointersInfo(startedPosition = Offset.Zero, pointersDown = 1)
-                    },
+                    pointersInfoOwner = { pointerInfoOwner() },
                 )
                 .connection
 
@@ -156,11 +155,18 @@
 
         fun downOffset(fractionOfScreen: Float) =
             if (fractionOfScreen < 0f) {
-                error("upOffset() is required, not implemented yet")
+                error("use upOffset()")
             } else {
                 Offset(x = 0f, y = down(fractionOfScreen))
             }
 
+        fun upOffset(fractionOfScreen: Float) =
+            if (fractionOfScreen < 0f) {
+                error("use downOffset()")
+            } else {
+                Offset(x = 0f, y = up(fractionOfScreen))
+            }
+
         val transitionState: TransitionState
             get() = layoutState.transitionState
 
@@ -343,13 +349,6 @@
     }
 
     @Test
-    fun onDragStarted_doesNotStartTransition_whenGestureFiltered() = runGestureTest {
-        gestureFilter = { _ -> true }
-        onDragStarted(overSlop = down(fractionOfScreen = 0.1f), expectedConsumedOverSlop = 0f)
-        assertIdle(currentScene = SceneA)
-    }
-
-    @Test
     fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest {
         val dragController = onDragStarted(overSlop = down(fractionOfScreen = 0.1f))
         assertTransition(currentScene = SceneA)
@@ -1135,6 +1134,45 @@
     }
 
     @Test
+    fun nestedScrollUseFromSourceInfo() = runGestureTest {
+        // Start at scene C.
+        navigateToSceneC()
+        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeAlways)
+
+        // Drag from the **top** of the screen
+        pointerInfoOwner = { PointersInfo(startedPosition = Offset(0f, 0f), pointersDown = 1) }
+        assertIdle(currentScene = SceneC)
+
+        nestedScroll.scroll(available = upOffset(fractionOfScreen = 0.1f))
+        assertTransition(
+            currentScene = SceneC,
+            fromScene = SceneC,
+            // userAction: Swipe.Up to SceneB
+            toScene = SceneB,
+            progress = 0.1f,
+        )
+
+        // Reset to SceneC
+        nestedScroll.preFling(Velocity.Zero)
+        advanceUntilIdle()
+
+        // Drag from the **bottom** of the screen
+        pointerInfoOwner = {
+            PointersInfo(startedPosition = Offset(0f, SCREEN_SIZE), pointersDown = 1)
+        }
+        assertIdle(currentScene = SceneC)
+
+        nestedScroll.scroll(available = upOffset(fractionOfScreen = 0.1f))
+        assertTransition(
+            currentScene = SceneC,
+            fromScene = SceneC,
+            // userAction: Swipe(SwipeDirection.Up, fromSource = Edge.Bottom) to SceneA
+            toScene = SceneA,
+            progress = 0.1f,
+        )
+    }
+
+    @Test
     fun transitionIsImmediatelyUpdatedWhenReleasingFinger() = runGestureTest {
         // Swipe up from the middle to transition to scene B.
         val middle = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f)
@@ -1203,7 +1241,8 @@
     fun overscroll_releaseBetween0And100Percent_up() = runGestureTest {
         // Make scene B overscrollable.
         layoutState.transitions = transitions {
-            from(SceneA, to = SceneB) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
+            defaultSwipeSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy)
+            from(SceneA, to = SceneB) {}
             overscroll(SceneB, Orientation.Vertical) { fade(TestElements.Foo) }
         }
 
@@ -1234,7 +1273,8 @@
     fun overscroll_releaseBetween0And100Percent_down() = runGestureTest {
         // Make scene C overscrollable.
         layoutState.transitions = transitions {
-            from(SceneA, to = SceneC) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
+            defaultSwipeSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy)
+            from(SceneA, to = SceneC) {}
             overscroll(SceneC, Orientation.Vertical) { fade(TestElements.Foo) }
         }
 
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt
index bde7699..badc43b 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/nestedscroll/PriorityNestedScrollConnectionTest.kt
@@ -18,8 +18,9 @@
 
 package com.android.compose.nestedscroll
 
+import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.UserInput
 import androidx.compose.ui.unit.Velocity
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.google.common.truth.Truth.assertThat
@@ -35,13 +36,14 @@
     private var canStartPostFling = false
     private var canContinueScroll = false
     private var isStarted = false
-    private var lastScroll: Offset? = null
-    private var returnOnScroll = Offset.Zero
-    private var lastStop: Velocity? = null
-    private var returnOnStop = Velocity.Zero
+    private var lastScroll: Float? = null
+    private var returnOnScroll = 0f
+    private var lastStop: Float? = null
+    private var returnOnStop = 0f
 
     private val scrollConnection =
         PriorityNestedScrollConnection(
+            orientation = Orientation.Vertical,
             canStartPreScroll = { _, _ -> canStartPreScroll },
             canStartPostScroll = { _, _ -> canStartPostScroll },
             canStartPostFling = { canStartPostFling },
@@ -58,11 +60,6 @@
             },
         )
 
-    private val offset1 = Offset(1f, 1f)
-    private val offset2 = Offset(2f, 2f)
-    private val velocity1 = Velocity(1f, 1f)
-    private val velocity2 = Velocity(2f, 2f)
-
     @Test
     fun step1_priorityModeShouldStartOnlyOnPreScroll() = runTest {
         canStartPreScroll = true
@@ -70,7 +67,7 @@
         scrollConnection.onPostScroll(
             consumed = Offset.Zero,
             available = Offset.Zero,
-            source = NestedScrollSource.Drag,
+            source = UserInput,
         )
         assertThat(isStarted).isEqualTo(false)
 
@@ -80,7 +77,7 @@
         scrollConnection.onPostFling(consumed = Velocity.Zero, available = Velocity.Zero)
         assertThat(isStarted).isEqualTo(false)
 
-        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
+        scrollConnection.onPreScroll(available = Offset.Zero, source = UserInput)
         assertThat(isStarted).isEqualTo(true)
     }
 
@@ -89,7 +86,7 @@
         scrollConnection.onPostScroll(
             consumed = Offset.Zero,
             available = Offset.Zero,
-            source = NestedScrollSource.Drag,
+            source = UserInput,
         )
     }
 
@@ -97,7 +94,7 @@
     fun step1_priorityModeShouldStartOnlyOnPostScroll() = runTest {
         canStartPostScroll = true
 
-        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
+        scrollConnection.onPreScroll(available = Offset.Zero, source = UserInput)
         assertThat(isStarted).isEqualTo(false)
 
         scrollConnection.onPreFling(available = Velocity.Zero)
@@ -115,7 +112,7 @@
         scrollConnection.onPostScroll(
             consumed = Offset.Zero,
             available = Offset.Zero,
-            source = NestedScrollSource.Drag,
+            source = UserInput,
         )
         assertThat(isStarted).isEqualTo(false)
 
@@ -128,12 +125,12 @@
         canStartPostScroll = true
 
         scrollConnection.onPostScroll(
-            consumed = offset1,
-            available = offset2,
-            source = NestedScrollSource.Drag,
+            consumed = Offset(1f, 1f),
+            available = Offset(2f, 2f),
+            source = UserInput,
         )
 
-        assertThat(lastScroll).isEqualTo(offset2)
+        assertThat(lastScroll).isEqualTo(2f)
     }
 
     @Test
@@ -141,13 +138,13 @@
         startPriorityModePostScroll()
         canContinueScroll = true
 
-        scrollConnection.onPreScroll(available = offset1, source = NestedScrollSource.Drag)
-        assertThat(lastScroll).isEqualTo(offset1)
+        scrollConnection.onPreScroll(available = Offset(1f, 1f), source = UserInput)
+        assertThat(lastScroll).isEqualTo(1f)
 
         canContinueScroll = false
-        scrollConnection.onPreScroll(available = offset2, source = NestedScrollSource.Drag)
-        assertThat(lastScroll).isNotEqualTo(offset2)
-        assertThat(lastScroll).isEqualTo(offset1)
+        scrollConnection.onPreScroll(available = Offset(2f, 2f), source = UserInput)
+        assertThat(lastScroll).isNotEqualTo(2f)
+        assertThat(lastScroll).isEqualTo(1f)
     }
 
     @Test
@@ -155,7 +152,7 @@
         startPriorityModePostScroll()
         canContinueScroll = false
 
-        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
+        scrollConnection.onPreScroll(available = Offset.Zero, source = UserInput)
 
         assertThat(lastStop).isNotNull()
     }
@@ -184,22 +181,22 @@
     fun receive_onPostFling() = runTest {
         canStartPostFling = true
 
-        scrollConnection.onPostFling(consumed = velocity1, available = velocity2)
+        scrollConnection.onPostFling(consumed = Velocity(1f, 1f), available = Velocity(2f, 2f))
 
-        assertThat(lastStop).isEqualTo(velocity2)
+        assertThat(lastStop).isEqualTo(2f)
     }
 
     @Test
     fun step1_priorityModeShouldStartOnlyOnPostFling() = runTest {
         canStartPostFling = true
 
-        scrollConnection.onPreScroll(available = Offset.Zero, source = NestedScrollSource.Drag)
+        scrollConnection.onPreScroll(available = Offset.Zero, source = UserInput)
         assertThat(isStarted).isEqualTo(false)
 
         scrollConnection.onPostScroll(
             consumed = Offset.Zero,
             available = Offset.Zero,
-            source = NestedScrollSource.Drag,
+            source = UserInput,
         )
         assertThat(isStarted).isEqualTo(false)
 
diff --git a/packages/SystemUI/customization/Android.bp b/packages/SystemUI/customization/Android.bp
index c399abc..81d92fa 100644
--- a/packages/SystemUI/customization/Android.bp
+++ b/packages/SystemUI/customization/Android.bp
@@ -36,6 +36,7 @@
         "SystemUIPluginLib",
         "SystemUIUnfoldLib",
         "kotlinx_coroutines",
+        "monet",
         "dagger2",
         "jsr330",
     ],
diff --git a/packages/SystemUI/customization/res/values/ids.xml b/packages/SystemUI/customization/res/values/ids.xml
index 5eafbfc..ec466f0 100644
--- a/packages/SystemUI/customization/res/values/ids.xml
+++ b/packages/SystemUI/customization/res/values/ids.xml
@@ -6,4 +6,13 @@
     <item type="id" name="weather_clock_weather_icon" />
     <item type="id" name="weather_clock_temperature" />
     <item type="id" name="weather_clock_alarm_dnd" />
+
+    <item type="id" name="HOUR_DIGIT_PAIR"/>
+    <item type="id" name="MINUTE_DIGIT_PAIR"/>
+    <item type="id" name="HOUR_FIRST_DIGIT"/>
+    <item type="id" name="HOUR_SECOND_DIGIT"/>
+    <item type="id" name="MINUTE_FIRST_DIGIT"/>
+    <item type="id" name="MINUTE_SECOND_DIGIT"/>
+    <item type="id" name="TIME_FULL_FORMAT"/>
+    <item type="id" name="DATE_FORMAT"/>
 </resources>
\ No newline at end of file
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
index 1863cd8..9877406 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
@@ -62,6 +62,7 @@
     // implement the get method and ensure a value is returned before initialization is complete.
     private var logger = DEFAULT_LOGGER
         get() = field ?: DEFAULT_LOGGER
+
     var messageBuffer: MessageBuffer
         get() = logger.buffer
         set(value) {
@@ -123,24 +124,24 @@
                 attrs,
                 R.styleable.AnimatableClockView,
                 defStyleAttr,
-                defStyleRes
+                defStyleRes,
             )
 
         try {
             dozingWeightInternal =
                 animatableClockViewAttributes.getInt(
                     R.styleable.AnimatableClockView_dozeWeight,
-                    /* default = */ 100
+                    /* default = */ 100,
                 )
             lockScreenWeightInternal =
                 animatableClockViewAttributes.getInt(
                     R.styleable.AnimatableClockView_lockScreenWeight,
-                    /* default = */ 300
+                    /* default = */ 300,
                 )
             chargeAnimationDelay =
                 animatableClockViewAttributes.getInt(
                     R.styleable.AnimatableClockView_chargeAnimationDelay,
-                    /* default = */ 200
+                    /* default = */ 200,
                 )
         } finally {
             animatableClockViewAttributes.recycle()
@@ -151,14 +152,14 @@
                 attrs,
                 android.R.styleable.TextView,
                 defStyleAttr,
-                defStyleRes
+                defStyleRes,
             )
 
         try {
             isSingleLineInternal =
                 textViewAttributes.getBoolean(
                     android.R.styleable.TextView_singleLine,
-                    /* default = */ false
+                    /* default = */ false,
                 )
         } finally {
             textViewAttributes.recycle()
@@ -280,7 +281,7 @@
         text: CharSequence,
         start: Int,
         lengthBefore: Int,
-        lengthAfter: Int
+        lengthAfter: Int,
     ) {
         logger.d({ "onTextChanged($str1)" }) { str1 = text.toString() }
         super.onTextChanged(text, start, lengthBefore, lengthAfter)
@@ -305,7 +306,7 @@
             interpolator = null,
             duration = 0,
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
         setTextStyle(
             weight = lockScreenWeight,
@@ -314,7 +315,7 @@
             interpolator = null,
             duration = COLOR_ANIM_DURATION,
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
     }
 
@@ -327,7 +328,7 @@
             interpolator = null,
             duration = 0,
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
         setTextStyle(
             weight = lockScreenWeight,
@@ -336,7 +337,7 @@
             duration = APPEAR_ANIM_DURATION,
             interpolator = Interpolators.EMPHASIZED_DECELERATE,
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
     }
 
@@ -353,7 +354,7 @@
             interpolator = null,
             duration = 0,
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
         setTextStyle(
             weight = dozingWeightInternal,
@@ -362,7 +363,7 @@
             interpolator = Interpolators.EMPHASIZED_DECELERATE,
             duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(),
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
     }
 
@@ -381,7 +382,7 @@
                 interpolator = null,
                 duration = CHARGE_ANIM_DURATION_PHASE_1,
                 delay = 0,
-                onAnimationEnd = null
+                onAnimationEnd = null,
             )
         }
         setTextStyle(
@@ -391,7 +392,7 @@
             interpolator = null,
             duration = CHARGE_ANIM_DURATION_PHASE_0,
             delay = chargeAnimationDelay.toLong(),
-            onAnimationEnd = startAnimPhase2
+            onAnimationEnd = startAnimPhase2,
         )
     }
 
@@ -404,7 +405,7 @@
             interpolator = null,
             duration = DOZE_ANIM_DURATION,
             delay = 0,
-            onAnimationEnd = null
+            onAnimationEnd = null,
         )
     }
 
@@ -444,7 +445,7 @@
         interpolator: TimeInterpolator?,
         duration: Long,
         delay: Long,
-        onAnimationEnd: Runnable?
+        onAnimationEnd: Runnable?,
     ) {
         textAnimator?.let {
             it.setTextStyle(
@@ -454,7 +455,7 @@
                 duration = duration,
                 interpolator = interpolator,
                 delay = delay,
-                onAnimationEnd = onAnimationEnd
+                onAnimationEnd = onAnimationEnd,
             )
             it.glyphFilter = glyphFilter
         }
@@ -468,7 +469,7 @@
                         duration = duration,
                         interpolator = interpolator,
                         delay = delay,
-                        onAnimationEnd = onAnimationEnd
+                        onAnimationEnd = onAnimationEnd,
                     )
                     textAnimator.glyphFilter = glyphFilter
                 }
@@ -476,6 +477,7 @@
     }
 
     fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context))
+
     fun refreshFormat(use24HourFormat: Boolean) {
         Patterns.update(context)
 
@@ -560,18 +562,11 @@
      * @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 means
      *   it finished moving.
      */
-    fun offsetGlyphsForStepClockAnimation(
-        distance: Float,
-        fraction: Float,
-    ) {
+    fun offsetGlyphsForStepClockAnimation(distance: Float, fraction: Float) {
         for (i in 0 until NUM_DIGITS) {
             val dir = if (isLayoutRtl) -1 else 1
             val digitFraction =
-                getDigitFraction(
-                    digit = i,
-                    isMovingToCenter = distance > 0,
-                    fraction = fraction,
-                )
+                getDigitFraction(digit = i, isMovingToCenter = distance > 0, fraction = fraction)
             val moveAmountForDigit = dir * distance * digitFraction
             glyphOffsets[i] = moveAmountForDigit
 
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt
new file mode 100644
index 0000000..d001ef96
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt
@@ -0,0 +1,448 @@
+/*
+ * 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.systemui.shared.clocks
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.content.res.Resources
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.util.TypedValue
+import com.android.internal.graphics.ColorUtils
+import com.android.internal.graphics.cam.Cam
+import com.android.internal.graphics.cam.CamUtils
+import com.android.internal.policy.SystemBarUtils
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.monet.ColorScheme
+import com.android.systemui.monet.Style as MonetStyle
+import com.android.systemui.monet.TonalPalette
+import java.io.IOException
+import kotlin.math.abs
+
+class AssetLoader
+private constructor(
+    private val pluginCtx: Context,
+    private val sysuiCtx: Context,
+    private val baseDir: String,
+    var colorScheme: ColorScheme?,
+    var seedColor: Int?,
+    var overrideChroma: Float?,
+    val typefaceCache: TypefaceCache,
+    val getThemeSeedColor: (Context) -> Int,
+    messageBuffer: MessageBuffer,
+) {
+    val logger = Logger(messageBuffer, TAG)
+    private val resources =
+        listOf(
+            Pair(pluginCtx.resources, pluginCtx.packageName),
+            Pair(sysuiCtx.resources, sysuiCtx.packageName),
+        )
+
+    constructor(
+        pluginCtx: Context,
+        sysuiCtx: Context,
+        baseDir: String,
+        messageBuffer: MessageBuffer,
+        getThemeSeedColor: ((Context) -> Int)? = null,
+    ) : this(
+        pluginCtx,
+        sysuiCtx,
+        baseDir,
+        colorScheme = null,
+        seedColor = null,
+        overrideChroma = null,
+        typefaceCache =
+            TypefaceCache(messageBuffer) { Typeface.createFromAsset(pluginCtx.assets, it) },
+        getThemeSeedColor = getThemeSeedColor ?: Companion::getThemeSeedColor,
+        messageBuffer = messageBuffer,
+    )
+
+    fun listAssets(path: String): List<String> {
+        return pluginCtx.resources.assets.list("$baseDir$path")?.toList() ?: emptyList()
+    }
+
+    fun tryReadString(resStr: String): String? = tryRead(resStr, ::readString)
+
+    fun readString(resStr: String): String {
+        val resPair = resolveResourceId(resStr)
+        if (resPair == null) {
+            throw IOException("Failed to parse string: $resStr")
+        }
+
+        val (res, id) = resPair
+        return res.getString(id)
+    }
+
+    fun tryReadColor(resStr: String): Int? = tryRead(resStr, ::readColor)
+
+    fun readColor(resStr: String): Int {
+        if (resStr.startsWith("#")) {
+            return Color.parseColor(resStr)
+        }
+
+        val schemeColor = tryParseColorFromScheme(resStr)
+        if (schemeColor != null) {
+            logColor("ColorScheme: $resStr", schemeColor)
+            return checkChroma(schemeColor)
+        }
+
+        val result = resolveColorResourceId(resStr)
+        if (result == null) {
+            throw IOException("Failed to parse color: $resStr")
+        }
+
+        val (res, colorId, targetTone) = result
+        val color = res.getColor(colorId)
+        if (targetTone == null || TonalPalette.SHADE_KEYS.contains(targetTone.toInt())) {
+            logColor("Resources: $resStr", color)
+            return checkChroma(color)
+        } else {
+            val interpolatedColor =
+                ColorStateList.valueOf(color)
+                    .withLStar((1000f - targetTone) / 10f)
+                    .getDefaultColor()
+            logColor("Resources (interpolated tone): $resStr", interpolatedColor)
+            return checkChroma(interpolatedColor)
+        }
+    }
+
+    private fun checkChroma(color: Int): Int {
+        return overrideChroma?.let {
+            val cam = Cam.fromInt(color)
+            val tone = CamUtils.lstarFromInt(color)
+            val result = ColorUtils.CAMToColor(cam.hue, it, tone)
+            logColor("Chroma override", result)
+            result
+        } ?: color
+    }
+
+    private fun tryParseColorFromScheme(resStr: String): Int? {
+        val colorScheme = this.colorScheme
+        if (colorScheme == null) {
+            logger.w("No color scheme available")
+            return null
+        }
+
+        val (packageName, category, name) = parseResourceId(resStr)
+        if (packageName != "android" || category != "color") {
+            logger.w("Failed to parse package from $resStr")
+            return null
+        }
+
+        var parts = name.split('_')
+        if (parts.size != 3) {
+            logger.w("Failed to find palette and shade from $name")
+            return null
+        }
+        val (_, paletteKey, shadeKeyStr) = parts
+
+        val palette =
+            when (paletteKey) {
+                "accent1" -> colorScheme.accent1
+                "accent2" -> colorScheme.accent2
+                "accent3" -> colorScheme.accent3
+                "neutral1" -> colorScheme.neutral1
+                "neutral2" -> colorScheme.neutral2
+                else -> return null
+            }
+
+        if (shadeKeyStr.contains("+") || shadeKeyStr.contains("-")) {
+            val signIndex = shadeKeyStr.indexOfLast { it == '-' || it == '+' }
+            // Use the tone of the seed color if it was set explicitly.
+            var baseTone =
+                if (seedColor != null) colorScheme.seedTone.toFloat()
+                else shadeKeyStr.substring(0, signIndex).toFloatOrNull()
+            val diff = shadeKeyStr.substring(signIndex).toFloatOrNull()
+
+            if (baseTone == null) {
+                logger.w("Failed to parse base tone from $shadeKeyStr")
+                return null
+            }
+
+            if (diff == null) {
+                logger.w("Failed to parse relative tone from $shadeKeyStr")
+                return null
+            }
+            return palette.getAtTone(baseTone + diff)
+        } else {
+            val shadeKey = shadeKeyStr.toIntOrNull()
+            if (shadeKey == null) {
+                logger.w("Failed to parse tone from $shadeKeyStr")
+                return null
+            }
+            return palette.allShadesMapped.get(shadeKey) ?: palette.getAtTone(shadeKey.toFloat())
+        }
+    }
+
+    fun readFontAsset(resStr: String): Typeface = typefaceCache.getTypeface(resStr)
+
+    fun tryReadTextAsset(path: String?): String? = tryRead(path, ::readTextAsset)
+
+    fun readTextAsset(path: String): String {
+        return pluginCtx.resources.assets.open("$baseDir$path").use { stream ->
+            val buffer = ByteArray(stream.available())
+            stream.read(buffer)
+            String(buffer)
+        }
+    }
+
+    fun tryReadDrawableAsset(path: String?): Drawable? = tryRead(path, ::readDrawableAsset)
+
+    fun readDrawableAsset(path: String): Drawable {
+        var result: Drawable?
+
+        if (path.startsWith("@")) {
+            val pair = resolveResourceId(path)
+            if (pair == null) {
+                throw IOException("Failed to parse $path to an id")
+            }
+            val (res, id) = pair
+            result = res.getDrawable(id)
+        } else if (path.endsWith("xml")) {
+            // TODO(b/248609434): Support xml files in assets
+            throw IOException("Cannot load xml files from assets")
+        } else {
+            // Attempt to load as if it's a bitmap and directly loadable
+            result =
+                pluginCtx.resources.assets.open("$baseDir$path").use { stream ->
+                    Drawable.createFromResourceStream(
+                        pluginCtx.resources,
+                        TypedValue(),
+                        stream,
+                        null,
+                    )
+                }
+        }
+
+        return result ?: throw IOException("Failed to load: $baseDir$path")
+    }
+
+    fun parseResourceId(resStr: String): Triple<String?, String, String> {
+        if (!resStr.startsWith("@")) {
+            throw IOException("Invalid resource id: $resStr; Must start with '@'")
+        }
+
+        // Parse out resource string
+        val parts = resStr.drop(1).split('/', ':')
+        return when (parts.size) {
+            2 -> Triple(null, parts[0], parts[1])
+            3 -> Triple(parts[0], parts[1], parts[2])
+            else -> throw IOException("Failed to parse resource string: $resStr")
+        }
+    }
+
+    fun resolveColorResourceId(resStr: String): Triple<Resources, Int, Float?>? {
+        var (packageName, category, name) = parseResourceId(resStr)
+
+        // Convert relative tonal specifiers to standard
+        val relIndex = name.indexOfLast { it == '_' }
+        val isToneRelative = name.contains("-") || name.contains("+")
+        val targetTone =
+            if (packageName != "android") {
+                null
+            } else if (isToneRelative) {
+                val signIndex = name.indexOfLast { it == '-' || it == '+' }
+                val baseTone = name.substring(relIndex + 1, signIndex).toFloatOrNull()
+                var diff = name.substring(signIndex).toFloatOrNull()
+                if (baseTone == null || diff == null) {
+                    logger.w("Failed to parse relative tone from $name")
+                    return null
+                }
+                baseTone + diff
+            } else {
+                val absTone = name.substring(relIndex + 1).toFloatOrNull()
+                if (absTone == null) {
+                    logger.w("Failed to parse absolute tone from $name")
+                    return null
+                }
+                absTone
+            }
+
+        if (
+            targetTone != null &&
+                (isToneRelative || !TonalPalette.SHADE_KEYS.contains(targetTone.toInt()))
+        ) {
+            val closeTone = TonalPalette.SHADE_KEYS.minBy { abs(it - targetTone) }
+            val prevName = name
+            name = name.substring(0, relIndex + 1) + closeTone
+            logger.i("Converted $prevName to $name")
+        }
+
+        val result = resolveResourceId(packageName, category, name)
+        if (result == null) {
+            return null
+        }
+
+        val (res, resId) = result
+        return Triple(res, resId, targetTone)
+    }
+
+    fun resolveResourceId(resStr: String): Pair<Resources, Int>? {
+        val (packageName, category, name) = parseResourceId(resStr)
+        return resolveResourceId(packageName, category, name)
+    }
+
+    fun resolveResourceId(
+        packageName: String?,
+        category: String,
+        name: String,
+    ): Pair<Resources, Int>? {
+        for ((res, ctxPkgName) in resources) {
+            val result = res.getIdentifier(name, category, packageName ?: ctxPkgName)
+            if (result != 0) {
+                return Pair(res, result)
+            }
+        }
+        return null
+    }
+
+    private fun <TArg : Any, TRes : Any> tryRead(arg: TArg?, fn: (TArg) -> TRes): TRes? {
+        try {
+            if (arg == null) {
+                return null
+            }
+            return fn(arg)
+        } catch (ex: IOException) {
+            logger.w("Failed to read $arg", ex)
+            return null
+        }
+    }
+
+    fun assetExists(path: String): Boolean {
+        try {
+            if (path.startsWith("@")) {
+                val pair = resolveResourceId(path)
+                val colorPair = resolveColorResourceId(path)
+                return pair != null || colorPair != null
+            } else {
+                val stream = pluginCtx.resources.assets.open("$baseDir$path")
+                if (stream == null) {
+                    return false
+                }
+
+                stream.close()
+                return true
+            }
+        } catch (ex: IOException) {
+            return false
+        }
+    }
+
+    fun copy(messageBuffer: MessageBuffer? = null): AssetLoader =
+        AssetLoader(
+            pluginCtx,
+            sysuiCtx,
+            baseDir,
+            colorScheme,
+            seedColor,
+            overrideChroma,
+            typefaceCache,
+            getThemeSeedColor,
+            messageBuffer ?: logger.buffer,
+        )
+
+    fun setSeedColor(seedColor: Int?, style: MonetStyle?) {
+        this.seedColor = seedColor
+        refreshColorPalette(style)
+    }
+
+    fun refreshColorPalette(style: MonetStyle?) {
+        val seedColor =
+            this.seedColor ?: getThemeSeedColor(sysuiCtx).also { logColor("Theme Seed Color", it) }
+        this.colorScheme =
+            ColorScheme(
+                seedColor,
+                false, // darkTheme is not used for palette generation
+                style ?: MonetStyle.CLOCK,
+            )
+
+        // Enforce low chroma on output colors if low chroma theme is selected
+        this.overrideChroma = run {
+            val cam = colorScheme?.seed?.let { Cam.fromInt(it) }
+            if (cam != null && cam.chroma < LOW_CHROMA_LIMIT) {
+                return@run cam.chroma * LOW_CHROMA_SCALE
+            }
+            return@run null
+        }
+    }
+
+    fun getClockPaddingStart(): Int {
+        val result = resolveResourceId(null, "dimen", "clock_padding_start")
+        if (result != null) {
+            val (res, id) = result
+            return res.getDimensionPixelSize(id)
+        }
+        return -1
+    }
+
+    fun getStatusBarHeight(): Int {
+        val display = pluginCtx.getDisplayNoVerify()
+        if (display != null) {
+            return SystemBarUtils.getStatusBarHeight(pluginCtx.resources, display.cutout)
+        }
+
+        logger.w("No display available; falling back to android.R.dimen.status_bar_height")
+        val statusBarHeight = resolveResourceId("android", "dimen", "status_bar_height")
+        if (statusBarHeight != null) {
+            val (res, resId) = statusBarHeight
+            return res.getDimensionPixelSize(resId)
+        }
+
+        throw Exception("Could not fetch StatusBarHeight")
+    }
+
+    fun getResourcesId(name: String): Int = getResource("id", name) { _, id -> id }
+
+    fun getDimen(name: String): Int = getResource("dimen", name, Resources::getDimensionPixelSize)
+
+    fun getString(name: String): String = getResource("string", name, Resources::getString)
+
+    private fun <T> getResource(
+        category: String,
+        name: String,
+        getter: (res: Resources, id: Int) -> T,
+    ): T {
+        val result = resolveResourceId(null, category, name)
+        if (result != null) {
+            val (res, id) = result
+            if (id == -1) throw Exception("Cannot find id of $id from $TAG")
+            return getter(res, id)
+        }
+        throw Exception("Cannot find id of $name from $TAG")
+    }
+
+    private fun logColor(name: String, color: Int) {
+        if (DEBUG_COLOR) {
+            val cam = Cam.fromInt(color)
+            val tone = CamUtils.lstarFromInt(color)
+            logger.i("$name -> (hue: ${cam.hue}, chroma: ${cam.chroma}, tone: $tone)")
+        }
+    }
+
+    companion object {
+        private val DEBUG_COLOR = true
+        private val LOW_CHROMA_LIMIT = 15
+        private val LOW_CHROMA_SCALE = 1.5f
+        private val TAG = AssetLoader::class.simpleName!!
+
+        private fun getThemeSeedColor(ctx: Context): Int {
+            return ctx.resources.getColor(android.R.color.system_palette_key_color_primary_light)
+        }
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt
new file mode 100644
index 0000000..5a04169
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.systemui.shared.clocks
+
+object ClockAnimation {
+    const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt
new file mode 100644
index 0000000..f5e8432
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt
@@ -0,0 +1,288 @@
+/*
+ * 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.systemui.shared.clocks
+
+import android.graphics.Point
+import android.view.animation.Interpolator
+import com.android.app.animation.Interpolators
+import com.android.internal.annotations.Keep
+import com.android.systemui.monet.Style as MonetStyle
+import com.android.systemui.shared.clocks.view.HorizontalAlignment
+import com.android.systemui.shared.clocks.view.VerticalAlignment
+
+/** Data format for a simple asset-defined clock */
+@Keep
+data class ClockDesign(
+    val id: String,
+    val name: String? = null,
+    val description: String? = null,
+    val thumbnail: String? = null,
+    val large: ClockFace? = null,
+    val small: ClockFace? = null,
+    val colorPalette: MonetStyle? = null,
+)
+
+/** Describes a clock using layers */
+@Keep
+data class ClockFace(
+    val layers: List<ClockLayer> = listOf<ClockLayer>(),
+    val layerBounds: LayerBounds = LayerBounds.FIT,
+    val wallpaper: String? = null,
+    val faceLayout: DigitalFaceLayout? = null,
+    val pickerScale: ClockFaceScaleInPicker? = ClockFaceScaleInPicker(1.0f, 1.0f),
+)
+
+@Keep data class ClockFaceScaleInPicker(val scaleX: Float, val scaleY: Float)
+
+/** Base Type for a Clock Layer */
+@Keep
+interface ClockLayer {
+    /** Override of face LayerBounds setting for this layer */
+    val layerBounds: LayerBounds?
+}
+
+/** Clock layer that renders a static asset */
+@Keep
+data class AssetLayer(
+    /** Asset to render in this layer */
+    val asset: AssetReference,
+    override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+/** Clock layer that renders the time (or a component of it) using numerals */
+@Keep
+data class DigitalHandLayer(
+    /** See SimpleDateFormat for timespec format info */
+    val timespec: DigitalTimespec,
+    val style: TextStyle,
+    // adoStyle concrete type must match style,
+    // cause styles will transition between style and aodStyle
+    val aodStyle: TextStyle?,
+    val timer: Int? = null,
+    override val layerBounds: LayerBounds? = null,
+    var faceLayout: DigitalFaceLayout? = null,
+    // we pass 12-hour format from json, which will be converted to 24-hour format in codes
+    val dateTimeFormat: String,
+    val alignment: DigitalAlignment?,
+    // ratio of margins to measured size, currently used for handwritten clocks
+    val marginRatio: DigitalMarginRatio? = DigitalMarginRatio(),
+) : ClockLayer
+
+/** Clock layer that renders the time (or a component of it) using numerals */
+@Keep
+data class ComposedDigitalHandLayer(
+    val customizedView: String? = null,
+    /** See SimpleDateFormat for timespec format info */
+    val digitalLayers: List<DigitalHandLayer> = listOf<DigitalHandLayer>(),
+    override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+@Keep
+data class DigitalAlignment(
+    val horizontalAlignment: HorizontalAlignment?,
+    val verticalAlignment: VerticalAlignment?,
+)
+
+@Keep
+data class DigitalMarginRatio(
+    val left: Float = 0F,
+    val top: Float = 0F,
+    val right: Float = 0F,
+    val bottom: Float = 0F,
+)
+
+/** Clock layer which renders a component of the time using an analog hand */
+@Keep
+data class AnalogHandLayer(
+    val timespec: AnalogTimespec,
+    val tickMode: AnalogTickMode,
+    val asset: AssetReference,
+    val timer: Int? = null,
+    val clock_pivot: Point = Point(0, 0),
+    val asset_pivot: Point? = null,
+    val length: Float = 1f,
+    override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+/** Clock layer which renders the time using an AVD */
+@Keep
+data class AnimatedHandLayer(
+    val timespec: AnalogTimespec,
+    val asset: AssetReference,
+    val timer: Int? = null,
+    override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+/** A collection of asset references for use in different device modes */
+@Keep
+data class AssetReference(
+    val light: String,
+    val dark: String,
+    val doze: String? = null,
+    val lightTint: String? = null,
+    val darkTint: String? = null,
+    val dozeTint: String? = null,
+)
+
+/**
+ * Core TextStyling attributes for text clocks. Both color and sizing information can be applied to
+ * either subtype.
+ */
+@Keep
+interface TextStyle {
+    // fontSizeScale is a scale factor applied to the default clock's font size.
+    val fontSizeScale: Float?
+}
+
+/**
+ * This specifies a font and styling parameters for that font. This is rendered using a text view
+ * and the text animation classes used by the default clock. To ensure default value take effects,
+ * all parameters MUST have a default value
+ */
+@Keep
+data class FontTextStyle(
+    // Font to load and use in the TextView
+    val fontFamily: String? = null,
+    val lineHeight: Float? = null,
+    val borderWidth: String? = null,
+    // ratio of borderWidth / fontSize
+    val borderWidthScale: Float? = null,
+    // A color literal like `#FF00FF` or a color resource like `@android:color/system_accent1_100`
+    val fillColorLight: String? = null,
+    // A color literal like `#FF00FF` or a color resource like `@android:color/system_accent1_100`
+    val fillColorDark: String? = null,
+    override val fontSizeScale: Float? = null,
+    /**
+     * use `wdth` for width, `wght` for weight, 'opsz' for optical size single quote for tag name,
+     * and no quote for value separate different axis with `,` e.g. "'wght' 1000, 'wdth' 108, 'opsz'
+     * 90"
+     */
+    var fontVariation: String? = null,
+    // used when alternate in one font file is needed
+    var fontFeatureSettings: String? = null,
+    val renderType: RenderType = RenderType.STROKE_TEXT,
+    val outlineColor: String? = null,
+    val transitionDuration: Long = -1L,
+    val transitionInterpolator: InterpolatorEnum? = null,
+) : TextStyle
+
+/**
+ * As an alternative to using a font, we can instead render a digital clock using a set of drawables
+ * for each numeral, and optionally a colon. These drawables will be rendered directly after sizing
+ * and placing them. This may be easier than generating a font file in some cases, and is provided
+ * for ease of use. Unlike fonts, these are not localizable to other numeric systems (like Burmese).
+ */
+@Keep
+data class LottieTextStyle(
+    val numbers: List<String> = listOf(),
+    // Spacing between numbers, dimension string
+    val spacing: String = "0dp",
+    // Colon drawable may be omitted if unused in format spec
+    val colon: String? = null,
+    // key is keypath name to get strokes from lottie, value is the color name to query color in
+    // palette, e.g. @android:color/system_accent1_100
+    val fillColorLightMap: Map<String, String>? = null,
+    val fillColorDarkMap: Map<String, String>? = null,
+    override val fontSizeScale: Float? = null,
+    val paddingVertical: String = "0dp",
+    val paddingHorizontal: String = "0dp",
+) : TextStyle
+
+/** Layer sizing mode for the clockface or layer */
+enum class LayerBounds {
+    /**
+     * Sized so the larger dimension matches the allocated space. This results in some of the
+     * allocated space being unused.
+     */
+    FIT,
+
+    /**
+     * Sized so the smaller dimension matches the allocated space. This will clip some content to
+     * the edges of the space.
+     */
+    FILL,
+
+    /** Fills the allocated space exactly by stretching the layer */
+    STRETCH,
+}
+
+/** Ticking mode for analog hands. */
+enum class AnalogTickMode {
+    SWEEP,
+    TICK,
+}
+
+/** Timspec options for Analog Hands. Named for tick interval. */
+enum class AnalogTimespec {
+    SECONDS,
+    MINUTES,
+    HOURS,
+    HOURS_OF_DAY,
+    DAY_OF_WEEK,
+    DAY_OF_MONTH,
+    DAY_OF_YEAR,
+    WEEK,
+    MONTH,
+    TIMER,
+}
+
+enum class DigitalTimespec {
+    TIME_FULL_FORMAT,
+    DIGIT_PAIR,
+    FIRST_DIGIT,
+    SECOND_DIGIT,
+    DATE_FORMAT,
+}
+
+enum class DigitalFaceLayout {
+    // can only use HH_PAIR, MM_PAIR from DigitalTimespec
+    TWO_PAIRS_VERTICAL,
+    TWO_PAIRS_HORIZONTAL,
+    // can only use HOUR_FIRST_DIGIT, HOUR_SECOND_DIGIT, MINUTE_FIRST_DIGIT, MINUTE_SECOND_DIGIT
+    // from DigitalTimespec, used for tabular layout when the font doesn't support tnum
+    FOUR_DIGITS_ALIGN_CENTER,
+    FOUR_DIGITS_HORIZONTAL,
+}
+
+enum class RenderType {
+    CHANGE_WEIGHT,
+    HOLLOW_TEXT,
+    STROKE_TEXT,
+    OUTER_OUTLINE_TEXT,
+}
+
+enum class InterpolatorEnum(factory: () -> Interpolator) {
+    STANDARD({ Interpolators.STANDARD }),
+    EMPHASIZED({ Interpolators.EMPHASIZED });
+
+    val interpolator: Interpolator by lazy(factory)
+}
+
+fun generateDigitalLayerIdString(layer: DigitalHandLayer): String {
+    return if (
+        layer.timespec == DigitalTimespec.TIME_FULL_FORMAT ||
+            layer.timespec == DigitalTimespec.DATE_FORMAT
+    ) {
+        layer.timespec.toString()
+    } else {
+        if ("h" in layer.dateTimeFormat) {
+            "HOUR" + "_" + layer.timespec.toString()
+        } else {
+            "MINUTE" + "_" + layer.timespec.toString()
+        }
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt
index 954155d..9da3022 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt
@@ -65,7 +65,7 @@
 private fun <TKey : Any, TVal : Any> ConcurrentHashMap<TKey, TVal>.concurrentGetOrPut(
     key: TKey,
     value: TVal,
-    onNew: (TVal) -> Unit
+    onNew: (TVal) -> Unit,
 ): TVal {
     val result = this.putIfAbsent(key, value)
     if (result == null) {
@@ -110,7 +110,7 @@
                 selfChange: Boolean,
                 uris: Collection<Uri>,
                 flags: Int,
-                userId: Int
+                userId: Int,
             ) {
                 scope.launch(bgDispatcher) { querySettings() }
             }
@@ -180,7 +180,7 @@
             override fun onPluginLoaded(
                 plugin: ClockProviderPlugin,
                 pluginContext: Context,
-                manager: PluginLifecycleManager<ClockProviderPlugin>
+                manager: PluginLifecycleManager<ClockProviderPlugin>,
             ) {
                 plugin.initialize(clockBuffers)
 
@@ -218,7 +218,7 @@
 
             override fun onPluginUnloaded(
                 plugin: ClockProviderPlugin,
-                manager: PluginLifecycleManager<ClockProviderPlugin>
+                manager: PluginLifecycleManager<ClockProviderPlugin>,
             ) {
                 for (clock in plugin.getClocks()) {
                     val id = clock.clockId
@@ -290,12 +290,12 @@
                         Settings.Secure.getStringForUser(
                             context.contentResolver,
                             Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
-                            ActivityManager.getCurrentUser()
+                            ActivityManager.getCurrentUser(),
                         )
                     } else {
                         Settings.Secure.getString(
                             context.contentResolver,
-                            Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE
+                            Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
                         )
                     }
 
@@ -320,13 +320,13 @@
                     context.contentResolver,
                     Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
                     json,
-                    ActivityManager.getCurrentUser()
+                    ActivityManager.getCurrentUser(),
                 )
             } else {
                 Settings.Secure.putString(
                     context.contentResolver,
                     Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
-                    json
+                    json,
                 )
             }
         } catch (ex: Exception) {
@@ -418,7 +418,7 @@
         pluginManager.addPluginListener(
             pluginListener,
             ClockProviderPlugin::class.java,
-            /*allowMultiple=*/ true
+            /*allowMultiple=*/ true,
         )
 
         scope.launch(bgDispatcher) { querySettings() }
@@ -427,7 +427,7 @@
                 Settings.Secure.getUriFor(Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE),
                 /*notifyForDescendants=*/ false,
                 settingObserver,
-                UserHandle.USER_ALL
+                UserHandle.USER_ALL,
             )
 
             ActivityManager.getService().registerUserSwitchObserver(userSwitchObserver, TAG)
@@ -435,7 +435,7 @@
             context.contentResolver.registerContentObserver(
                 Settings.Secure.getUriFor(Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE),
                 /*notifyForDescendants=*/ false,
-                settingObserver
+                settingObserver,
             )
         }
     }
@@ -504,7 +504,7 @@
         val isCurrent = currentClockId == info.metadata.clockId
         logger.log(
             if (isCurrent) LogLevel.INFO else LogLevel.DEBUG,
-            { "Connected $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+            { "Connected $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
         ) {
             str1 = info.metadata.clockId
             str2 = info.manager.toString()
@@ -516,7 +516,7 @@
         val isCurrent = currentClockId == info.metadata.clockId
         logger.log(
             if (isCurrent) LogLevel.INFO else LogLevel.DEBUG,
-            { "Loaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+            { "Loaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
         ) {
             str1 = info.metadata.clockId
             str2 = info.manager.toString()
@@ -532,7 +532,7 @@
         val isCurrent = currentClockId == info.metadata.clockId
         logger.log(
             if (isCurrent) LogLevel.WARNING else LogLevel.DEBUG,
-            { "Unloaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+            { "Unloaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
         ) {
             str1 = info.metadata.clockId
             str2 = info.manager.toString()
@@ -548,7 +548,7 @@
         val isCurrent = currentClockId == info.metadata.clockId
         logger.log(
             if (isCurrent) LogLevel.INFO else LogLevel.DEBUG,
-            { "Disconnected $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+            { "Disconnected $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
         ) {
             str1 = info.metadata.clockId
             str2 = info.manager.toString()
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt
index 4802e34..07191c6 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt
@@ -34,7 +34,7 @@
     val layoutInflater: LayoutInflater,
     val resources: Resources,
     val hasStepClockAnimation: Boolean = false,
-    val migratedClocks: Boolean = false
+    val migratedClocks: Boolean = false,
 ) : ClockProvider {
     private var messageBuffers: ClockMessageBuffers? = null
 
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt
new file mode 100644
index 0000000..3869706
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.systemui.shared.clocks
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.TimeInterpolator
+import android.animation.ValueAnimator
+import android.graphics.Point
+
+class DigitTranslateAnimator(val updateCallback: () -> Unit) {
+    val DEFAULT_ANIMATION_DURATION = 500L
+    val updatedTranslate = Point(0, 0)
+
+    val baseTranslation = Point(0, 0)
+    var targetTranslation: Point? = null
+    val bounceAnimator: ValueAnimator =
+        ValueAnimator.ofFloat(1f).apply {
+            duration = DEFAULT_ANIMATION_DURATION
+            addUpdateListener {
+                updateTranslation(it.animatedFraction, updatedTranslate)
+                updateCallback()
+            }
+            addListener(
+                object : AnimatorListenerAdapter() {
+                    override fun onAnimationEnd(animation: Animator) {
+                        rebase()
+                    }
+
+                    override fun onAnimationCancel(animation: Animator) {
+                        rebase()
+                    }
+                }
+            )
+        }
+
+    fun rebase() {
+        baseTranslation.x = updatedTranslate.x
+        baseTranslation.y = updatedTranslate.y
+    }
+
+    fun animatePosition(
+        animate: Boolean = true,
+        delay: Long = 0,
+        duration: Long = -1L,
+        interpolator: TimeInterpolator? = null,
+        targetTranslation: Point? = null,
+        onAnimationEnd: Runnable? = null,
+    ) {
+        this.targetTranslation = targetTranslation ?: Point(0, 0)
+        if (animate) {
+            bounceAnimator.cancel()
+            bounceAnimator.startDelay = delay
+            bounceAnimator.duration =
+                if (duration == -1L) {
+                    DEFAULT_ANIMATION_DURATION
+                } else {
+                    duration
+                }
+            interpolator?.let { bounceAnimator.interpolator = it }
+            if (onAnimationEnd != null) {
+                val listener =
+                    object : AnimatorListenerAdapter() {
+                        override fun onAnimationEnd(animation: Animator) {
+                            onAnimationEnd.run()
+                            bounceAnimator.removeListener(this)
+                        }
+
+                        override fun onAnimationCancel(animation: Animator) {
+                            bounceAnimator.removeListener(this)
+                        }
+                    }
+                bounceAnimator.addListener(listener)
+            }
+            bounceAnimator.start()
+        } else {
+            // No animation is requested, thus set base and target state to the same state.
+            updateTranslation(1F, updatedTranslate)
+            rebase()
+            updateCallback()
+        }
+    }
+
+    fun updateTranslation(progress: Float, outPoint: Point) {
+        outPoint.x =
+            (baseTranslation.x + progress * (targetTranslation!!.x - baseTranslation.x)).toInt()
+        outPoint.y =
+            (baseTranslation.y + progress * (targetTranslation!!.y - baseTranslation.y)).toInt()
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt
new file mode 100644
index 0000000..2be6c65
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt
@@ -0,0 +1,64 @@
+/*
+ * 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.systemui.shared.clocks
+
+import android.content.Context
+import android.util.TypedValue
+import java.util.regex.Pattern
+
+class DimensionParser(private val ctx: Context) {
+    fun convert(dimension: String?): Float? {
+        if (dimension == null) {
+            return null
+        }
+        return convert(dimension)
+    }
+
+    fun convert(dimension: String): Float {
+        val metrics = ctx.resources.displayMetrics
+        val (value, unit) = parse(dimension)
+        return TypedValue.applyDimension(unit, value, metrics)
+    }
+
+    fun parse(dimension: String): Pair<Float, Int> {
+        val matcher = parserPattern.matcher(dimension)
+        if (!matcher.matches()) {
+            throw NumberFormatException("Failed to parse '$dimension'")
+        }
+
+        val value =
+            matcher.group(1)?.toFloat() ?: throw NumberFormatException("Bad value in '$dimension'")
+        val unit =
+            dimensionMap.get(matcher.group(3) ?: "")
+                ?: throw NumberFormatException("Bad unit in '$dimension'")
+        return Pair(value, unit)
+    }
+
+    private companion object {
+        val parserPattern = Pattern.compile("(\\d+(\\.\\d+)?)([a-z]+)")
+        val dimensionMap =
+            mapOf(
+                "dp" to TypedValue.COMPLEX_UNIT_DIP,
+                "dip" to TypedValue.COMPLEX_UNIT_DIP,
+                "sp" to TypedValue.COMPLEX_UNIT_SP,
+                "px" to TypedValue.COMPLEX_UNIT_PX,
+                "pt" to TypedValue.COMPLEX_UNIT_PT,
+                "mm" to TypedValue.COMPLEX_UNIT_MM,
+                "in" to TypedValue.COMPLEX_UNIT_IN,
+            )
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt
new file mode 100644
index 0000000..34cb4ef
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.systemui.shared.clocks
+
+import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.core.LogcatOnlyMessageBuffer
+import com.android.systemui.log.core.Logger
+
+object LogUtil {
+    // Used when MessageBuffers are not provided by the host application
+    val DEFAULT_MESSAGE_BUFFER = LogcatOnlyMessageBuffer(LogLevel.INFO)
+
+    // Only intended for use during initialization steps where the correct logger doesn't exist yet
+    val FALLBACK_INIT_LOGGER = Logger(LogcatOnlyMessageBuffer(LogLevel.ERROR), "CLOCK_INIT")
+
+    // Debug is primarially used for tests, but can also be used for tracking down hard issues.
+    val DEBUG_MESSAGE_BUFFER = LogcatOnlyMessageBuffer(LogLevel.DEBUG)
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt
new file mode 100644
index 0000000..f5a9375
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.systemui.shared.clocks
+
+import android.graphics.Typeface
+import com.android.systemui.animation.TypefaceVariantCache
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import java.lang.ref.ReferenceQueue
+import java.lang.ref.WeakReference
+
+class TypefaceCache(messageBuffer: MessageBuffer, val typefaceFactory: (String) -> Typeface) {
+    private val logger = Logger(messageBuffer, this::class.simpleName!!)
+
+    private data class CacheKey(val res: String, val fvar: String?)
+
+    private inner class WeakTypefaceRef(val key: CacheKey, typeface: Typeface) :
+        WeakReference<Typeface>(typeface, queue)
+
+    private var totalHits = 0
+
+    private var totalMisses = 0
+
+    private var totalEvictions = 0
+
+    // We use a map of WeakRefs here instead of an LruCache. This prevents needing to resize the
+    // cache depending on the number of distinct fonts used by a clock, as different clocks have
+    // different numbers of simultaneously loaded and configured fonts. Because our clocks tend to
+    // initialize a number of parallel views and animators, our usages of Typefaces overlap. As a
+    // result, once a typeface is no longer being used, it is unlikely to be recreated immediately.
+    private val cache = mutableMapOf<CacheKey, WeakTypefaceRef>()
+    private val queue = ReferenceQueue<Typeface>()
+
+    fun getTypeface(res: String): Typeface {
+        checkQueue()
+        val key = CacheKey(res, null)
+        cache.get(key)?.get()?.let {
+            logHit(key)
+            return it
+        }
+
+        logMiss(key)
+        val result = typefaceFactory(res)
+        cache.put(key, WeakTypefaceRef(key, result))
+        return result
+    }
+
+    fun getVariantCache(res: String): TypefaceVariantCache {
+        val baseTypeface = getTypeface(res)
+        return object : TypefaceVariantCache {
+            override fun getTypefaceForVariant(fvar: String?): Typeface? {
+                checkQueue()
+                val key = CacheKey(res, fvar)
+                cache.get(key)?.get()?.let {
+                    logHit(key)
+                    return it
+                }
+
+                logMiss(key)
+                return TypefaceVariantCache.createVariantTypeface(baseTypeface, fvar).also {
+                    cache.put(key, WeakTypefaceRef(key, it))
+                }
+            }
+        }
+    }
+
+    private fun logHit(key: CacheKey) {
+        totalHits++
+        if (DEBUG_HITS)
+            logger.i({ "HIT: $str1; Total: $int1" }) {
+                str1 = key.toString()
+                int1 = totalHits
+            }
+    }
+
+    private fun logMiss(key: CacheKey) {
+        totalMisses++
+        logger.w({ "MISS: $str1; Total: $int1" }) {
+            str1 = key.toString()
+            int1 = totalMisses
+        }
+    }
+
+    private fun logEviction(key: CacheKey) {
+        totalEvictions++
+        logger.i({ "EVICTED: $str1; Total: $int1" }) {
+            str1 = key.toString()
+            int1 = totalEvictions
+        }
+    }
+
+    private fun checkQueue() =
+        generateSequence { queue.poll() }
+            .filterIsInstance<WeakTypefaceRef>()
+            .forEach {
+                logEviction(it.key)
+                cache.remove(it.key)
+            }
+
+    companion object {
+        private val DEBUG_HITS = false
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt
new file mode 100644
index 0000000..eb72346
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt
@@ -0,0 +1,180 @@
+/*
+ * 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.systemui.shared.clocks.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Point
+import android.view.View
+import android.widget.FrameLayout
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.plugins.clocks.AlarmData
+import com.android.systemui.plugins.clocks.WeatherData
+import com.android.systemui.plugins.clocks.ZenData
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.LogUtil
+import java.util.Locale
+
+abstract class DigitalClockFaceView(ctx: Context, messageBuffer: MessageBuffer) : FrameLayout(ctx) {
+    protected val logger = Logger(messageBuffer, this::class.simpleName!!)
+        get() = field ?: LogUtil.FALLBACK_INIT_LOGGER
+
+    abstract var digitalClockTextViewMap: MutableMap<Int, SimpleDigitalClockTextView>
+
+    @VisibleForTesting
+    var isAnimationEnabled = true
+        set(value) {
+            field = value
+            digitalClockTextViewMap.forEach { _, view -> view.isAnimationEnabled = value }
+        }
+
+    var dozeFraction: Float = 0F
+        set(value) {
+            field = value
+            digitalClockTextViewMap.forEach { _, view -> view.dozeFraction = field }
+        }
+
+    val dozeControlState = DozeControlState()
+
+    var isReactiveTouchInteractionEnabled = false
+        set(value) {
+            field = value
+        }
+
+    open val text: String?
+        get() = null
+
+    open fun refreshTime() = logger.d("refreshTime()")
+
+    override fun invalidate() {
+        logger.d("invalidate()")
+        super.invalidate()
+    }
+
+    override fun requestLayout() {
+        logger.d("requestLayout()")
+        super.requestLayout()
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        logger.d("onMeasure()")
+        calculateSize(widthMeasureSpec, heightMeasureSpec)?.let { setMeasuredDimension(it.x, it.y) }
+            ?: run { super.onMeasure(widthMeasureSpec, heightMeasureSpec) }
+        calculateLeftTopPosition()
+        dozeControlState.animateReady = true
+    }
+
+    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
+        logger.d("onLayout()")
+        super.onLayout(changed, left, top, right, bottom)
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        text?.let { logger.d({ "onDraw($str1)" }) { str1 = it } } ?: run { logger.d("onDraw()") }
+        super.onDraw(canvas)
+    }
+
+    /*
+     * Called in onMeasure to generate width/height overrides to the normal measuring logic. A null
+     * result causes the normal view measuring logic to execute.
+     */
+    protected open fun calculateSize(widthMeasureSpec: Int, heightMeasureSpec: Int): Point? = null
+
+    protected open fun calculateLeftTopPosition() {}
+
+    override fun addView(child: View?) {
+        if (child == null) return
+        logger.d({ "addView($str1 @$int1)" }) {
+            str1 = child::class.simpleName!!
+            int1 = child.id
+        }
+        super.addView(child)
+        if (child is SimpleDigitalClockTextView) {
+            digitalClockTextViewMap[child.id] = child
+        }
+        child.setWillNotDraw(true)
+    }
+
+    open fun animateDoze(isDozing: Boolean, isAnimated: Boolean) {
+        digitalClockTextViewMap.forEach { _, view -> view.animateDoze(isDozing, isAnimated) }
+    }
+
+    open fun animateCharge() {
+        digitalClockTextViewMap.forEach { _, view -> view.animateCharge() }
+    }
+
+    open fun onPositionUpdated(fromLeft: Int, direction: Int, fraction: Float) {}
+
+    fun updateColors(assets: AssetLoader, isRegionDark: Boolean) {
+        digitalClockTextViewMap.forEach { _, view -> view.updateColors(assets, isRegionDark) }
+        invalidate()
+    }
+
+    fun onFontSettingChanged(fontSizePx: Float) {
+        digitalClockTextViewMap.forEach { _, view -> view.applyTextSize(fontSizePx) }
+    }
+
+    open val hasCustomWeatherDataDisplay
+        get() = false
+
+    open val hasCustomPositionUpdatedAnimation
+        get() = false
+
+    /** True if it's large weather clock, will use weatherBlueprint in compose */
+    open val useCustomClockScene
+        get() = false
+
+    // TODO: implement ClockEventUnion?
+    open fun onLocaleChanged(locale: Locale) {}
+
+    open fun onWeatherDataChanged(data: WeatherData) {}
+
+    open fun onAlarmDataChanged(data: AlarmData) {}
+
+    open fun onZenDataChanged(data: ZenData) {}
+
+    open fun onPickerCarouselSwiping(swipingFraction: Float) {}
+
+    open fun isAlignedWithScreen(): Boolean = false
+
+    /**
+     * animateDoze needs correct translate value, which is calculated in onMeasure so we need to
+     * delay this animation when we get correct values
+     */
+    class DozeControlState {
+        var animateDoze: () -> Unit = {}
+            set(value) {
+                if (animateReady) {
+                    value()
+                    field = {}
+                } else {
+                    field = value
+                }
+            }
+
+        var animateReady = false
+            set(value) {
+                if (value) {
+                    animateDoze()
+                    animateDoze = {}
+                }
+                field = value
+            }
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt
new file mode 100644
index 0000000..c29c8da
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.systemui.shared.clocks.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Point
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.RelativeLayout
+import com.android.app.animation.Interpolators
+import com.android.systemui.customization.R
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.DigitTranslateAnimator
+import com.android.systemui.shared.clocks.FontTextStyle
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+fun clamp(value: Float, minVal: Float, maxVal: Float): Float = max(min(value, maxVal), minVal)
+
+class FlexClockView(context: Context, val assetLoader: AssetLoader, messageBuffer: MessageBuffer) :
+    DigitalClockFaceView(context, messageBuffer) {
+    override var digitalClockTextViewMap = mutableMapOf<Int, SimpleDigitalClockTextView>()
+    val digitLeftTopMap = mutableMapOf<Int, Point>()
+    var maxSingleDigitHeight = -1
+    var maxSingleDigitWidth = -1
+    val lockscreenTranslate = Point(0, 0)
+    val aodTranslate = Point(0, 0)
+
+    init {
+        setWillNotDraw(false)
+        layoutParams =
+            RelativeLayout.LayoutParams(
+                ViewGroup.LayoutParams.WRAP_CONTENT,
+                ViewGroup.LayoutParams.WRAP_CONTENT,
+            )
+    }
+
+    private var prevX = 0f
+    private var prevY = 0f
+    private var isDown = false
+
+    // TODO(b/340253296): Genericize; json spec
+    private var wght = 603f
+    private var wdth = 100f
+
+    // TODO(b/340253296): Json spec
+    private val MAX_WGHT = 950f
+    private val MIN_WGHT = 50f
+    private val WGHT_SCALE = 0.5f
+
+    private val MAX_WDTH = 150f
+    private val MIN_WDTH = 0f
+    private val WDTH_SCALE = 0.2f
+
+    override fun onTouchEvent(evt: MotionEvent): Boolean {
+        // TODO(b/340253296): implement on DigitalClockFaceView?
+        if (!isReactiveTouchInteractionEnabled) {
+            return super.onTouchEvent(evt)
+        }
+
+        when (evt.action) {
+            MotionEvent.ACTION_DOWN -> {
+                isDown = true
+                prevX = evt.x
+                prevY = evt.y
+                return true
+            }
+
+            MotionEvent.ACTION_MOVE -> {
+                if (!isDown) {
+                    return super.onTouchEvent(evt)
+                }
+
+                wdth = clamp(wdth + (evt.x - prevX) * WDTH_SCALE, MIN_WDTH, MAX_WDTH)
+                wght = clamp(wght + (evt.y - prevY) * WGHT_SCALE, MIN_WGHT, MAX_WGHT)
+                prevX = evt.x
+                prevY = evt.y
+
+                // TODO(b/340253296): Genericize; json spec
+                val fvar = "'wght' $wght, 'wdth' $wdth, 'opsz' 144, 'ROND' 100"
+                digitalClockTextViewMap.forEach { (_, view) ->
+                    val textStyle = view.textStyle as FontTextStyle
+                    textStyle.fontVariation = fvar
+                    view.applyStyles(assetLoader, textStyle, view.aodStyle)
+                }
+
+                requestLayout()
+                invalidate()
+                return true
+            }
+
+            MotionEvent.ACTION_UP -> {
+                isDown = false
+                return true
+            }
+        }
+
+        return super.onTouchEvent(evt)
+    }
+
+    override fun addView(child: View?) {
+        super.addView(child)
+        (child as SimpleDigitalClockTextView).digitTranslateAnimator =
+            DigitTranslateAnimator(::invalidate)
+    }
+
+    protected override fun calculateSize(widthMeasureSpec: Int, heightMeasureSpec: Int): Point {
+        digitalClockTextViewMap.forEach { (_, textView) ->
+            textView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+        }
+        val textView = digitalClockTextViewMap[R.id.HOUR_FIRST_DIGIT]!!
+        maxSingleDigitHeight = textView.measuredHeight
+        maxSingleDigitWidth = textView.measuredWidth
+        aodTranslate.x = -(maxSingleDigitWidth * AOD_HORIZONTAL_TRANSLATE_RATIO).toInt()
+        aodTranslate.y = (maxSingleDigitHeight * AOD_VERTICAL_TRANSLATE_RATIO).toInt()
+        return Point(
+            ((maxSingleDigitWidth + abs(aodTranslate.x)) * 2),
+            ((maxSingleDigitHeight + abs(aodTranslate.y)) * 2),
+        )
+    }
+
+    protected override fun calculateLeftTopPosition() {
+        digitLeftTopMap[R.id.HOUR_FIRST_DIGIT] = Point(0, 0)
+        digitLeftTopMap[R.id.HOUR_SECOND_DIGIT] = Point(maxSingleDigitWidth, 0)
+        digitLeftTopMap[R.id.MINUTE_FIRST_DIGIT] = Point(0, maxSingleDigitHeight)
+        digitLeftTopMap[R.id.MINUTE_SECOND_DIGIT] = Point(maxSingleDigitWidth, maxSingleDigitHeight)
+        digitLeftTopMap.forEach { _, point ->
+            point.x += abs(aodTranslate.x)
+            point.y += abs(aodTranslate.y)
+        }
+    }
+
+    override fun refreshTime() {
+        super.refreshTime()
+        digitalClockTextViewMap.forEach { (_, textView) -> textView.refreshText() }
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+        digitalClockTextViewMap.forEach { (id, _) ->
+            val textView = digitalClockTextViewMap[id]!!
+            canvas.translate(digitLeftTopMap[id]!!.x.toFloat(), digitLeftTopMap[id]!!.y.toFloat())
+            textView.draw(canvas)
+            canvas.translate(-digitLeftTopMap[id]!!.x.toFloat(), -digitLeftTopMap[id]!!.y.toFloat())
+        }
+    }
+
+    override fun animateDoze(isDozing: Boolean, isAnimated: Boolean) {
+        dozeControlState.animateDoze = {
+            super.animateDoze(isDozing, isAnimated)
+            if (maxSingleDigitHeight == -1) {
+                measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+            }
+            digitalClockTextViewMap.forEach { (id, textView) ->
+                textView.digitTranslateAnimator?.let {
+                    if (!isDozing) {
+                        it.animatePosition(
+                            animate = isAnimated && isAnimationEnabled,
+                            interpolator = Interpolators.EMPHASIZED,
+                            duration = AOD_TRANSITION_DURATION,
+                            targetTranslation =
+                                updateDirectionalTargetTranslate(id, lockscreenTranslate),
+                        )
+                    } else {
+                        it.animatePosition(
+                            animate = isAnimated && isAnimationEnabled,
+                            interpolator = Interpolators.EMPHASIZED,
+                            duration = AOD_TRANSITION_DURATION,
+                            onAnimationEnd = null,
+                            targetTranslation = updateDirectionalTargetTranslate(id, aodTranslate),
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    override fun animateCharge() {
+        super.animateCharge()
+        digitalClockTextViewMap.forEach { (id, textView) ->
+            textView.digitTranslateAnimator?.let {
+                it.animatePosition(
+                    animate = isAnimationEnabled,
+                    interpolator = Interpolators.EMPHASIZED,
+                    duration = CHARGING_TRANSITION_DURATION,
+                    onAnimationEnd = {
+                        it.animatePosition(
+                            animate = isAnimationEnabled,
+                            interpolator = Interpolators.EMPHASIZED,
+                            duration = CHARGING_TRANSITION_DURATION,
+                            targetTranslation =
+                                updateDirectionalTargetTranslate(
+                                    id,
+                                    if (dozeFraction == 1F) aodTranslate else lockscreenTranslate,
+                                ),
+                        )
+                    },
+                    targetTranslation =
+                        updateDirectionalTargetTranslate(
+                            id,
+                            if (dozeFraction == 1F) lockscreenTranslate else aodTranslate,
+                        ),
+                )
+            }
+        }
+    }
+
+    companion object {
+        val AOD_TRANSITION_DURATION = 750L
+        val CHARGING_TRANSITION_DURATION = 300L
+
+        val AOD_HORIZONTAL_TRANSLATE_RATIO = 0.15F
+        val AOD_VERTICAL_TRANSLATE_RATIO = 0.075F
+
+        // Use the sign of targetTranslation to control the direction of digit translation
+        fun updateDirectionalTargetTranslate(id: Int, targetTranslation: Point): Point {
+            val outPoint = Point(targetTranslation)
+            when (id) {
+                R.id.HOUR_FIRST_DIGIT -> {
+                    outPoint.x *= -1
+                    outPoint.y *= -1
+                }
+
+                R.id.HOUR_SECOND_DIGIT -> {
+                    outPoint.x *= 1
+                    outPoint.y *= -1
+                }
+
+                R.id.MINUTE_FIRST_DIGIT -> {
+                    outPoint.x *= -1
+                    outPoint.y *= 1
+                }
+
+                R.id.MINUTE_SECOND_DIGIT -> {
+                    outPoint.x *= 1
+                    outPoint.y *= 1
+                }
+            }
+            return outPoint
+        }
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt
new file mode 100644
index 0000000..74617b1
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt
@@ -0,0 +1,654 @@
+/*
+ * 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.systemui.shared.clocks.view
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Point
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.graphics.Rect
+import android.text.Layout
+import android.text.TextPaint
+import android.util.AttributeSet
+import android.util.Log
+import android.util.MathUtils
+import android.util.TypedValue
+import android.view.View.MeasureSpec.AT_MOST
+import android.view.View.MeasureSpec.EXACTLY
+import android.view.animation.Interpolator
+import android.widget.TextView
+import com.android.internal.annotations.VisibleForTesting
+import com.android.systemui.animation.TextAnimator
+import com.android.systemui.animation.TypefaceVariantCache
+import com.android.systemui.customization.R
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.ClockAnimation
+import com.android.systemui.shared.clocks.DigitTranslateAnimator
+import com.android.systemui.shared.clocks.DimensionParser
+import com.android.systemui.shared.clocks.FontTextStyle
+import com.android.systemui.shared.clocks.LogUtil
+import com.android.systemui.shared.clocks.RenderType
+import com.android.systemui.shared.clocks.TextStyle
+import java.lang.Thread
+import kotlin.math.ceil
+import kotlin.math.max
+import kotlin.math.min
+
+private val TAG = SimpleDigitalClockTextView::class.simpleName!!
+
+@SuppressLint("AppCompatCustomView")
+open class SimpleDigitalClockTextView(
+    ctx: Context,
+    messageBuffer: MessageBuffer,
+    attrs: AttributeSet? = null,
+) : TextView(ctx, attrs), SimpleDigitalClockView {
+    val lockScreenPaint = TextPaint()
+    override lateinit var textStyle: FontTextStyle
+    lateinit var aodStyle: FontTextStyle
+    private val parser = DimensionParser(ctx)
+    var maxSingleDigitHeight = -1
+    var maxSingleDigitWidth = -1
+    var digitTranslateAnimator: DigitTranslateAnimator? = null
+    var aodFontSizePx: Float = -1F
+    var isVertical: Boolean = false
+
+    // Store the font size when there's no height constraint as a reference when adjusting font size
+    private var lastUnconstrainedTextSize: Float = Float.MAX_VALUE
+    // Calculated by height of styled text view / text size
+    // Used as a factor to calculate a smaller font size when text height is constrained
+    @VisibleForTesting var fontSizeAdjustFactor = 1F
+
+    private val initThread = Thread.currentThread()
+
+    // textBounds is the size of text in LS, which only measures current text in lockscreen style
+    var textBounds = Rect()
+    // prevTextBounds and targetTextBounds are to deal with dozing animation between LS and AOD
+    // especially for the textView which has different bounds during the animation
+    // prevTextBounds holds the state we are transitioning from
+    private val prevTextBounds = Rect()
+    // targetTextBounds holds the state we are interpolating to
+    private val targetTextBounds = Rect()
+    protected val logger = Logger(messageBuffer, this::class.simpleName!!)
+        get() = field ?: LogUtil.FALLBACK_INIT_LOGGER
+
+    private var aodDozingInterpolator: Interpolator? = null
+
+    @VisibleForTesting lateinit var textAnimator: TextAnimator
+    @VisibleForTesting var outlineAnimator: TextAnimator? = null
+    // used for hollow style for AOD version
+    // because stroke style for some fonts have some unwanted inner strokes
+    // we want to draw this layer on top to oclude them
+    @VisibleForTesting var innerAnimator: TextAnimator? = null
+
+    lateinit var typefaceCache: TypefaceVariantCache
+        private set
+
+    private fun setTypefaceCache(value: TypefaceVariantCache) {
+        typefaceCache = value
+        if (this::textAnimator.isInitialized) {
+            textAnimator.typefaceCache = value
+        }
+        outlineAnimator?.typefaceCache = value
+        innerAnimator?.typefaceCache = value
+    }
+
+    @VisibleForTesting
+    var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = { layout, invalidateCb ->
+        TextAnimator(layout, ClockAnimation.NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb).also {
+            if (this::typefaceCache.isInitialized) {
+                it.typefaceCache = typefaceCache
+            }
+        }
+    }
+
+    override var verticalAlignment: VerticalAlignment = VerticalAlignment.CENTER
+    override var horizontalAlignment: HorizontalAlignment = HorizontalAlignment.LEFT
+    override var isAnimationEnabled = true
+    override var dozeFraction: Float = 0F
+        set(value) {
+            field = value
+            invalidate()
+        }
+
+    // Have to passthrough to unify View with SimpleDigitalClockView
+    override var text: String
+        get() = super.getText().toString()
+        set(value) = super.setText(value)
+
+    var textBorderWidth = 0F
+    var aodBorderWidth = 0F
+    var baselineFromMeasure = 0
+
+    var textFillColor: Int? = null
+    var textOutlineColor = TEXT_OUTLINE_DEFAULT_COLOR
+    var aodFillColor = AOD_DEFAULT_COLOR
+    var aodOutlineColor = AOD_OUTLINE_DEFAULT_COLOR
+
+    override fun updateColors(assets: AssetLoader, isRegionDark: Boolean) {
+        val fillColor = if (isRegionDark) textStyle.fillColorLight else textStyle.fillColorDark
+        textFillColor =
+            fillColor?.let { assets.readColor(it) }
+                ?: assets.seedColor
+                ?: getDefaultColor(assets, isRegionDark)
+        // for NumberOverlapView to read correct color
+        lockScreenPaint.color = textFillColor as Int
+        textStyle.outlineColor?.let { textOutlineColor = assets.readColor(it) }
+            ?: run { textOutlineColor = TEXT_OUTLINE_DEFAULT_COLOR }
+        (aodStyle.fillColorLight ?: aodStyle.fillColorDark)?.let {
+            aodFillColor = assets.readColor(it)
+        } ?: run { aodFillColor = AOD_DEFAULT_COLOR }
+        aodStyle.outlineColor?.let { aodOutlineColor = assets.readColor(it) }
+            ?: run { aodOutlineColor = AOD_OUTLINE_DEFAULT_COLOR }
+        if (dozeFraction < 1f) {
+            textAnimator.setTextStyle(color = textFillColor, animate = false)
+            outlineAnimator?.setTextStyle(color = textOutlineColor, animate = false)
+        }
+        invalidate()
+    }
+
+    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+        logger.d("onMeasure()")
+        if (isVertical) {
+            // use at_most to avoid apply measuredWidth from last measuring to measuredHeight
+            // cause we use max to setMeasuredDimension
+            super.onMeasure(
+                MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), AT_MOST),
+                MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), AT_MOST),
+            )
+        } else {
+            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+        }
+
+        val layout = this.layout
+        if (layout != null) {
+            if (!this::textAnimator.isInitialized) {
+                textAnimator = textAnimatorFactory(layout, ::invalidate)
+                outlineAnimator = textAnimatorFactory(layout) {}
+                innerAnimator = textAnimatorFactory(layout) {}
+                setInterpolatorPaint()
+            } else {
+                textAnimator.updateLayout(layout)
+                outlineAnimator?.updateLayout(layout)
+                innerAnimator?.updateLayout(layout)
+            }
+            baselineFromMeasure = layout.getLineBaseline(0)
+        } else {
+            val currentThread = Thread.currentThread()
+            Log.wtf(
+                TAG,
+                "TextView.getLayout() is null after measure! " +
+                    "currentThread=$currentThread; initThread=$initThread",
+            )
+        }
+
+        var expectedWidth: Int
+        var expectedHeight: Int
+
+        if (MeasureSpec.getMode(heightMeasureSpec) == EXACTLY) {
+            // For view which has fixed height, e.g. small clock,
+            // we should always return the size required from parent view
+            expectedHeight = heightMeasureSpec
+        } else {
+            expectedHeight =
+                MeasureSpec.makeMeasureSpec(
+                    if (isSingleDigit()) {
+                        maxSingleDigitHeight
+                    } else {
+                        textBounds.height() + 2 * lockScreenPaint.strokeWidth.toInt()
+                    },
+                    MeasureSpec.getMode(measuredHeight),
+                )
+        }
+        if (MeasureSpec.getMode(widthMeasureSpec) == EXACTLY) {
+            expectedWidth = widthMeasureSpec
+        } else {
+            expectedWidth =
+                MeasureSpec.makeMeasureSpec(
+                    if (isSingleDigit()) {
+                        maxSingleDigitWidth
+                    } else {
+                        max(
+                            textBounds.width() + 2 * lockScreenPaint.strokeWidth.toInt(),
+                            MeasureSpec.getSize(measuredWidth),
+                        )
+                    },
+                    MeasureSpec.getMode(measuredWidth),
+                )
+        }
+
+        if (isVertical) {
+            expectedWidth = expectedHeight.also { expectedHeight = expectedWidth }
+        }
+        setMeasuredDimension(expectedWidth, expectedHeight)
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        if (isVertical) {
+            canvas.save()
+            canvas.translate(0F, measuredHeight.toFloat())
+            canvas.rotate(-90F)
+        }
+        logger.d({ "onDraw(); ls: $str1; aod: $str2;" }) {
+            str1 = textAnimator.textInterpolator.shapedText
+            str2 = outlineAnimator?.textInterpolator?.shapedText
+        }
+        val translation = getLocalTranslation()
+        canvas.translate(translation.x.toFloat(), translation.y.toFloat())
+        digitTranslateAnimator?.let {
+            canvas.translate(it.updatedTranslate.x.toFloat(), it.updatedTranslate.y.toFloat())
+        }
+
+        if (aodStyle.renderType == RenderType.HOLLOW_TEXT) {
+            canvas.saveLayer(
+                -translation.x.toFloat(),
+                -translation.y.toFloat(),
+                (-translation.x + measuredWidth).toFloat(),
+                (-translation.y + measuredHeight).toFloat(),
+                null,
+            )
+            outlineAnimator?.draw(canvas)
+            canvas.saveLayer(
+                -translation.x.toFloat(),
+                -translation.y.toFloat(),
+                (-translation.x + measuredWidth).toFloat(),
+                (-translation.y + measuredHeight).toFloat(),
+                Paint().also { it.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) },
+            )
+            innerAnimator?.draw(canvas)
+            canvas.restore()
+            canvas.restore()
+        } else if (aodStyle.renderType != RenderType.CHANGE_WEIGHT) {
+            outlineAnimator?.draw(canvas)
+        }
+        textAnimator.draw(canvas)
+
+        digitTranslateAnimator?.let {
+            canvas.translate(-it.updatedTranslate.x.toFloat(), -it.updatedTranslate.y.toFloat())
+        }
+        canvas.translate(-translation.x.toFloat(), -translation.y.toFloat())
+        if (isVertical) {
+            canvas.restore()
+        }
+    }
+
+    override fun invalidate() {
+        logger.d("invalidate()")
+        super.invalidate()
+        (parent as? DigitalClockFaceView)?.invalidate()
+    }
+
+    override fun refreshTime() {
+        logger.d("refreshTime()")
+        refreshText()
+    }
+
+    override fun animateDoze(isDozing: Boolean, isAnimated: Boolean) {
+        if (!this::textAnimator.isInitialized) {
+            return
+        }
+        val fvar = if (isDozing) aodStyle.fontVariation else textStyle.fontVariation
+        textAnimator.setTextStyle(
+            animate = isAnimated && isAnimationEnabled,
+            color = if (isDozing) aodFillColor else textFillColor,
+            textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize,
+            fvar = fvar,
+            duration = aodStyle.transitionDuration,
+            interpolator = aodDozingInterpolator,
+        )
+        updateTextBoundsForTextAnimator()
+        outlineAnimator?.setTextStyle(
+            animate = isAnimated && isAnimationEnabled,
+            color = if (isDozing) aodOutlineColor else textOutlineColor,
+            textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize,
+            fvar = fvar,
+            strokeWidth = if (isDozing) aodBorderWidth else textBorderWidth,
+            duration = aodStyle.transitionDuration,
+            interpolator = aodDozingInterpolator,
+        )
+        innerAnimator?.setTextStyle(
+            animate = isAnimated && isAnimationEnabled,
+            color = Color.WHITE,
+            textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize,
+            fvar = fvar,
+            duration = aodStyle.transitionDuration,
+            interpolator = aodDozingInterpolator,
+        )
+    }
+
+    override fun animateCharge() {
+        if (!this::textAnimator.isInitialized || textAnimator.isRunning()) {
+            // Skip charge animation if dozing animation is already playing.
+            return
+        }
+        logger.d("animateCharge()")
+        val middleFvar = if (dozeFraction == 0F) aodStyle.fontVariation else textStyle.fontVariation
+        val endFvar = if (dozeFraction == 0F) textStyle.fontVariation else aodStyle.fontVariation
+        val startAnimPhase2 = Runnable {
+            textAnimator.setTextStyle(fvar = endFvar, animate = isAnimationEnabled)
+            outlineAnimator?.setTextStyle(fvar = endFvar, animate = isAnimationEnabled)
+            innerAnimator?.setTextStyle(fvar = endFvar, animate = isAnimationEnabled)
+            updateTextBoundsForTextAnimator()
+        }
+        textAnimator.setTextStyle(
+            fvar = middleFvar,
+            animate = isAnimationEnabled,
+            onAnimationEnd = startAnimPhase2,
+        )
+        outlineAnimator?.setTextStyle(fvar = middleFvar, animate = isAnimationEnabled)
+        innerAnimator?.setTextStyle(fvar = middleFvar, animate = isAnimationEnabled)
+        updateTextBoundsForTextAnimator()
+    }
+
+    fun refreshText() {
+        lockScreenPaint.getTextBounds(text, 0, text.length, textBounds)
+        if (this::textAnimator.isInitialized) {
+            textAnimator.textInterpolator.targetPaint.getTextBounds(
+                text,
+                0,
+                text.length,
+                targetTextBounds,
+            )
+        }
+        if (layout == null) {
+            requestLayout()
+        } else {
+            textAnimator.updateLayout(layout)
+            outlineAnimator?.updateLayout(layout)
+            innerAnimator?.updateLayout(layout)
+        }
+    }
+
+    private fun isSingleDigit(): Boolean {
+        return id == R.id.HOUR_FIRST_DIGIT ||
+            id == R.id.HOUR_SECOND_DIGIT ||
+            id == R.id.MINUTE_FIRST_DIGIT ||
+            id == R.id.MINUTE_SECOND_DIGIT
+    }
+
+    private fun updateInterpolatedTextBounds(): Rect {
+        val interpolatedTextBounds = Rect()
+        if (textAnimator.animator.animatedFraction != 1.0f && textAnimator.animator.isRunning) {
+            interpolatedTextBounds.left =
+                MathUtils.lerp(
+                        prevTextBounds.left,
+                        targetTextBounds.left,
+                        textAnimator.animator.animatedValue as Float,
+                    )
+                    .toInt()
+
+            interpolatedTextBounds.right =
+                MathUtils.lerp(
+                        prevTextBounds.right,
+                        targetTextBounds.right,
+                        textAnimator.animator.animatedValue as Float,
+                    )
+                    .toInt()
+
+            interpolatedTextBounds.top =
+                MathUtils.lerp(
+                        prevTextBounds.top,
+                        targetTextBounds.top,
+                        textAnimator.animator.animatedValue as Float,
+                    )
+                    .toInt()
+
+            interpolatedTextBounds.bottom =
+                MathUtils.lerp(
+                        prevTextBounds.bottom,
+                        targetTextBounds.bottom,
+                        textAnimator.animator.animatedValue as Float,
+                    )
+                    .toInt()
+        } else {
+            interpolatedTextBounds.set(targetTextBounds)
+        }
+        return interpolatedTextBounds
+    }
+
+    private fun updateXtranslation(inPoint: Point, interpolatedTextBounds: Rect): Point {
+        val viewWidth = if (isVertical) measuredHeight else measuredWidth
+        when (horizontalAlignment) {
+            HorizontalAlignment.LEFT -> {
+                inPoint.x = lockScreenPaint.strokeWidth.toInt() - interpolatedTextBounds.left
+            }
+            HorizontalAlignment.RIGHT -> {
+                inPoint.x =
+                    viewWidth - interpolatedTextBounds.right - lockScreenPaint.strokeWidth.toInt()
+            }
+            HorizontalAlignment.CENTER -> {
+                inPoint.x =
+                    (viewWidth - interpolatedTextBounds.width()) / 2 - interpolatedTextBounds.left
+            }
+        }
+        return inPoint
+    }
+
+    // translation of reference point of text
+    // used for translation when calling textInterpolator
+    fun getLocalTranslation(): Point {
+        val viewHeight = if (isVertical) measuredWidth else measuredHeight
+        val interpolatedTextBounds = updateInterpolatedTextBounds()
+        val localTranslation = Point(0, 0)
+        val correctedBaseline = if (baseline != -1) baseline else baselineFromMeasure
+        // get the change from current baseline to expected baseline
+        when (verticalAlignment) {
+            VerticalAlignment.CENTER -> {
+                localTranslation.y =
+                    ((viewHeight - interpolatedTextBounds.height()) / 2 -
+                        interpolatedTextBounds.top -
+                        correctedBaseline)
+            }
+            VerticalAlignment.TOP -> {
+                localTranslation.y =
+                    (-interpolatedTextBounds.top + lockScreenPaint.strokeWidth - correctedBaseline)
+                        .toInt()
+            }
+            VerticalAlignment.BOTTOM -> {
+                localTranslation.y =
+                    viewHeight -
+                        interpolatedTextBounds.bottom -
+                        lockScreenPaint.strokeWidth.toInt() -
+                        correctedBaseline
+            }
+            VerticalAlignment.BASELINE -> {
+                localTranslation.y = -lockScreenPaint.strokeWidth.toInt()
+            }
+        }
+
+        return updateXtranslation(localTranslation, interpolatedTextBounds)
+    }
+
+    override fun applyStyles(assets: AssetLoader, textStyle: TextStyle, aodStyle: TextStyle?) {
+        this.textStyle = textStyle as FontTextStyle
+        val typefaceName = "fonts/" + textStyle.fontFamily
+        setTypefaceCache(assets.typefaceCache.getVariantCache(typefaceName))
+        lockScreenPaint.strokeJoin = Paint.Join.ROUND
+        lockScreenPaint.typeface = typefaceCache.getTypefaceForVariant(textStyle.fontVariation)
+        textStyle.fontFeatureSettings?.let {
+            lockScreenPaint.fontFeatureSettings = it
+            fontFeatureSettings = it
+        }
+        typeface = lockScreenPaint.typeface
+        textStyle.lineHeight?.let { lineHeight = it.toInt() }
+        // borderWidth in textStyle and aodStyle is used to draw,
+        // strokeWidth in lockScreenPaint is used to measure and get enough space for the text
+        textStyle.borderWidth?.let { textBorderWidth = parser.convert(it) }
+
+        if (aodStyle != null && aodStyle is FontTextStyle) {
+            this.aodStyle = aodStyle
+        } else {
+            this.aodStyle = textStyle.copy()
+        }
+        this.aodStyle.transitionInterpolator?.let { aodDozingInterpolator = it.interpolator }
+        aodBorderWidth = parser.convert(this.aodStyle.borderWidth ?: DEFAULT_AOD_STROKE_WIDTH)
+        lockScreenPaint.strokeWidth = ceil(max(textBorderWidth, aodBorderWidth))
+        measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+        setInterpolatorPaint()
+        recomputeMaxSingleDigitSizes()
+        invalidate()
+    }
+
+    // When constrainedByHeight is on, targetFontSizePx is the constrained height of textView
+    override fun applyTextSize(targetFontSizePx: Float?, constrainedByHeight: Boolean) {
+        val adjustedFontSizePx = adjustFontSize(targetFontSizePx, constrainedByHeight)
+        val fontSizePx = adjustedFontSizePx * (textStyle.fontSizeScale ?: 1f)
+        aodFontSizePx =
+            adjustedFontSizePx * (aodStyle.fontSizeScale ?: textStyle.fontSizeScale ?: 1f)
+        if (fontSizePx > 0) {
+            setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx)
+            lockScreenPaint.textSize = textSize
+            lockScreenPaint.getTextBounds(text, 0, text.length, textBounds)
+            targetTextBounds.set(textBounds)
+        }
+        if (!constrainedByHeight) {
+            val lastUnconstrainedHeight = textBounds.height() + lockScreenPaint.strokeWidth * 2
+            fontSizeAdjustFactor = lastUnconstrainedHeight / lastUnconstrainedTextSize
+        }
+        textStyle.borderWidthScale?.let {
+            textBorderWidth = fontSizePx * it
+            if (dozeFraction < 1.0F) {
+                outlineAnimator?.setTextStyle(strokeWidth = textBorderWidth, animate = false)
+            }
+        }
+        aodStyle.borderWidthScale?.let {
+            aodBorderWidth = fontSizePx * it
+            if (dozeFraction > 0.0F) {
+                outlineAnimator?.setTextStyle(strokeWidth = aodBorderWidth, animate = false)
+            }
+        }
+
+        lockScreenPaint.strokeWidth = ceil(max(textBorderWidth, aodBorderWidth))
+        recomputeMaxSingleDigitSizes()
+
+        if (this::textAnimator.isInitialized) {
+            textAnimator.setTextStyle(textSize = lockScreenPaint.textSize, animate = false)
+        }
+        outlineAnimator?.setTextStyle(textSize = lockScreenPaint.textSize, animate = false)
+        innerAnimator?.setTextStyle(textSize = lockScreenPaint.textSize, animate = false)
+    }
+
+    private fun recomputeMaxSingleDigitSizes() {
+        val rectForCalculate = Rect()
+        maxSingleDigitHeight = 0
+        maxSingleDigitWidth = 0
+
+        for (i in 0..9) {
+            lockScreenPaint.getTextBounds(i.toString(), 0, 1, rectForCalculate)
+            maxSingleDigitHeight = max(maxSingleDigitHeight, rectForCalculate.height())
+            maxSingleDigitWidth = max(maxSingleDigitWidth, rectForCalculate.width())
+        }
+        maxSingleDigitWidth += 2 * lockScreenPaint.strokeWidth.toInt()
+        maxSingleDigitHeight += 2 * lockScreenPaint.strokeWidth.toInt()
+    }
+
+    // called without animation, can be used to set the initial state of animator
+    private fun setInterpolatorPaint() {
+        if (this::textAnimator.isInitialized) {
+            // set initial style
+            textAnimator.textInterpolator.targetPaint.set(lockScreenPaint)
+            textAnimator.textInterpolator.onTargetPaintModified()
+            textAnimator.setTextStyle(
+                fvar = textStyle.fontVariation,
+                textSize = lockScreenPaint.textSize,
+                color = textFillColor,
+                animate = false,
+            )
+        }
+
+        if (outlineAnimator != null) {
+            outlineAnimator!!
+                .textInterpolator
+                .targetPaint
+                .set(
+                    TextPaint(lockScreenPaint).also {
+                        it.style =
+                            if (aodStyle.renderType == RenderType.HOLLOW_TEXT)
+                                Paint.Style.FILL_AND_STROKE
+                            else Paint.Style.STROKE
+                    }
+                )
+            outlineAnimator!!.textInterpolator.onTargetPaintModified()
+            outlineAnimator!!.setTextStyle(
+                fvar = aodStyle.fontVariation,
+                textSize = lockScreenPaint.textSize,
+                color = Color.TRANSPARENT,
+                animate = false,
+            )
+        }
+
+        if (innerAnimator != null) {
+            innerAnimator!!
+                .textInterpolator
+                .targetPaint
+                .set(TextPaint(lockScreenPaint).also { it.style = Paint.Style.FILL })
+            innerAnimator!!.textInterpolator.onTargetPaintModified()
+            innerAnimator!!.setTextStyle(
+                fvar = aodStyle.fontVariation,
+                textSize = lockScreenPaint.textSize,
+                color = Color.WHITE,
+                animate = false,
+            )
+        }
+    }
+
+    /* Called after textAnimator.setTextStyle
+     * textAnimator.setTextStyle will update targetPaint,
+     * and rebase if previous animator is canceled
+     * so basePaint will store the state we transition from
+     * and targetPaint will store the state we transition to
+     */
+    private fun updateTextBoundsForTextAnimator() {
+        textAnimator.textInterpolator.basePaint.getTextBounds(text, 0, text.length, prevTextBounds)
+        textAnimator.textInterpolator.targetPaint.getTextBounds(
+            text,
+            0,
+            text.length,
+            targetTextBounds,
+        )
+    }
+
+    /*
+     * Adjust text size to adapt to large display / font size
+     * where the text view will be constrained by height
+     */
+    private fun adjustFontSize(targetFontSizePx: Float?, constrainedByHeight: Boolean): Float {
+        return if (constrainedByHeight) {
+            min((targetFontSizePx ?: 0F) / fontSizeAdjustFactor, lastUnconstrainedTextSize)
+        } else {
+            lastUnconstrainedTextSize = targetFontSizePx ?: 1F
+            lastUnconstrainedTextSize
+        }
+    }
+
+    companion object {
+        val DEFAULT_AOD_STROKE_WIDTH = "2dp"
+        val TEXT_OUTLINE_DEFAULT_COLOR = Color.TRANSPARENT
+        val AOD_DEFAULT_COLOR = Color.TRANSPARENT
+        val AOD_OUTLINE_DEFAULT_COLOR = Color.WHITE
+        private val DEFAULT_LIGHT_COLOR = "@android:color/system_accent1_100+0"
+        private val DEFAULT_DARK_COLOR = "@android:color/system_accent2_600+0"
+
+        fun getDefaultColor(assets: AssetLoader, isRegionDark: Boolean) =
+            assets.readColor(if (isRegionDark) DEFAULT_LIGHT_COLOR else DEFAULT_DARK_COLOR)
+    }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt
new file mode 100644
index 0000000..bbd2d3d
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.systemui.shared.clocks.view
+
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.TextStyle
+
+interface SimpleDigitalClockView {
+    var text: String
+    var verticalAlignment: VerticalAlignment
+    var horizontalAlignment: HorizontalAlignment
+    var dozeFraction: Float
+    val textStyle: TextStyle
+    @VisibleForTesting var isAnimationEnabled: Boolean
+
+    fun applyStyles(assets: AssetLoader, textStyle: TextStyle, aodStyle: TextStyle?)
+
+    fun applyTextSize(targetFontSizePx: Float?, constrainedByHeight: Boolean = false)
+
+    fun updateColors(assets: AssetLoader, isRegionDark: Boolean)
+
+    fun refreshTime()
+
+    fun animateCharge()
+
+    fun animateDoze(isDozing: Boolean, isAnimated: Boolean)
+}
+
+enum class VerticalAlignment {
+    TOP,
+    BOTTOM,
+    BASELINE, // default
+    CENTER,
+}
+
+enum class HorizontalAlignment {
+    LEFT,
+    RIGHT,
+    CENTER, // default
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/WindowMagnificationFrameSizePrefsTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/WindowMagnificationFrameSizePrefsTest.java
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/accessibility/WindowMagnificationFrameSizePrefsTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/WindowMagnificationFrameSizePrefsTest.java
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/WindowMagnificationFrameSpecTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/WindowMagnificationFrameSpecTest.kt
similarity index 95%
rename from packages/SystemUI/tests/src/com/android/systemui/accessibility/WindowMagnificationFrameSpecTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/WindowMagnificationFrameSpecTest.kt
index 791a26e..6f43c20 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/WindowMagnificationFrameSpecTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/WindowMagnificationFrameSpecTest.kt
@@ -16,9 +16,9 @@
 
 package com.android.systemui.accessibility
 
-import android.testing.AndroidTestingRunner
 import android.util.Size
 import androidx.test.filters.SmallTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.accessibility.WindowMagnificationSettings.MagnificationSize
 import com.google.common.truth.Truth.assertThat
@@ -26,7 +26,7 @@
 import org.junit.runner.RunWith
 
 @SmallTest
-@RunWith(AndroidTestingRunner::class)
+@RunWith(AndroidJUnit4::class)
 class WindowMagnificationFrameSpecTest : SysuiTestCase() {
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/floatingmenu/MenuInfoRepositoryTest.java
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/fontscaling/FontScalingDialogDelegateTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/fontscaling
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/accessibility/fontscaling/FontScalingDialogDelegateTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/fontscaling
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/hearingaid/HearingDevicesDialogDelegateTest.java
diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/animation/ViewHierarchyAnimatorTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/authentication/data/repository/AuthenticationRepositoryTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/battery/BatteryMeterViewTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/battery/BatteryMeterViewTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/battery/BatteryMeterViewTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/battery/BatteryMeterViewTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/AuthControllerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/AuthControllerTest.java
index 65825b2..2dcbdc8 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/AuthControllerTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/AuthControllerTest.java
@@ -17,6 +17,7 @@
 package com.android.systemui.biometrics;
 
 import static android.hardware.biometrics.BiometricAuthenticator.TYPE_FINGERPRINT;
+import static android.view.Display.INVALID_DISPLAY;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -68,10 +69,12 @@
 import android.hardware.fingerprint.IFingerprintAuthenticatorsRegisteredCallback;
 import android.os.Handler;
 import android.os.RemoteException;
+import android.os.UserHandle;
 import android.os.UserManager;
 import android.testing.TestableContext;
 import android.testing.TestableLooper;
 import android.testing.TestableLooper.RunWithLooper;
+import android.view.Display;
 import android.view.DisplayInfo;
 import android.view.Surface;
 import android.view.WindowManager;
@@ -210,6 +213,7 @@
                 .thenReturn(true);
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT))
                 .thenReturn(true);
+        when(mUserManager.isVisibleBackgroundUsersSupported()).thenReturn(false);
 
         when(mDialog1.getOpPackageName()).thenReturn("Dialog1");
         when(mDialog2.getOpPackageName()).thenReturn("Dialog2");
@@ -462,7 +466,7 @@
     @Test
     public void testShowInvoked_whenSystemRequested() {
         showDialog(new int[] {1} /* sensorIds */, false /* credentialAllowed */);
-        verify(mDialog1).show(any());
+        verify(mDialog1).show(mWindowManager);
     }
 
     @Test
@@ -679,7 +683,7 @@
         // 2) Client cancels authentication
 
         showDialog(new int[0] /* sensorIds */, true /* credentialAllowed */);
-        verify(mDialog1).show(any());
+        verify(mDialog1).show(mWindowManager);
 
         final byte[] credentialAttestation = generateRandomHAT();
 
@@ -695,7 +699,7 @@
     @Test
     public void testShowNewDialog_beforeOldDialogDismissed_SkipsAnimations() {
         showDialog(new int[] {1} /* sensorIds */, false /* credentialAllowed */);
-        verify(mDialog1).show(any());
+        verify(mDialog1).show(mWindowManager);
 
         showDialog(new int[] {1} /* sensorIds */, false /* credentialAllowed */);
 
@@ -703,7 +707,7 @@
         verify(mDialog1).dismissWithoutCallback(eq(false) /* animate */);
 
         // Second dialog should be shown without animation
-        verify(mDialog2).show(any());
+        verify(mDialog2).show(mWindowManager);
     }
 
     @Test
@@ -990,13 +994,97 @@
         verify(mDialog1, never()).show(any());
     }
 
+    @Test
+    public void testShowDialog_visibleBackgroundUser() {
+        int backgroundUserId = 1001;
+        int backgroundDisplayId = 1001;
+        when(mUserManager.isVisibleBackgroundUsersSupported()).thenReturn(true);
+        WindowManager wm = mockBackgroundUser(backgroundUserId, backgroundDisplayId,
+                true /* isVisible */, true /* hasUserManager */, true /* hasDisplay */);
+
+        showDialog(new int[]{1} /* sensorIds */, backgroundUserId /* userId */,
+                false /* credentialAllowed */);
+
+        verify(mDialog1).show(wm);
+    }
+
+    @Test
+    public void testShowDialog_invisibleBackgroundUser_defaultWM() {
+        int backgroundUserId = 1001;
+        when(mUserManager.isVisibleBackgroundUsersSupported()).thenReturn(true);
+        mockBackgroundUser(backgroundUserId, INVALID_DISPLAY,
+                false /* isVisible */, true /* hasUserManager */, true /* hasDisplay */);
+
+        showDialog(new int[]{1} /* sensorIds */, backgroundUserId /* userId */,
+                false /* credentialAllowed */);
+
+        verify(mDialog1).show(mWindowManager);
+    }
+
+    @Test
+    public void testShowDialog_visibleBackgroundUser_noUserManager_dismissError()
+            throws RemoteException {
+        int backgroundUserId = 1001;
+        int backgroundDisplayId = 1001;
+        when(mUserManager.isVisibleBackgroundUsersSupported()).thenReturn(true);
+        mockBackgroundUser(backgroundUserId, backgroundDisplayId,
+                true /* isVisible */, false /* hasUserManager */, true /* hasDisplay */);
+
+        showDialog(new int[]{1} /* sensorIds */, backgroundUserId /* userId */,
+                false /* credentialAllowed */);
+
+        verify(mDialog1, never()).show(any());
+        verify(mReceiver).onDialogDismissed(
+                eq(BiometricPrompt.DISMISSED_REASON_ERROR_NO_WM),
+                eq(null) /* credentialAttestation */);
+    }
+
+    @Test
+    public void testShowDialog_visibleBackgroundUser_invalidDisplayId_dismissError()
+            throws RemoteException {
+        int backgroundUserId = 1001;
+        when(mUserManager.isVisibleBackgroundUsersSupported()).thenReturn(true);
+        mockBackgroundUser(backgroundUserId, INVALID_DISPLAY,
+                true /* isVisible */, true /* hasUserManager */, false /* hasDisplay */);
+
+        showDialog(new int[]{1} /* sensorIds */, backgroundUserId /* userId */,
+                false /* credentialAllowed */);
+
+        verify(mDialog1, never()).show(any());
+        verify(mReceiver).onDialogDismissed(
+                eq(BiometricPrompt.DISMISSED_REASON_ERROR_NO_WM),
+                eq(null) /* credentialAttestation */);
+    }
+
+    @Test
+    public void testShowDialog_visibleBackgroundUser_invalidDisplay_dismissError()
+            throws RemoteException {
+        int backgroundUserId = 1001;
+        int backgroundDisplayId = 1001;
+        when(mUserManager.isVisibleBackgroundUsersSupported()).thenReturn(true);
+        mockBackgroundUser(backgroundUserId, backgroundDisplayId,
+                true /* isVisible */, true /* hasUserManager */, false /* hasDisplay */);
+
+        showDialog(new int[]{1} /* sensorIds */, backgroundUserId /* userId */,
+                false /* credentialAllowed */);
+
+        verify(mDialog1, never()).show(any());
+        verify(mReceiver).onDialogDismissed(
+                eq(BiometricPrompt.DISMISSED_REASON_ERROR_NO_WM),
+                eq(null) /* credentialAttestation */);
+    }
+
     private void showDialog(int[] sensorIds, boolean credentialAllowed) {
+        showDialog(sensorIds, 0 /* userId */, credentialAllowed);
+    }
+
+    private void showDialog(int[] sensorIds, int userId, boolean credentialAllowed) {
         mAuthController.showAuthenticationDialog(createTestPromptInfo(),
                 mReceiver /* receiver */,
                 sensorIds,
                 credentialAllowed,
                 true /* requireConfirmation */,
-                0 /* userId */,
+                userId /* userId */,
                 0 /* operationId */,
                 "testPackage",
                 REQUEST_ID);
@@ -1059,6 +1147,40 @@
         assertTrue(mAuthController.isFaceAuthEnrolled(userId));
     }
 
+    /**
+     * Create mocks related to visible background users.
+     *
+     * @param userId the user id of the background user to mock
+     * @param displayId display id of the background user
+     * @param isVisible whether the background user is a visible background user or not
+     * @param hasUserManager simulate whether the background user's context will return a mock
+     *                       UserManager instance or null
+     * @param hasDisplay simulate whether the background user's context will return a mock Display
+     *                   instance or null
+     * @return mock WindowManager instance associated with the background user's display context
+     */
+    private WindowManager mockBackgroundUser(int userId, int displayId, boolean isVisible,
+            boolean hasUserManager, boolean hasDisplay) {
+        Context mockUserContext = mock(Context.class);
+        Context mockDisplayContext = mock(Context.class);
+        UserManager mockUserManager = mock(UserManager.class);
+        Display mockDisplay = mock(Display.class);
+        WindowManager mockDisplayWM = mock(WindowManager.class);
+        doReturn(mockUserContext).when(mContextSpy).createContextAsUser(eq(UserHandle.of(userId)),
+                anyInt());
+        if (hasUserManager) {
+            when(mockUserContext.getSystemService(UserManager.class)).thenReturn(mockUserManager);
+        }
+        when(mockUserManager.isUserVisible()).thenReturn(isVisible);
+        when(mockUserManager.getMainDisplayIdAssignedToUser()).thenReturn(displayId);
+        if (hasDisplay) {
+            when(mDisplayManager.getDisplay(displayId)).thenReturn(mockDisplay);
+        }
+        doReturn(mockDisplayContext).when(mContextSpy).createDisplayContext(mockDisplay);
+        when(mockDisplayContext.getSystemService(WindowManager.class)).thenReturn(mockDisplayWM);
+        return mockDisplayWM;
+    }
+
     private final class TestableAuthController extends AuthController {
         private int mBuildCount = 0;
 
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt
index 75a77cf..194b41f 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt
@@ -27,12 +27,23 @@
 import android.hardware.face.FaceSensorPropertiesInternal
 import android.hardware.fingerprint.FingerprintSensorProperties
 import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
+import com.android.keyguard.keyguardUpdateMonitor
+import com.android.systemui.SysuiTestableContext
+import com.android.systemui.biometrics.data.repository.biometricStatusRepository
+import com.android.systemui.biometrics.shared.model.AuthenticationReason
+import com.android.systemui.bouncer.data.repository.keyguardBouncerRepository
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.res.R
+import com.android.systemui.util.mockito.whenever
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
 
 /** Create [FingerprintSensorPropertiesInternal] for a test. */
 internal fun fingerprintSensorPropertiesInternal(
     ids: List<Int> = listOf(0),
     strong: Boolean = true,
-    sensorType: Int = FingerprintSensorProperties.TYPE_REAR
+    sensorType: Int = FingerprintSensorProperties.TYPE_REAR,
 ): List<FingerprintSensorPropertiesInternal> {
     val componentInfo =
         listOf(
@@ -41,15 +52,15 @@
                 "vendor/model/revision" /* hardwareVersion */,
                 "1.01" /* firmwareVersion */,
                 "00000001" /* serialNumber */,
-                "" /* softwareVersion */
+                "", /* softwareVersion */
             ),
             ComponentInfoInternal(
                 "matchingAlgorithm" /* componentId */,
                 "" /* hardwareVersion */,
                 "" /* firmwareVersion */,
                 "" /* serialNumber */,
-                "vendor/version/revision" /* softwareVersion */
-            )
+                "vendor/version/revision", /* softwareVersion */
+            ),
         )
     return ids.map { id ->
         FingerprintSensorPropertiesInternal(
@@ -58,7 +69,7 @@
             5 /* maxEnrollmentsPerUser */,
             componentInfo,
             sensorType,
-            false /* resetLockoutRequiresHardwareAuthToken */
+            false, /* resetLockoutRequiresHardwareAuthToken */
         )
     }
 }
@@ -75,15 +86,15 @@
                 "vendor/model/revision" /* hardwareVersion */,
                 "1.01" /* firmwareVersion */,
                 "00000001" /* serialNumber */,
-                "" /* softwareVersion */
+                "", /* softwareVersion */
             ),
             ComponentInfoInternal(
                 "matchingAlgorithm" /* componentId */,
                 "" /* hardwareVersion */,
                 "" /* firmwareVersion */,
                 "" /* serialNumber */,
-                "vendor/version/revision" /* softwareVersion */
-            )
+                "vendor/version/revision", /* softwareVersion */
+            ),
         )
     return ids.map { id ->
         FaceSensorPropertiesInternal(
@@ -94,7 +105,7 @@
             FaceSensorProperties.TYPE_RGB,
             true /* supportsFaceDetection */,
             true /* supportsSelfIllumination */,
-            false /* resetLockoutRequiresHardwareAuthToken */
+            false, /* resetLockoutRequiresHardwareAuthToken */
         )
     }
 }
@@ -145,3 +156,67 @@
     info.negativeButtonText = negativeButton
     return info
 }
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal fun TestScope.updateSfpsIndicatorRequests(
+    kosmos: Kosmos,
+    mContext: SysuiTestableContext,
+    primaryBouncerRequest: Boolean? = null,
+    alternateBouncerRequest: Boolean? = null,
+    biometricPromptRequest: Boolean? = null,
+    // TODO(b/365182034): update when rest to unlock feature is implemented
+    //    progressBarShowing: Boolean? = null
+) {
+    biometricPromptRequest?.let { hasBiometricPromptRequest ->
+        if (hasBiometricPromptRequest) {
+            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
+                AuthenticationReason.BiometricPromptAuthentication
+            )
+        } else {
+            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
+                AuthenticationReason.NotRunning
+            )
+        }
+    }
+
+    primaryBouncerRequest?.let { hasPrimaryBouncerRequest ->
+        updatePrimaryBouncer(
+            kosmos,
+            mContext,
+            isShowing = hasPrimaryBouncerRequest,
+            isAnimatingAway = false,
+            fpsDetectionRunning = true,
+            isUnlockingWithFpAllowed = true,
+        )
+    }
+
+    alternateBouncerRequest?.let { hasAlternateBouncerRequest ->
+        kosmos.keyguardBouncerRepository.setAlternateVisible(hasAlternateBouncerRequest)
+    }
+
+    // TODO(b/365182034): set progress bar visibility when rest to unlock feature is implemented
+
+    runCurrent()
+}
+
+internal fun updatePrimaryBouncer(
+    kosmos: Kosmos,
+    mContext: SysuiTestableContext,
+    isShowing: Boolean,
+    isAnimatingAway: Boolean,
+    fpsDetectionRunning: Boolean,
+    isUnlockingWithFpAllowed: Boolean,
+) {
+    kosmos.keyguardBouncerRepository.setPrimaryShow(isShowing)
+    kosmos.keyguardBouncerRepository.setPrimaryStartingToHide(false)
+    val primaryStartDisappearAnimation = if (isAnimatingAway) Runnable {} else null
+    kosmos.keyguardBouncerRepository.setPrimaryStartDisappearAnimation(
+        primaryStartDisappearAnimation
+    )
+
+    whenever(kosmos.keyguardUpdateMonitor.isFingerprintDetectionRunning)
+        .thenReturn(fpsDetectionRunning)
+    whenever(kosmos.keyguardUpdateMonitor.isUnlockingWithFingerprintAllowed)
+        .thenReturn(isUnlockingWithFpAllowed)
+    mContext.orCreateTestableResources.addOverride(R.bool.config_show_sidefps_hint_on_bouncer, true)
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/UdfpsViewTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/UdfpsViewTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/UdfpsViewTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/UdfpsViewTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/LogContextInteractorImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/LogContextInteractorImplTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/LogContextInteractorImplTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/LogContextInteractorImplTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptSelectorInteractorImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/PromptSelectorInteractorImplTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptSelectorInteractorImplTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/interactor/PromptSelectorInteractorImplTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/model
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/domain/model
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/shared/model/BiometricModalitiesTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/shared/model/BiometricModalitiesTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/shared/model/BiometricModalitiesTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/shared/model/BiometricModalitiesTest.kt
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt
index 7fa165c..57df662 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/binder/SideFpsOverlayViewBinderTest.kt
@@ -16,64 +16,48 @@
 
 package com.android.systemui.biometrics.ui.binder
 
-import android.animation.Animator
-import android.graphics.Rect
-import android.hardware.biometrics.SensorLocationInternal
-import android.hardware.display.DisplayManager
-import android.hardware.display.DisplayManagerGlobal
 import android.testing.TestableLooper
-import android.view.Display
-import android.view.DisplayInfo
 import android.view.LayoutInflater
 import android.view.View
-import android.view.ViewPropertyAnimator
-import android.view.WindowInsets
 import android.view.WindowManager
-import android.view.WindowMetrics
 import android.view.layoutInflater
 import android.view.windowManager
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.airbnb.lottie.LottieAnimationView
-import com.android.keyguard.keyguardUpdateMonitor
 import com.android.systemui.SysuiTestCase
-import com.android.systemui.biometrics.FingerprintInteractiveToAuthProvider
-import com.android.systemui.biometrics.data.repository.biometricStatusRepository
 import com.android.systemui.biometrics.data.repository.fingerprintPropertyRepository
-import com.android.systemui.biometrics.shared.model.AuthenticationReason
 import com.android.systemui.biometrics.shared.model.DisplayRotation
 import com.android.systemui.biometrics.shared.model.FingerprintSensorType
 import com.android.systemui.biometrics.shared.model.SensorStrength
-import com.android.systemui.bouncer.data.repository.keyguardBouncerRepository
+import com.android.systemui.biometrics.updateSfpsIndicatorRequests
 import com.android.systemui.display.data.repository.displayRepository
 import com.android.systemui.display.data.repository.displayStateRepository
-import com.android.systemui.keyguard.ui.viewmodel.sideFpsProgressBarViewModel
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.res.R
 import com.android.systemui.testKosmos
 import com.android.systemui.util.mockito.eq
-import com.android.systemui.util.mockito.whenever
 import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.Captor
 import org.mockito.Mock
-import org.mockito.Mockito
 import org.mockito.Mockito.any
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.mock
 import org.mockito.Mockito.never
-import org.mockito.Mockito.spy
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
-import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.firstValue
 
 @OptIn(ExperimentalCoroutinesApi::class)
 @SmallTest
@@ -83,84 +67,25 @@
     private val kosmos = testKosmos()
 
     @JvmField @Rule var mockitoRule: MockitoRule = MockitoJUnit.rule()
-    @Mock private lateinit var displayManager: DisplayManager
-    @Mock
-    private lateinit var fingerprintInteractiveToAuthProvider: FingerprintInteractiveToAuthProvider
     @Mock private lateinit var layoutInflater: LayoutInflater
     @Mock private lateinit var sideFpsView: View
-
-    private val contextDisplayInfo = DisplayInfo()
-
-    private var displayWidth: Int = 0
-    private var displayHeight: Int = 0
-    private var boundsWidth: Int = 0
-    private var boundsHeight: Int = 0
-
-    private lateinit var deviceConfig: DeviceConfig
-    private lateinit var sensorLocation: SensorLocationInternal
-
-    enum class DeviceConfig {
-        X_ALIGNED,
-        Y_ALIGNED,
-    }
+    @Captor private lateinit var viewCaptor: ArgumentCaptor<View>
 
     @Before
     fun setup() {
         allowTestableLooperAsMainThread() // repeatWhenAttached requires the main thread
-
-        mContext = spy(mContext)
-
-        val resources = mContext.resources
-        whenever(mContext.display)
-            .thenReturn(
-                Display(mock(DisplayManagerGlobal::class.java), 1, contextDisplayInfo, resources)
-            )
-
         kosmos.layoutInflater = layoutInflater
-
-        whenever(fingerprintInteractiveToAuthProvider.enabledForCurrentUser)
-            .thenReturn(MutableStateFlow(false))
-
-        context.addMockSystemService(DisplayManager::class.java, displayManager)
         context.addMockSystemService(WindowManager::class.java, kosmos.windowManager)
-
         `when`(layoutInflater.inflate(R.layout.sidefps_view, null, false)).thenReturn(sideFpsView)
         `when`(sideFpsView.requireViewById<LottieAnimationView>(eq(R.id.sidefps_animation)))
             .thenReturn(mock(LottieAnimationView::class.java))
-        with(mock(ViewPropertyAnimator::class.java)) {
-            `when`(sideFpsView.animate()).thenReturn(this)
-            `when`(alpha(Mockito.anyFloat())).thenReturn(this)
-            `when`(setStartDelay(Mockito.anyLong())).thenReturn(this)
-            `when`(setDuration(Mockito.anyLong())).thenReturn(this)
-            `when`(setListener(any())).thenAnswer {
-                (it.arguments[0] as Animator.AnimatorListener).onAnimationEnd(
-                    mock(Animator::class.java)
-                )
-                this
-            }
-        }
     }
 
     @Test
     fun verifyIndicatorNotAdded_whenInRearDisplayMode() {
         kosmos.testScope.runTest {
-            setupTestConfiguration(
-                DeviceConfig.X_ALIGNED,
-                rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = true
-            )
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.NotRunning
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-            updatePrimaryBouncer(
-                isShowing = true,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
-            runCurrent()
-
+            setupTestConfiguration(isInRearDisplayMode = true)
+            updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true)
             verify(kosmos.windowManager, never()).addView(any(), any())
         }
     }
@@ -168,33 +93,14 @@
     @Test
     fun verifyIndicatorShowAndHide_onPrimaryBouncerShowAndHide() {
         kosmos.testScope.runTest {
-            setupTestConfiguration(
-                DeviceConfig.X_ALIGNED,
-                rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
-            )
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.NotRunning
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-            // Show primary bouncer
-            updatePrimaryBouncer(
-                isShowing = true,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
+            setupTestConfiguration(isInRearDisplayMode = false)
+            updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true)
             runCurrent()
 
             verify(kosmos.windowManager).addView(any(), any())
 
             // Hide primary bouncer
-            updatePrimaryBouncer(
-                isShowing = false,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
+            updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = false)
             runCurrent()
 
             verify(kosmos.windowManager).removeView(any())
@@ -204,30 +110,19 @@
     @Test
     fun verifyIndicatorShowAndHide_onAlternateBouncerShowAndHide() {
         kosmos.testScope.runTest {
-            setupTestConfiguration(
-                DeviceConfig.X_ALIGNED,
-                rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
-            )
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.NotRunning
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-            // Show alternate bouncer
-            kosmos.keyguardBouncerRepository.setAlternateVisible(true)
+            setupTestConfiguration(isInRearDisplayMode = false)
+            updateSfpsIndicatorRequests(kosmos, mContext, alternateBouncerRequest = true)
             runCurrent()
 
             verify(kosmos.windowManager).addView(any(), any())
 
-            var viewCaptor = argumentCaptor<View>()
             verify(kosmos.windowManager).addView(viewCaptor.capture(), any())
             verify(viewCaptor.firstValue)
                 .announceForAccessibility(
                     mContext.getText(R.string.accessibility_side_fingerprint_indicator_label)
                 )
 
-            // Hide alternate bouncer
-            kosmos.keyguardBouncerRepository.setAlternateVisible(false)
+            updateSfpsIndicatorRequests(kosmos, mContext, alternateBouncerRequest = false)
             runCurrent()
 
             verify(kosmos.windowManager).removeView(any())
@@ -237,30 +132,14 @@
     @Test
     fun verifyIndicatorShownAndHidden_onSystemServerAuthenticationStartedAndStopped() {
         kosmos.testScope.runTest {
-            setupTestConfiguration(
-                DeviceConfig.X_ALIGNED,
-                rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-            updatePrimaryBouncer(
-                isShowing = false,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
-            // System server authentication started
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.BiometricPromptAuthentication
-            )
+            setupTestConfiguration(isInRearDisplayMode = false)
+            updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = true)
             runCurrent()
 
             verify(kosmos.windowManager).addView(any(), any())
 
             // System server authentication stopped
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.NotRunning
-            )
+            updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = false)
             runCurrent()
 
             verify(kosmos.windowManager).removeView(any())
@@ -269,45 +148,37 @@
 
     // On progress bar shown - hide indicator
     // On progress bar hidden - show indicator
+    // TODO(b/365182034): update + enable when rest to unlock feature is implemented
+    @Ignore("b/365182034")
     @Test
     fun verifyIndicatorProgressBarInteraction() {
         kosmos.testScope.runTest {
             // Pre-auth conditions
-            setupTestConfiguration(
-                DeviceConfig.X_ALIGNED,
-                rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
-            )
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.NotRunning
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-
-            // Show primary bouncer
-            updatePrimaryBouncer(
-                isShowing = true,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
+            setupTestConfiguration(isInRearDisplayMode = false)
+            updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true)
             runCurrent()
 
             val inOrder = inOrder(kosmos.windowManager)
-
             // Verify indicator shown
             inOrder.verify(kosmos.windowManager).addView(any(), any())
 
             // Set progress bar visible
-            kosmos.sideFpsProgressBarViewModel.setVisible(true)
-
+            updateSfpsIndicatorRequests(
+                kosmos,
+                mContext,
+                primaryBouncerRequest = true,
+            ) // , progressBarShowing = true)
             runCurrent()
 
             // Verify indicator hidden
             inOrder.verify(kosmos.windowManager).removeView(any())
 
             // Set progress bar invisible
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-
+            updateSfpsIndicatorRequests(
+                kosmos,
+                mContext,
+                primaryBouncerRequest = true,
+            ) // , progressBarShowing = false)
             runCurrent()
 
             // Verify indicator shown
@@ -315,78 +186,18 @@
         }
     }
 
-    private fun updatePrimaryBouncer(
-        isShowing: Boolean,
-        isAnimatingAway: Boolean,
-        fpsDetectionRunning: Boolean,
-        isUnlockingWithFpAllowed: Boolean,
-    ) {
-        kosmos.keyguardBouncerRepository.setPrimaryShow(isShowing)
-        kosmos.keyguardBouncerRepository.setPrimaryStartingToHide(false)
-        val primaryStartDisappearAnimation = if (isAnimatingAway) Runnable {} else null
-        kosmos.keyguardBouncerRepository.setPrimaryStartDisappearAnimation(
-            primaryStartDisappearAnimation
-        )
-
-        whenever(kosmos.keyguardUpdateMonitor.isFingerprintDetectionRunning)
-            .thenReturn(fpsDetectionRunning)
-        whenever(kosmos.keyguardUpdateMonitor.isUnlockingWithFingerprintAllowed)
-            .thenReturn(isUnlockingWithFpAllowed)
-        mContext.orCreateTestableResources.addOverride(
-            R.bool.config_show_sidefps_hint_on_bouncer,
-            true
-        )
-    }
-
-    private suspend fun TestScope.setupTestConfiguration(
-        deviceConfig: DeviceConfig,
-        rotation: DisplayRotation = DisplayRotation.ROTATION_0,
-        isInRearDisplayMode: Boolean,
-    ) {
-        this@SideFpsOverlayViewBinderTest.deviceConfig = deviceConfig
-
-        when (deviceConfig) {
-            DeviceConfig.X_ALIGNED -> {
-                displayWidth = 3000
-                displayHeight = 1500
-                boundsWidth = 200
-                boundsHeight = 100
-                sensorLocation = SensorLocationInternal("", 2500, 0, boundsWidth / 2)
-            }
-            DeviceConfig.Y_ALIGNED -> {
-                displayWidth = 2500
-                displayHeight = 2000
-                boundsWidth = 100
-                boundsHeight = 200
-                sensorLocation = SensorLocationInternal("", displayWidth, 300, boundsHeight / 2)
-            }
-        }
-
-        whenever(kosmos.windowManager.maximumWindowMetrics)
-            .thenReturn(
-                WindowMetrics(
-                    Rect(0, 0, displayWidth, displayHeight),
-                    mock(WindowInsets::class.java),
-                )
-            )
-
-        contextDisplayInfo.uniqueId = DISPLAY_ID
-
+    private suspend fun TestScope.setupTestConfiguration(isInRearDisplayMode: Boolean) {
         kosmos.fingerprintPropertyRepository.setProperties(
             sensorId = 1,
             strength = SensorStrength.STRONG,
             sensorType = FingerprintSensorType.POWER_BUTTON,
-            sensorLocations = mapOf(DISPLAY_ID to sensorLocation)
+            sensorLocations = emptyMap(),
         )
 
         kosmos.displayStateRepository.setIsInRearDisplayMode(isInRearDisplayMode)
-        kosmos.displayStateRepository.setCurrentRotation(rotation)
+        kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0)
         kosmos.displayRepository.emitDisplayChangeEvent(0)
         kosmos.sideFpsOverlayViewBinder.start()
         runCurrent()
     }
-
-    companion object {
-        private const val DISPLAY_ID = "displayId"
-    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/PromptViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/PromptViewModelTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/PromptViewModelTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/PromptViewModelTest.kt
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt
index 0db7b62..84d062a 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/biometrics/ui/viewmodel/SideFpsOverlayViewModelTest.kt
@@ -30,23 +30,19 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.airbnb.lottie.model.KeyPath
-import com.android.keyguard.keyguardUpdateMonitor
 import com.android.settingslib.Utils
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.biometrics.FingerprintInteractiveToAuthProvider
-import com.android.systemui.biometrics.data.repository.biometricStatusRepository
 import com.android.systemui.biometrics.data.repository.fingerprintPropertyRepository
 import com.android.systemui.biometrics.domain.interactor.displayStateInteractor
-import com.android.systemui.biometrics.shared.model.AuthenticationReason
 import com.android.systemui.biometrics.shared.model.DisplayRotation
 import com.android.systemui.biometrics.shared.model.FingerprintSensorType
 import com.android.systemui.biometrics.shared.model.LottieCallback
 import com.android.systemui.biometrics.shared.model.SensorStrength
-import com.android.systemui.bouncer.data.repository.keyguardBouncerRepository
+import com.android.systemui.biometrics.updateSfpsIndicatorRequests
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.display.data.repository.displayRepository
 import com.android.systemui.display.data.repository.displayStateRepository
-import com.android.systemui.keyguard.ui.viewmodel.sideFpsProgressBarViewModel
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.res.R
 import com.android.systemui.testKosmos
@@ -84,17 +80,17 @@
     private val indicatorColor =
         Utils.getColorAttrDefaultColor(
             context,
-            com.android.internal.R.attr.materialColorPrimaryFixed
+            com.android.internal.R.attr.materialColorPrimaryFixed,
         )
     private val outerRimColor =
         Utils.getColorAttrDefaultColor(
             context,
-            com.android.internal.R.attr.materialColorPrimaryFixedDim
+            com.android.internal.R.attr.materialColorPrimaryFixedDim,
         )
     private val chevronFill =
         Utils.getColorAttrDefaultColor(
             context,
-            com.android.internal.R.attr.materialColorOnPrimaryFixed
+            com.android.internal.R.attr.materialColorOnPrimaryFixed,
         )
     private val color_blue400 =
         context.getColor(com.android.settingslib.color.R.color.settingslib_color_blue400)
@@ -133,7 +129,7 @@
             setupTestConfiguration(
                 DeviceConfig.X_ALIGNED,
                 rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
+                isInRearDisplayMode = false,
             )
 
             val overlayViewProperties by
@@ -167,7 +163,7 @@
             setupTestConfiguration(
                 DeviceConfig.Y_ALIGNED,
                 rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
+                isInRearDisplayMode = false,
             )
 
             val overlayViewProperties by
@@ -201,7 +197,7 @@
             setupTestConfiguration(
                 DeviceConfig.X_ALIGNED,
                 rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
+                isInRearDisplayMode = false,
             )
 
             val overlayViewParams by
@@ -243,7 +239,7 @@
             setupTestConfiguration(
                 DeviceConfig.Y_ALIGNED,
                 rotation = DisplayRotation.ROTATION_0,
-                isInRearDisplayMode = false
+                isInRearDisplayMode = false,
             )
 
             val overlayViewParams by
@@ -284,17 +280,7 @@
         kosmos.testScope.runTest {
             val lottieCallbacks by collectLastValue(kosmos.sideFpsOverlayViewModel.lottieCallbacks)
 
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.NotRunning
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-
-            updatePrimaryBouncer(
-                isShowing = true,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
+            updateSfpsIndicatorRequests(kosmos, mContext, primaryBouncerRequest = true)
             runCurrent()
 
             assertThat(lottieCallbacks)
@@ -312,17 +298,7 @@
             val lottieCallbacks by collectLastValue(kosmos.sideFpsOverlayViewModel.lottieCallbacks)
             setDarkMode(true)
 
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.BiometricPromptAuthentication
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-
-            updatePrimaryBouncer(
-                isShowing = false,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
+            updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = true)
             runCurrent()
 
             assertThat(lottieCallbacks)
@@ -338,17 +314,7 @@
             val lottieCallbacks by collectLastValue(kosmos.sideFpsOverlayViewModel.lottieCallbacks)
             setDarkMode(false)
 
-            kosmos.biometricStatusRepository.setFingerprintAuthenticationReason(
-                AuthenticationReason.BiometricPromptAuthentication
-            )
-            kosmos.sideFpsProgressBarViewModel.setVisible(false)
-
-            updatePrimaryBouncer(
-                isShowing = false,
-                isAnimatingAway = false,
-                fpsDetectionRunning = true,
-                isUnlockingWithFpAllowed = true
-            )
+            updateSfpsIndicatorRequests(kosmos, mContext, biometricPromptRequest = true)
             runCurrent()
 
             assertThat(lottieCallbacks)
@@ -371,29 +337,6 @@
         mContext.resources.configuration.uiMode = uiMode
     }
 
-    private fun updatePrimaryBouncer(
-        isShowing: Boolean,
-        isAnimatingAway: Boolean,
-        fpsDetectionRunning: Boolean,
-        isUnlockingWithFpAllowed: Boolean,
-    ) {
-        kosmos.keyguardBouncerRepository.setPrimaryShow(isShowing)
-        kosmos.keyguardBouncerRepository.setPrimaryStartingToHide(false)
-        val primaryStartDisappearAnimation = if (isAnimatingAway) Runnable {} else null
-        kosmos.keyguardBouncerRepository.setPrimaryStartDisappearAnimation(
-            primaryStartDisappearAnimation
-        )
-
-        whenever(kosmos.keyguardUpdateMonitor.isFingerprintDetectionRunning)
-            .thenReturn(fpsDetectionRunning)
-        whenever(kosmos.keyguardUpdateMonitor.isUnlockingWithFingerprintAllowed)
-            .thenReturn(isUnlockingWithFpAllowed)
-        mContext.orCreateTestableResources.addOverride(
-            R.bool.config_show_sidefps_hint_on_bouncer,
-            true
-        )
-    }
-
     private suspend fun TestScope.setupTestConfiguration(
         deviceConfig: DeviceConfig,
         rotation: DisplayRotation = DisplayRotation.ROTATION_0,
@@ -432,7 +375,7 @@
             sensorId = 1,
             strength = SensorStrength.STRONG,
             sensorType = FingerprintSensorType.POWER_BUTTON,
-            sensorLocations = mapOf(DISPLAY_ID to sensorLocation)
+            sensorLocations = mapOf(DISPLAY_ID to sensorLocation),
         )
 
         kosmos.displayStateRepository.setIsInRearDisplayMode(isInRearDisplayMode)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/composable/BouncerPredictiveBackTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/composable
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/composable/BouncerPredictiveBackTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/composable
diff --git a/packages/SystemUI/tests/src/com/android/systemui/classifier/BrightLineClassifierTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/classifier/BrightLineClassifierTest.java
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/classifier/BrightLineClassifierTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/classifier/BrightLineClassifierTest.java
diff --git a/packages/SystemUI/tests/src/com/android/systemui/classifier/ZigZagClassifierTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/classifier/ZigZagClassifierTest.java
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/classifier/ZigZagClassifierTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/classifier/ZigZagClassifierTest.java
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/CommunalWidgetDaoTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/CommunalWidgetDaoTest.kt
index d4d966a..2312bbd 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/CommunalWidgetDaoTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/CommunalWidgetDaoTest.kt
@@ -22,6 +22,7 @@
 import androidx.test.filters.SmallTest
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.communal.nano.CommunalHubState
+import com.android.systemui.communal.shared.model.CommunalContentSize
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.lifecycle.InstantTaskExecutorRule
 import com.google.common.truth.Truth.assertThat
@@ -102,7 +103,7 @@
                     widgetId = widgetId,
                     provider = provider,
                     rank = rank,
-                    userSerialNumber = userSerialNumber
+                    userSerialNumber = userSerialNumber,
                 )
             }
             assertThat(widgets())
@@ -110,7 +111,7 @@
                     communalItemRankEntry1,
                     communalWidgetItemEntry1,
                     communalItemRankEntry2,
-                    communalWidgetItemEntry2
+                    communalWidgetItemEntry2,
                 )
         }
 
@@ -129,7 +130,7 @@
                 communalWidgetDao.addWidget(
                     widgetId = widgetId,
                     provider = provider,
-                    userSerialNumber = userSerialNumber
+                    userSerialNumber = userSerialNumber,
                 )
             }
 
@@ -165,7 +166,7 @@
                     communalItemRankEntry1,
                     communalWidgetItemEntry1,
                     communalItemRankEntry2,
-                    communalWidgetItemEntry2
+                    communalWidgetItemEntry2,
                 )
 
             communalWidgetDao.deleteWidgetById(communalWidgetItemEntry1.widgetId)
@@ -251,6 +252,7 @@
                     componentName = "pk_name/cls_name_4",
                     itemId = 4L,
                     userSerialNumber = 0,
+                    spanY = 3,
                 )
             assertThat(widgets())
                 .containsExactly(
@@ -267,6 +269,68 @@
         }
 
     @Test
+    fun addWidget_withDifferentSpanY_readsCorrectValuesInDb() =
+        testScope.runTest {
+            val widgets = collectLastValue(communalWidgetDao.getWidgets())
+
+            // Add widgets with different spanY values
+            communalWidgetDao.addWidget(
+                widgetId = 1,
+                provider = ComponentName("pkg_name", "cls_name_1"),
+                rank = 0,
+                userSerialNumber = 0,
+                spanY = CommunalContentSize.FULL.span,
+            )
+            communalWidgetDao.addWidget(
+                widgetId = 2,
+                provider = ComponentName("pkg_name", "cls_name_2"),
+                rank = 1,
+                userSerialNumber = 0,
+                spanY = CommunalContentSize.HALF.span,
+            )
+            communalWidgetDao.addWidget(
+                widgetId = 3,
+                provider = ComponentName("pkg_name", "cls_name_3"),
+                rank = 2,
+                userSerialNumber = 0,
+                spanY = CommunalContentSize.THIRD.span,
+            )
+
+            // Verify that the widgets have the correct spanY values
+            assertThat(widgets())
+                .containsExactly(
+                    CommunalItemRank(uid = 1L, rank = 0),
+                    CommunalWidgetItem(
+                        uid = 1L,
+                        widgetId = 1,
+                        componentName = "pkg_name/cls_name_1",
+                        itemId = 1L,
+                        userSerialNumber = 0,
+                        spanY = CommunalContentSize.FULL.span,
+                    ),
+                    CommunalItemRank(uid = 2L, rank = 1),
+                    CommunalWidgetItem(
+                        uid = 2L,
+                        widgetId = 2,
+                        componentName = "pkg_name/cls_name_2",
+                        itemId = 2L,
+                        userSerialNumber = 0,
+                        spanY = CommunalContentSize.HALF.span,
+                    ),
+                    CommunalItemRank(uid = 3L, rank = 2),
+                    CommunalWidgetItem(
+                        uid = 3L,
+                        widgetId = 3,
+                        componentName = "pkg_name/cls_name_3",
+                        itemId = 3L,
+                        userSerialNumber = 0,
+                        spanY = CommunalContentSize.THIRD.span,
+                    ),
+                )
+                .inOrder()
+        }
+
+    @Test
     fun restoreCommunalHubState() =
         testScope.runTest {
             // Set up db
@@ -288,6 +352,7 @@
                         componentName = fakeWidget.componentName,
                         itemId = rank.uid,
                         userSerialNumber = fakeWidget.userSerialNumber,
+                        spanY = 3,
                     )
                 expected[rank] = widget
             }
@@ -343,6 +408,7 @@
                 componentName = widgetInfo1.provider.flattenToString(),
                 itemId = communalItemRankEntry1.uid,
                 userSerialNumber = widgetInfo1.userSerialNumber,
+                spanY = 3,
             )
         val communalWidgetItemEntry2 =
             CommunalWidgetItem(
@@ -351,6 +417,7 @@
                 componentName = widgetInfo2.provider.flattenToString(),
                 itemId = communalItemRankEntry2.uid,
                 userSerialNumber = widgetInfo2.userSerialNumber,
+                spanY = 3,
             )
         val communalWidgetItemEntry3 =
             CommunalWidgetItem(
@@ -359,6 +426,7 @@
                 componentName = widgetInfo3.provider.flattenToString(),
                 itemId = communalItemRankEntry3.uid,
                 userSerialNumber = widgetInfo3.userSerialNumber,
+                spanY = 3,
             )
         val fakeState =
             CommunalHubState().apply {
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/DefaultWidgetPopulationTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/DefaultWidgetPopulationTest.kt
index eba395b..596db07 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/DefaultWidgetPopulationTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/db/DefaultWidgetPopulationTest.kt
@@ -117,6 +117,7 @@
                     componentName = defaultWidgets[0],
                     rank = 0,
                     userSerialNumber = 0,
+                    spanY = 3,
                 )
             verify(communalWidgetDao)
                 .addWidget(
@@ -124,6 +125,7 @@
                     componentName = defaultWidgets[1],
                     rank = 1,
                     userSerialNumber = 0,
+                    spanY = 3,
                 )
             verify(communalWidgetDao)
                 .addWidget(
@@ -131,6 +133,7 @@
                     componentName = defaultWidgets[2],
                     rank = 2,
                     userSerialNumber = 0,
+                    spanY = 3,
                 )
         }
 
@@ -152,6 +155,7 @@
                     componentName = any(),
                     rank = anyInt(),
                     userSerialNumber = anyInt(),
+                    spanY = anyInt(),
                 )
         }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt
index 980a5ec..3d30ecc 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt
@@ -143,7 +143,8 @@
     fun communalWidgets_queryWidgetsFromDb() =
         testScope.runTest {
             val communalItemRankEntry = CommunalItemRank(uid = 1L, rank = 1)
-            val communalWidgetItemEntry = CommunalWidgetItem(uid = 1L, 1, "pk_name/cls_name", 1L, 0)
+            val communalWidgetItemEntry =
+                CommunalWidgetItem(uid = 1L, 1, "pk_name/cls_name", 1L, 0, 3)
             fakeWidgets.value = mapOf(communalItemRankEntry to communalWidgetItemEntry)
             fakeProviders.value = mapOf(1 to providerInfoA)
 
@@ -169,19 +170,15 @@
             fakeWidgets.value =
                 mapOf(
                     CommunalItemRank(uid = 1L, rank = 1) to
-                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0),
+                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0, 3),
                     CommunalItemRank(uid = 2L, rank = 2) to
-                        CommunalWidgetItem(uid = 2L, 2, "pk_2/cls_2", 2L, 0),
+                        CommunalWidgetItem(uid = 2L, 2, "pk_2/cls_2", 2L, 0, 3),
                     CommunalItemRank(uid = 3L, rank = 3) to
-                        CommunalWidgetItem(uid = 3L, 3, "pk_3/cls_3", 3L, 0),
+                        CommunalWidgetItem(uid = 3L, 3, "pk_3/cls_3", 3L, 0, 3),
                     CommunalItemRank(uid = 4L, rank = 4) to
-                        CommunalWidgetItem(uid = 4L, 4, "pk_4/cls_4", 4L, 0),
+                        CommunalWidgetItem(uid = 4L, 4, "pk_4/cls_4", 4L, 0, 3),
                 )
-            fakeProviders.value =
-                mapOf(
-                    1 to providerInfoA,
-                    2 to providerInfoB,
-                )
+            fakeProviders.value = mapOf(1 to providerInfoA, 2 to providerInfoB)
 
             // Expect to see only widget 1 and 2
             val communalWidgets by collectLastValue(underTest.communalWidgets)
@@ -207,15 +204,11 @@
             fakeWidgets.value =
                 mapOf(
                     CommunalItemRank(uid = 1L, rank = 1) to
-                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0),
+                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0, 3),
                     CommunalItemRank(uid = 2L, rank = 2) to
-                        CommunalWidgetItem(uid = 2L, 2, "pk_2/cls_2", 2L, 0),
+                        CommunalWidgetItem(uid = 2L, 2, "pk_2/cls_2", 2L, 0, 3),
                 )
-            fakeProviders.value =
-                mapOf(
-                    1 to providerInfoA,
-                    2 to providerInfoB,
-                )
+            fakeProviders.value = mapOf(1 to providerInfoA, 2 to providerInfoB)
 
             // Expect two widgets
             val communalWidgets by collectLastValue(underTest.communalWidgets)
@@ -235,11 +228,7 @@
                 )
 
             // Provider info updated for widget 1
-            fakeProviders.value =
-                mapOf(
-                    1 to providerInfoC,
-                    2 to providerInfoB,
-                )
+            fakeProviders.value = mapOf(1 to providerInfoC, 2 to providerInfoB)
             runCurrent()
 
             assertThat(communalWidgets)
@@ -269,7 +258,7 @@
             whenever(
                     communalWidgetHost.allocateIdAndBindWidget(
                         any<ComponentName>(),
-                        any<UserHandle>()
+                        any<UserHandle>(),
                     )
                 )
                 .thenReturn(id)
@@ -294,7 +283,7 @@
             whenever(
                     communalWidgetHost.allocateIdAndBindWidget(
                         any<ComponentName>(),
-                        any<UserHandle>()
+                        any<UserHandle>(),
                     )
                 )
                 .thenReturn(id)
@@ -303,7 +292,7 @@
 
             verify(communalWidgetHost).allocateIdAndBindWidget(provider, mainUser)
             verify(communalWidgetDao, never())
-                .addWidget(anyInt(), any<ComponentName>(), anyInt(), anyInt())
+                .addWidget(anyInt(), any<ComponentName>(), anyInt(), anyInt(), anyInt())
             verify(appWidgetHost).deleteAppWidgetId(id)
 
             // Verify backup not requested
@@ -321,7 +310,7 @@
             whenever(
                     communalWidgetHost.allocateIdAndBindWidget(
                         any<ComponentName>(),
-                        any<UserHandle>()
+                        any<UserHandle>(),
                     )
                 )
                 .thenReturn(id)
@@ -332,7 +321,7 @@
 
             verify(communalWidgetHost).allocateIdAndBindWidget(provider, mainUser)
             verify(communalWidgetDao, never())
-                .addWidget(anyInt(), any<ComponentName>(), anyInt(), anyInt())
+                .addWidget(anyInt(), any<ComponentName>(), anyInt(), anyInt(), anyInt())
             verify(appWidgetHost).deleteAppWidgetId(id)
 
             // Verify backup not requested
@@ -350,7 +339,7 @@
             whenever(
                     communalWidgetHost.allocateIdAndBindWidget(
                         any<ComponentName>(),
-                        any<UserHandle>()
+                        any<UserHandle>(),
                     )
                 )
                 .thenReturn(id)
@@ -650,8 +639,10 @@
                     eq(newWidgetId),
                     componentNameCaptor.capture(),
                     eq(2),
-                    eq(testUserSerialNumber(workProfile))
+                    eq(testUserSerialNumber(workProfile)),
+                    anyInt(),
                 )
+
             assertThat(componentNameCaptor.firstValue)
                 .isEqualTo(ComponentName("pk_name", "fake_widget_2"))
         }
@@ -662,9 +653,9 @@
             fakeWidgets.value =
                 mapOf(
                     CommunalItemRank(uid = 1L, rank = 1) to
-                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0),
+                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0, 3),
                     CommunalItemRank(uid = 2L, rank = 2) to
-                        CommunalWidgetItem(uid = 2L, 2, "pk_2/cls_2", 2L, 0),
+                        CommunalWidgetItem(uid = 2L, 2, "pk_2/cls_2", 2L, 0, 3),
                 )
 
             // Widget 1 is installed
@@ -707,7 +698,7 @@
             fakeWidgets.value =
                 mapOf(
                     CommunalItemRank(uid = 1L, rank = 1) to
-                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0),
+                        CommunalWidgetItem(uid = 1L, 1, "pk_1/cls_1", 1L, 0, 3)
                 )
 
             // Widget 1 is pending install
@@ -732,7 +723,7 @@
                         componentName = ComponentName("pk_1", "cls_1"),
                         icon = fakeIcon,
                         user = mainUser,
-                    ),
+                    )
                 )
 
             // Package for widget 1 finished installing
@@ -749,10 +740,23 @@
                         appWidgetId = 1,
                         providerInfo = providerInfoA,
                         rank = 1,
-                    ),
+                    )
                 )
         }
 
+    @Test
+    fun updateWidgetSpanY_updatesWidgetInDaoAndRequestsBackup() =
+        testScope.runTest {
+            val widgetId = 1
+            val newSpanY = 6
+
+            underTest.updateWidgetSpanY(widgetId, newSpanY)
+            runCurrent()
+
+            verify(communalWidgetDao).updateWidgetSpanY(widgetId, newSpanY)
+            verify(backupManager).dataChanged()
+        }
+
     private fun setAppWidgetIds(ids: List<Int>) {
         whenever(appWidgetHost.appWidgetIds).thenReturn(ids.toIntArray())
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFaceAuthInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFaceAuthInteractorTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFaceAuthInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/deviceentry/domain/interactor/DeviceEntryFaceAuthInteractorTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/DreamOverlayServiceTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/touch/CommunalTouchHandlerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/touch
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/dreams/touch/CommunalTouchHandlerTest.java
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/dreams/touch
diff --git a/packages/SystemUI/tests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadStatsInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadStatsInteractorTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadStatsInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadStatsInteractorTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt
index 8f9e238..8b13411 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/data/repository/KeyboardRepositoryTest.kt
@@ -145,7 +145,7 @@
 
             fakeInputManager.addPhysicalKeyboard(
                 PHYSICAL_NOT_FULL_KEYBOARD_ID,
-                isFullKeyboard = false
+                isFullKeyboard = false,
             )
             assertThat(isKeyboardConnected).isFalse()
 
@@ -223,7 +223,7 @@
             backlightListenerCaptor.value.onBacklightChanged(
                 current = 1,
                 max = 5,
-                triggeredByKeyPress = false
+                triggeredByKeyPress = false,
             )
             assertThat(backlight).isNull()
         }
@@ -239,7 +239,7 @@
             backlightListenerCaptor.value.onBacklightChanged(
                 current = 1,
                 max = 5,
-                triggeredByKeyPress = true
+                triggeredByKeyPress = true,
             )
             assertThat(backlight).isNotNull()
         }
@@ -318,15 +318,75 @@
         }
     }
 
+    @Test
+    fun connectedKeyboards_emitsAllKeyboards() {
+        testScope.runTest {
+            val firstKeyboard = Keyboard(vendorId = 1, productId = 1)
+            val secondKeyboard = Keyboard(vendorId = 2, productId = 2)
+            captureDeviceListener()
+            val keyboards by collectLastValueImmediately(underTest.connectedKeyboards)
+
+            fakeInputManager.addPhysicalKeyboard(
+                PHYSICAL_FULL_KEYBOARD_ID,
+                vendorId = firstKeyboard.vendorId,
+                productId = firstKeyboard.productId,
+            )
+            assertThat(keyboards)
+                .containsExactly(Keyboard(firstKeyboard.vendorId, firstKeyboard.productId))
+
+            fakeInputManager.addPhysicalKeyboard(
+                ANOTHER_PHYSICAL_FULL_KEYBOARD_ID,
+                vendorId = secondKeyboard.vendorId,
+                productId = secondKeyboard.productId,
+            )
+            assertThat(keyboards)
+                .containsExactly(
+                    Keyboard(firstKeyboard.vendorId, firstKeyboard.productId),
+                    Keyboard(secondKeyboard.vendorId, secondKeyboard.productId),
+                )
+        }
+    }
+
+    @Test
+    fun connectedKeyboards_emitsOnlyFullPhysicalKeyboards() {
+        testScope.runTest {
+            captureDeviceListener()
+            val keyboards by collectLastValueImmediately(underTest.connectedKeyboards)
+
+            fakeInputManager.addPhysicalKeyboard(PHYSICAL_FULL_KEYBOARD_ID)
+            fakeInputManager.addDevice(VIRTUAL_FULL_KEYBOARD_ID, SOURCE_KEYBOARD)
+            fakeInputManager.addPhysicalKeyboard(
+                PHYSICAL_NOT_FULL_KEYBOARD_ID,
+                isFullKeyboard = false,
+            )
+
+            assertThat(keyboards).hasSize(1)
+        }
+    }
+
+    @Test
+    fun connectedKeyboards_emitsOnlyConnectedKeyboards() {
+        testScope.runTest {
+            captureDeviceListener()
+            val keyboards by collectLastValueImmediately(underTest.connectedKeyboards)
+
+            fakeInputManager.addPhysicalKeyboard(PHYSICAL_FULL_KEYBOARD_ID)
+            fakeInputManager.addPhysicalKeyboard(ANOTHER_PHYSICAL_FULL_KEYBOARD_ID)
+            fakeInputManager.removeDevice(ANOTHER_PHYSICAL_FULL_KEYBOARD_ID)
+
+            assertThat(keyboards).hasSize(1)
+        }
+    }
+
     private fun KeyboardBacklightListener.onBacklightChanged(
         current: Int,
         max: Int,
-        triggeredByKeyPress: Boolean = true
+        triggeredByKeyPress: Boolean = true,
     ) {
         onKeyboardBacklightChanged(
             /* deviceId= */ 0,
             TestBacklightState(current, max),
-            triggeredByKeyPress
+            triggeredByKeyPress,
         )
     }
 
@@ -343,7 +403,7 @@
 
     private class TestBacklightState(
         private val brightnessLevel: Int,
-        private val maxBrightnessLevel: Int
+        private val maxBrightnessLevel: Int,
     ) : KeyboardBacklightState() {
         override fun getBrightnessLevel() = brightnessLevel
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/data/repository/ShortcutHelperCategoriesRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/shortcut/data/repository/ShortcutHelperCategoriesRepositoryTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/data/repository/ShortcutHelperCategoriesRepositoryTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/shortcut/data/repository/ShortcutHelperCategoriesRepositoryTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/domain/interactor/ShortcutHelperCategoriesInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/shortcut/domain/interactor/ShortcutHelperCategoriesInteractorTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/domain/interactor/ShortcutHelperCategoriesInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/shortcut/domain/interactor/ShortcutHelperCategoriesInteractorTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/ui/ShortcutHelperActivityStarterTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/shortcut/ui
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/ui/ShortcutHelperActivityStarterTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/keyboard/shortcut/ui
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepositoryTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepositoryTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/KeyguardClockRepositoryTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardSmartspaceRepositoryImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/KeyguardSmartspaceRepositoryImplTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardSmartspaceRepositoryImplTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/data/repository/KeyguardSmartspaceRepositoryImplTest.kt
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractorTest.kt
index 073ed61..b6ec6a6 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractorTest.kt
@@ -21,16 +21,23 @@
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.coroutines.collectValues
 import com.android.systemui.keyguard.data.fakeLightRevealScrimRepository
+import com.android.systemui.keyguard.data.repository.DEFAULT_REVEAL_DURATION
 import com.android.systemui.keyguard.data.repository.DEFAULT_REVEAL_EFFECT
 import com.android.systemui.keyguard.data.repository.FakeLightRevealScrimRepository
+import com.android.systemui.keyguard.data.repository.FakeLightRevealScrimRepository.RevealAnimatorRequest
 import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
 import com.android.systemui.keyguard.shared.model.KeyguardState
 import com.android.systemui.keyguard.shared.model.TransitionState
 import com.android.systemui.keyguard.shared.model.TransitionStep
 import com.android.systemui.kosmos.testScope
+import com.android.systemui.power.data.repository.fakePowerRepository
+import com.android.systemui.power.domain.interactor.powerInteractor
+import com.android.systemui.power.shared.model.WakeSleepReason
+import com.android.systemui.power.shared.model.WakefulnessState
 import com.android.systemui.statusbar.LightRevealEffect
 import com.android.systemui.statusbar.LightRevealScrim
 import com.android.systemui.testKosmos
+import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
@@ -52,6 +59,8 @@
 
     private val fakeKeyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository
 
+    private val fakePowerRepository = kosmos.fakePowerRepository
+
     private val underTest = kosmos.lightRevealScrimInteractor
 
     private val reveal1 =
@@ -107,4 +116,50 @@
             runCurrent()
             assertEquals(listOf(DEFAULT_REVEAL_EFFECT, reveal1, reveal2), values)
         }
+
+    @Test
+    fun transitionToAod_folding_doesNotAnimateTheScrim() =
+        kosmos.testScope.runTest {
+            updateWakefulness(goToSleepReason = WakeSleepReason.FOLD)
+            runCurrent()
+
+            // Transition to AOD
+            fakeKeyguardTransitionRepository.sendTransitionStep(
+                TransitionStep(to = KeyguardState.AOD, transitionState = TransitionState.STARTED)
+            )
+            runCurrent()
+
+            assertThat(fakeLightRevealScrimRepository.revealAnimatorRequests.last())
+                .isEqualTo(RevealAnimatorRequest(reveal = false, duration = 0))
+        }
+
+    @Test
+    fun transitionToAod_powerButton_animatesTheScrim() =
+        kosmos.testScope.runTest {
+            updateWakefulness(goToSleepReason = WakeSleepReason.POWER_BUTTON)
+            runCurrent()
+
+            // Transition to AOD
+            fakeKeyguardTransitionRepository.sendTransitionStep(
+                TransitionStep(to = KeyguardState.AOD, transitionState = TransitionState.STARTED)
+            )
+            runCurrent()
+
+            assertThat(fakeLightRevealScrimRepository.revealAnimatorRequests.last())
+                .isEqualTo(
+                    RevealAnimatorRequest(
+                        reveal = false,
+                        duration = DEFAULT_REVEAL_DURATION
+                    )
+                )
+        }
+
+    private fun updateWakefulness(goToSleepReason: WakeSleepReason) {
+        fakePowerRepository.updateWakefulness(
+            rawState = WakefulnessState.STARTING_TO_SLEEP,
+            lastWakeReason = WakeSleepReason.POWER_BUTTON,
+            lastSleepReason = goToSleepReason,
+            powerButtonLaunchGestureTriggered = false
+        )
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/binder/WindowManagerLockscreenVisibilityManagerTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/binder/WindowManagerLockscreenVisibilityManagerTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/binder/WindowManagerLockscreenVisibilityManagerTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/keyguard/ui/binder/WindowManagerLockscreenVisibilityManagerTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/log/table/LogDiffsForTableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/log/table/LogDiffsForTableTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/log/table/LogDiffsForTableTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/log/table/LogDiffsForTableTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/log/table/TableLogBufferFactoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/log/table/TableLogBufferFactoryTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/log/table/TableLogBufferFactoryTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/log/table/TableLogBufferFactoryTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/log/table/TableLogBufferTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/log/table/TableLogBufferTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/log/table/TableLogBufferTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/log/table/TableLogBufferTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt
index 7da2e9a..fc9e595 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt
@@ -408,6 +408,40 @@
             verify(mockImageLoader, times(1)).loadBitmap(any(), anyInt(), anyInt(), anyInt())
         }
 
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun testLoadMediaDataInBg_fromResumeToActive_doesNotCancelResumeToActiveTask() =
+        testScope.runTest {
+            val mockImageLoader = mock<ImageLoader>()
+            val mediaDataLoader =
+                MediaDataLoader(
+                    context,
+                    testDispatcher,
+                    testScope,
+                    mediaControllerFactory,
+                    mediaFlags,
+                    mockImageLoader,
+                    statusBarManager,
+                )
+            metadataBuilder.putString(
+                MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
+                "content://album_art_uri",
+            )
+
+            testScope.launch {
+                mediaDataLoader.loadMediaData(
+                    KEY,
+                    createMediaNotification(),
+                    isConvertingToActive = true,
+                )
+            }
+            testScope.launch { mediaDataLoader.loadMediaData(KEY, createMediaNotification()) }
+            testScope.launch { mediaDataLoader.loadMediaData(KEY, createMediaNotification()) }
+            testScope.advanceUntilIdle()
+
+            verify(mockImageLoader, times(2)).loadBitmap(any(), anyInt(), anyInt(), anyInt())
+        }
+
     private fun createMediaNotification(
         mediaSession: MediaSession? = session,
         applicationInfo: ApplicationInfo? = null,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/ui/viewmodel/TaskSwitcherNotificationViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/mediaprojection/taskswitcher/ui/viewmodel/TaskSwitcherNotificationViewModelTest.kt
similarity index 100%
rename from packages/SystemUI/tests/src/com/android/systemui/mediaprojection/taskswitcher/ui/viewmodel/TaskSwitcherNotificationViewModelTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/mediaprojection/taskswitcher/ui/viewmodel/TaskSwitcherNotificationViewModelTest.kt
diff --git a/packages/SystemUI/tests/src/com/android/systemui/mediarouter/data/repository/MediaRouterRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/mediarouter/data/repository/MediaRouterRepositoryTest.kt
similarity index 96%
rename from packages/SystemUI/tests/src/com/android/systemui/mediarouter/data/repository/MediaRouterRepositoryTest.kt
rename to packages/SystemUI/multivalentTests/src/com/android/systemui/mediarouter/data/repository/MediaRouterRepositoryTest.kt
index 84ec1a5..77be8c7 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/mediarouter/data/repository/MediaRouterRepositoryTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/mediarouter/data/repository/MediaRouterRepositoryTest.kt
@@ -17,6 +17,7 @@
 package com.android.systemui.mediarouter.data.repository
 
 import androidx.test.filters.SmallTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.coroutines.collectLastValue
 import com.android.systemui.kosmos.Kosmos
@@ -28,9 +29,11 @@
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
 
 @SmallTest
 @OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
 class MediaRouterRepositoryTest : SysuiTestCase() {
     val kosmos = Kosmos()
     val testScope = kosmos.testScope
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelForceQSTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelForceQSTest.kt
index acd69af..da16640 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelForceQSTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelForceQSTest.kt
@@ -26,10 +26,11 @@
 import com.google.common.truth.Truth.assertThat
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
+import platform.test.runner.parameterized.ParameterizedAndroidJunit4
+import platform.test.runner.parameterized.Parameters
 
 @SmallTest
-@RunWith(Parameterized::class)
+@RunWith(ParameterizedAndroidJunit4::class)
 @RunWithLooper
 class QSFragmentComposeViewModelForceQSTest(private val testData: TestData) :
     AbstractQSFragmentComposeViewModelTest() {
@@ -75,7 +76,7 @@
     companion object {
         private const val EXPANSION = 0.3f
 
-        @Parameterized.Parameters(name = "{0}")
+        @Parameters(name = "{0}")
         @JvmStatic
         fun createTestData(): List<TestData> {
             return statusBarStates.flatMap { statusBarState ->
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerHapticsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerHapticsViewModelTest.kt
index 664315d..ca9500b 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerHapticsViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerHapticsViewModelTest.kt
@@ -51,7 +51,7 @@
 import org.mockito.kotlin.eq
 import org.mockito.kotlin.mock
 import org.mockito.kotlin.verify
-import org.mockito.kotlin.verifyZeroInteractions
+import org.mockito.kotlin.verifyNoMoreInteractions
 
 @OptIn(ExperimentalCoroutinesApi::class)
 @SmallTest
@@ -134,7 +134,7 @@
             runCurrent()
 
             // THEN the view does not play a haptic feedback constant
-            verifyZeroInteractions(view)
+            verifyNoMoreInteractions(view)
         }
 
     @EnableFlags(Flags.FLAG_MSDL_FEEDBACK, Flags.FLAG_DUAL_SHADE)
@@ -202,7 +202,7 @@
             runCurrent()
 
             // THEN the view does not play a haptic feedback constant
-            verifyZeroInteractions(view)
+            verifyNoMoreInteractions(view)
         }
 
     private fun createTransitionState(from: SceneKey, to: ContentKey) =
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModelTest.kt
index 15d6881..fcb366b 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModelTest.kt
@@ -29,6 +29,7 @@
 import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
 import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
 import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.coroutines.collectValues
 import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository
 import com.android.systemui.deviceentry.domain.interactor.deviceEntryInteractor
 import com.android.systemui.flags.EnableSceneContainer
@@ -50,6 +51,7 @@
 import com.android.systemui.shade.shared.flag.DualShade
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.test.TestScope
@@ -248,6 +250,27 @@
             assertThat(actions?.get(Swipe.Up)).isEqualTo(UserActionResult(Scenes.Communal))
         }
 
+    @Test
+    fun upTransitionSceneKey_neverGoesBackToShadeScene() =
+        testScope.runTest {
+            val actions by collectValues(underTest.actions)
+            val currentScene by collectLastValue(kosmos.sceneInteractor.currentScene)
+            assertThat(currentScene).isEqualTo(Scenes.Lockscreen)
+            kosmos.sceneInteractor.changeScene(Scenes.Shade, "")
+            assertThat(currentScene).isEqualTo(Scenes.Shade)
+
+            kosmos.sceneInteractor.changeScene(Scenes.QuickSettings, "")
+            assertThat(currentScene).isEqualTo(Scenes.QuickSettings)
+
+            actions.forEachIndexed { index, map ->
+                assertWithMessage(
+                        "Actions on index $index is incorrectly mapping back to the Shade scene!"
+                    )
+                    .that((map[Swipe.Up] as? UserActionResult.ChangeScene)?.toScene)
+                    .isNotEqualTo(Scenes.Shade)
+            }
+        }
+
     private fun TestScope.setDeviceEntered(isEntered: Boolean) {
         if (isEntered) {
             // Unlock the device marking the device has entered.
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerTest.java
index 523a89a..5b0b59d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerTest.java
@@ -71,7 +71,11 @@
 import com.android.internal.widget.LockPatternUtils;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.broadcast.BroadcastDispatcher;
+import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor;
+import com.android.systemui.deviceentry.shared.model.DeviceUnlockStatus;
 import com.android.systemui.dump.DumpManager;
+import com.android.systemui.flags.DisableSceneContainer;
+import com.android.systemui.flags.EnableSceneContainer;
 import com.android.systemui.flags.FakeFeatureFlagsClassic;
 import com.android.systemui.log.LogWtfHandlerRule;
 import com.android.systemui.plugins.statusbar.StatusBarStateController;
@@ -90,6 +94,10 @@
 
 import com.google.android.collect.Lists;
 
+import dagger.Lazy;
+
+import kotlinx.coroutines.flow.StateFlow;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -152,6 +160,12 @@
     private BroadcastDispatcher mBroadcastDispatcher;
     @Mock
     private KeyguardStateController mKeyguardStateController;
+    @Mock
+    private Lazy<DeviceUnlockedInteractor> mDeviceUnlockedInteractorLazy;
+    @Mock
+    private DeviceUnlockedInteractor mDeviceUnlockedInteractor;
+    @Mock
+    private StateFlow<DeviceUnlockStatus> mDeviceUnlockStatusStateFlow;
 
     private UserInfo mCurrentUser;
     private UserInfo mSecondaryUser;
@@ -238,6 +252,9 @@
         mLockscreenUserManager = new TestNotificationLockscreenUserManager(mContext);
         mLockscreenUserManager.setUpWithPresenter(mPresenter);
 
+        when(mDeviceUnlockedInteractor.getDeviceUnlockStatus())
+                .thenReturn(mDeviceUnlockStatusStateFlow);
+
         mBackgroundExecutor.runAllReady();
     }
 
@@ -493,7 +510,8 @@
     }
 
     @Test
-    public void testUpdateIsPublicMode() {
+    @DisableSceneContainer
+    public void testUpdateIsPublicMode_sceneContainerDisabled() {
         when(mKeyguardStateController.isMethodSecure()).thenReturn(true);
         when(mKeyguardStateController.isShowing()).thenReturn(false);
 
@@ -527,6 +545,57 @@
         mBackgroundExecutor.runAllReady();
         assertTrue(mLockscreenUserManager.isLockscreenPublicMode(0));
         verify(listener, never()).onNotificationStateChanged();
+
+        verify(mDeviceUnlockedInteractorLazy, never()).get();
+    }
+
+    @Test
+    @EnableSceneContainer
+    public void testUpdateIsPublicMode_sceneContainerEnabled() {
+        when(mDeviceUnlockedInteractorLazy.get()).thenReturn(mDeviceUnlockedInteractor);
+
+        // device is unlocked
+        when(mDeviceUnlockStatusStateFlow.getValue()).thenReturn(new DeviceUnlockStatus(
+                /* isUnlocked = */ true,
+                /* deviceUnlockSource = */ null
+        ));
+
+        NotificationStateChangedListener listener = mock(NotificationStateChangedListener.class);
+        mLockscreenUserManager.addNotificationStateChangedListener(listener);
+        mLockscreenUserManager.mCurrentProfiles.append(0, mock(UserInfo.class));
+
+        // first call explicitly sets user 0 to not public; notifies
+        mLockscreenUserManager.updatePublicMode();
+        mBackgroundExecutor.runAllReady();
+        assertFalse(mLockscreenUserManager.isLockscreenPublicMode(0));
+        verify(listener).onNotificationStateChanged();
+        clearInvocations(listener);
+
+        // calling again has no changes; does not notify
+        mLockscreenUserManager.updatePublicMode();
+        mBackgroundExecutor.runAllReady();
+        assertFalse(mLockscreenUserManager.isLockscreenPublicMode(0));
+        verify(listener, never()).onNotificationStateChanged();
+
+        // device is not unlocked
+        when(mDeviceUnlockStatusStateFlow.getValue()).thenReturn(new DeviceUnlockStatus(
+                /* isUnlocked = */ false,
+                /* deviceUnlockSource = */ null
+        ));
+
+        // Calling again with device now not unlocked makes user 0 public; notifies
+        when(mKeyguardStateController.isShowing()).thenReturn(true);
+        mLockscreenUserManager.updatePublicMode();
+        mBackgroundExecutor.runAllReady();
+        assertTrue(mLockscreenUserManager.isLockscreenPublicMode(0));
+        verify(listener).onNotificationStateChanged();
+        clearInvocations(listener);
+
+        // calling again has no changes; does not notify
+        mLockscreenUserManager.updatePublicMode();
+        mBackgroundExecutor.runAllReady();
+        assertTrue(mLockscreenUserManager.isLockscreenPublicMode(0));
+        verify(listener, never()).onNotificationStateChanged();
     }
 
     @Test
@@ -972,7 +1041,9 @@
                     mSettings,
                     mock(DumpManager.class),
                     mock(LockPatternUtils.class),
-                    mFakeFeatureFlags);
+                    mFakeFeatureFlags,
+                    mDeviceUnlockedInteractorLazy
+            );
         }
 
         public BroadcastReceiver getBaseBroadcastReceiverForTest() {
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/emptyshade/ui/viewmodel/EmptyShadeViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/emptyshade/ui/viewmodel/EmptyShadeViewModelTest.kt
index 28857a0..34f4608 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/emptyshade/ui/viewmodel/EmptyShadeViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/notification/emptyshade/ui/viewmodel/EmptyShadeViewModelTest.kt
@@ -208,8 +208,8 @@
             assertThat(footerVisible).isTrue()
         }
 
-    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME)
     @Test
+    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME, Flags.FLAG_MODES_UI, Flags.FLAG_MODES_API)
     fun onClick_whenHistoryDisabled_leadsToSettingsPage() =
         testScope.runTest {
             val onClick by collectLastValue(underTest.onClick)
@@ -222,8 +222,8 @@
             assertThat(onClick?.backStack).isEmpty()
         }
 
-    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME)
     @Test
+    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME, Flags.FLAG_MODES_UI, Flags.FLAG_MODES_API)
     fun onClick_whenHistoryEnabled_leadsToHistoryPage() =
         testScope.runTest {
             val onClick by collectLastValue(underTest.onClick)
@@ -237,8 +237,8 @@
                 .containsExactly(Settings.ACTION_NOTIFICATION_SETTINGS)
         }
 
-    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME)
     @Test
+    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME, Flags.FLAG_MODES_UI, Flags.FLAG_MODES_API)
     fun onClick_whenOneModeHidingNotifications_leadsToModeSettings() =
         testScope.runTest {
             val onClick by collectLastValue(underTest.onClick)
@@ -263,8 +263,8 @@
                 .containsExactly(Settings.ACTION_ZEN_MODE_SETTINGS)
         }
 
-    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME)
     @Test
+    @EnableFlags(ModesEmptyShadeFix.FLAG_NAME, Flags.FLAG_MODES_UI, Flags.FLAG_MODES_API)
     fun onClick_whenMultipleModesHidingNotifications_leadsToGeneralModesSettings() =
         testScope.runTest {
             val onClick by collectLastValue(underTest.onClick)
diff --git a/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml b/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml
index 3b3ed39..91cd019 100644
--- a/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml
+++ b/packages/SystemUI/res/layout/biometric_prompt_one_pane_layout.xml
@@ -215,17 +215,4 @@
         app:layout_constraintTop_toTopOf="parent"
         app:layout_constraintVertical_bias="1.0"
         tools:srcCompat="@tools:sample/avatars" />
-
-    <com.android.systemui.biometrics.BiometricPromptLottieViewWrapper
-        android:id="@+id/biometric_icon_overlay"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:layout_gravity="center"
-        android:contentDescription="@null"
-        android:scaleType="fitXY"
-        android:importantForAccessibility="no"
-        app:layout_constraintBottom_toBottomOf="@+id/biometric_icon"
-        app:layout_constraintEnd_toEndOf="@+id/biometric_icon"
-        app:layout_constraintStart_toStartOf="@+id/biometric_icon"
-        app:layout_constraintTop_toTopOf="@+id/biometric_icon" />
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml b/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml
index 2a00495..51117a7 100644
--- a/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml
+++ b/packages/SystemUI/res/layout/biometric_prompt_two_pane_layout.xml
@@ -40,19 +40,6 @@
         app:layout_constraintTop_toTopOf="parent"
         tools:srcCompat="@tools:sample/avatars" />
 
-    <com.android.systemui.biometrics.BiometricPromptLottieViewWrapper
-        android:id="@+id/biometric_icon_overlay"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        android:layout_gravity="center"
-        android:contentDescription="@null"
-        android:scaleType="fitXY"
-        android:importantForAccessibility="no"
-        app:layout_constraintBottom_toBottomOf="@+id/biometric_icon"
-        app:layout_constraintEnd_toEndOf="@+id/biometric_icon"
-        app:layout_constraintStart_toStartOf="@+id/biometric_icon"
-        app:layout_constraintTop_toTopOf="@+id/biometric_icon" />
-
     <ScrollView
         android:id="@+id/scrollView"
         android:layout_width="0dp"
diff --git a/packages/SystemUI/res/values-en-rGB/strings.xml b/packages/SystemUI/res/values-en-rGB/strings.xml
index 9b7ae5d..cea405b 100644
--- a/packages/SystemUI/res/values-en-rGB/strings.xml
+++ b/packages/SystemUI/res/values-en-rGB/strings.xml
@@ -573,8 +573,7 @@
     <string name="notification_section_header_conversations" msgid="821834744538345661">"Conversations"</string>
     <string name="accessibility_notification_section_header_gentle_clear_all" msgid="6490207897764933919">"Clear all silent notifications"</string>
     <string name="dnd_suppressing_shade_text" msgid="5588252250634464042">"Notifications paused by Do Not Disturb"</string>
-    <!-- no translation found for modes_suppressing_shade_text (6037581130837903239) -->
-    <skip />
+    <string name="modes_suppressing_shade_text" msgid="6037581130837903239">"{count,plural,offset:1 =0{No notifications}=1{Notifications paused by {mode}}=2{Notifications paused by {mode} and one other mode}other{Notifications paused by {mode} and # other modes}}"</string>
     <string name="media_projection_action_text" msgid="3634906766918186440">"Start now"</string>
     <string name="empty_shade_text" msgid="8935967157319717412">"No notifications"</string>
     <string name="no_unseen_notif_text" msgid="395512586119868682">"No new notifications"</string>
@@ -1436,6 +1435,8 @@
     <string name="all_apps_edu_toast_content" msgid="8807496014667211562">"To view all your apps, press the action key on your keyboard"</string>
     <string name="redacted_notification_single_line_title" msgid="212019960919261670">"Redacted"</string>
     <string name="redacted_notification_single_line_text" msgid="8684166405005242945">"Unlock to view"</string>
+    <!-- no translation found for contextual_education_dialog_title (4630392552837487324) -->
+    <skip />
     <string name="back_edu_notification_title" msgid="5624780717751357278">"Use your touchpad to go back"</string>
     <string name="back_edu_notification_content" msgid="2497557451540954068">"Swipe left or right using three fingers. Tap to learn more gestures."</string>
     <string name="home_edu_notification_title" msgid="6097902076909654045">"Use your touchpad to go home"</string>
diff --git a/packages/SystemUI/res/values-night/styles.xml b/packages/SystemUI/res/values-night/styles.xml
index e9dd039f3..7bd4ca8 100644
--- a/packages/SystemUI/res/values-night/styles.xml
+++ b/packages/SystemUI/res/values-night/styles.xml
@@ -64,4 +64,9 @@
         <item name="android:windowLightNavigationBar">false</item>
     </style>
 
+    <style name="ContextualEduDialog" parent="@android:style/Theme.DeviceDefault.Dialog.NoActionBar">
+        <!-- To make the dialog wrap to content when the education text is short -->
+        <item name="windowMinWidthMajor">0%</item>
+        <item name="windowMinWidthMinor">0%</item>
+    </style>
 </resources>
diff --git a/packages/SystemUI/res/values-pt-rBR/strings.xml b/packages/SystemUI/res/values-pt-rBR/strings.xml
index 5c9679d..399523e 100644
--- a/packages/SystemUI/res/values-pt-rBR/strings.xml
+++ b/packages/SystemUI/res/values-pt-rBR/strings.xml
@@ -1436,6 +1436,8 @@
     <string name="all_apps_edu_toast_content" msgid="8807496014667211562">"Para ver todos os apps, pressione a tecla de ação no teclado"</string>
     <string name="redacted_notification_single_line_title" msgid="212019960919261670">"Encoberto"</string>
     <string name="redacted_notification_single_line_text" msgid="8684166405005242945">"Desbloquear para visualizar"</string>
+    <!-- no translation found for contextual_education_dialog_title (4630392552837487324) -->
+    <skip />
     <string name="back_edu_notification_title" msgid="5624780717751357278">"Use o touchpad para voltar"</string>
     <string name="back_edu_notification_content" msgid="2497557451540954068">"Deslize para a esquerda ou direita usando três dedos. Toque para aprender outros gestos."</string>
     <string name="home_edu_notification_title" msgid="6097902076909654045">"Use o touchpad para acessar a tela inicial"</string>
diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml
index b34d6e4..1c09f84 100644
--- a/packages/SystemUI/res/values/styles.xml
+++ b/packages/SystemUI/res/values/styles.xml
@@ -1721,7 +1721,7 @@
         <item name="android:windowLightNavigationBar">true</item>
     </style>
 
-    <style name="ContextualEduDialog" parent="@android:style/Theme.DeviceDefault.Dialog.NoActionBar">
+    <style name="ContextualEduDialog" parent="@android:style/Theme.DeviceDefault.Light.Dialog.NoActionBar">
         <!-- To make the dialog wrap to content when the education text is short -->
         <item name="windowMinWidthMajor">0%</item>
         <item name="windowMinWidthMinor">0%</item>
diff --git a/packages/SystemUI/schemas/com.android.systemui.communal.data.db.CommunalDatabase/4.json b/packages/SystemUI/schemas/com.android.systemui.communal.data.db.CommunalDatabase/4.json
new file mode 100644
index 0000000..c3fb8d4
--- /dev/null
+++ b/packages/SystemUI/schemas/com.android.systemui.communal.data.db.CommunalDatabase/4.json
@@ -0,0 +1,88 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 4,
+    "identityHash": "a49f2f7d25cf12d1baf9a3a3e6243b64",
+    "entities": [
+      {
+        "tableName": "communal_widget_table",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `widget_id` INTEGER NOT NULL, `component_name` TEXT NOT NULL, `item_id` INTEGER NOT NULL, `user_serial_number` INTEGER NOT NULL DEFAULT -1, `span_y` INTEGER NOT NULL DEFAULT 3)",
+        "fields": [
+          {
+            "fieldPath": "uid",
+            "columnName": "uid",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "widgetId",
+            "columnName": "widget_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "componentName",
+            "columnName": "component_name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "itemId",
+            "columnName": "item_id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userSerialNumber",
+            "columnName": "user_serial_number",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "spanY",
+            "columnName": "span_y",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "uid"
+          ]
+        }
+      },
+      {
+        "tableName": "communal_item_rank_table",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rank` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "uid",
+            "columnName": "uid",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "rank",
+            "columnName": "rank",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "uid"
+          ]
+        }
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a49f2f7d25cf12d1baf9a3a3e6243b64')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java b/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java
index 22130f8..8e01e04 100644
--- a/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java
+++ b/packages/SystemUI/src/com/android/keyguard/KeyguardUpdateMonitor.java
@@ -114,6 +114,7 @@
 import com.android.internal.util.LatencyTracker;
 import com.android.internal.widget.LockPatternUtils;
 import com.android.keyguard.logging.KeyguardUpdateMonitorLogger;
+import com.android.keyguard.logging.SimLogger;
 import com.android.settingslib.Utils;
 import com.android.settingslib.WirelessUtils;
 import com.android.settingslib.fuelgauge.BatteryStatus;
@@ -285,6 +286,7 @@
     private final Context mContext;
     private final UserTracker mUserTracker;
     private final KeyguardUpdateMonitorLogger mLogger;
+    private final SimLogger mSimLogger;
     private final boolean mIsSystemUser;
     private final Provider<JavaAdapter> mJavaAdapter;
     private final Provider<SceneInteractor> mSceneInteractor;
@@ -582,14 +584,14 @@
 
     private void handleSimSubscriptionInfoChanged() {
         Assert.isMainThread();
-        mLogger.v("onSubscriptionInfoChanged()");
+        mSimLogger.v("onSubscriptionInfoChanged()");
         List<SubscriptionInfo> subscriptionInfos = getSubscriptionInfo(true /* forceReload */);
         if (!subscriptionInfos.isEmpty()) {
             for (SubscriptionInfo subInfo : subscriptionInfos) {
-                mLogger.logSubInfo(subInfo);
+                mSimLogger.logSubInfo(subInfo);
             }
         } else {
-            mLogger.v("onSubscriptionInfoChanged: list is null");
+            mSimLogger.v("onSubscriptionInfoChanged: list is null");
         }
 
         // Hack level over 9000: Because the subscription id is not yet valid when we see the
@@ -612,7 +614,7 @@
         while (iter.hasNext()) {
             Map.Entry<Integer, SimData> simData = iter.next();
             if (!activeSubIds.contains(simData.getKey())) {
-                mLogger.logInvalidSubId(simData.getKey());
+                mSimLogger.logInvalidSubId(simData.getKey());
                 iter.remove();
 
                 SimData data = simData.getValue();
@@ -1700,7 +1702,7 @@
                     }
                     return;
                 }
-                mLogger.logSimStateFromIntent(action,
+                mSimLogger.logSimStateFromIntent(action,
                         intent.getStringExtra(Intent.EXTRA_SIM_STATE),
                         args.slotId,
                         args.subId);
@@ -1720,7 +1722,7 @@
                 ServiceState serviceState = ServiceState.newFromBundle(intent.getExtras());
                 int subId = intent.getIntExtra(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX,
                         SubscriptionManager.INVALID_SUBSCRIPTION_ID);
-                mLogger.logServiceStateIntent(action, serviceState, subId);
+                mSimLogger.logServiceStateIntent(action, serviceState, subId);
                 mHandler.sendMessage(
                         mHandler.obtainMessage(MSG_SERVICE_STATE_CHANGE, subId, 0, serviceState));
             } else if (TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED.equals(action)) {
@@ -2154,6 +2156,7 @@
             LatencyTracker latencyTracker,
             ActiveUnlockConfig activeUnlockConfiguration,
             KeyguardUpdateMonitorLogger logger,
+            SimLogger simLogger,
             UiEventLogger uiEventLogger,
             // This has to be a provider because SessionTracker depends on KeyguardUpdateMonitor :(
             Provider<SessionTracker> sessionTrackerProvider,
@@ -2196,6 +2199,7 @@
         mSensorPrivacyManager = sensorPrivacyManager;
         mActiveUnlockConfig = activeUnlockConfiguration;
         mLogger = logger;
+        mSimLogger = simLogger;
         mUiEventLogger = uiEventLogger;
         mSessionTrackerProvider = sessionTrackerProvider;
         mTrustManager = trustManager;
@@ -3369,36 +3373,39 @@
     }
 
     /**
+     * Removes all valid subscription info from the map for the given slotId.
+     */
+    private void invalidateSlot(int slotId) {
+        Iterator<Map.Entry<Integer, SimData>> iter = mSimDatas.entrySet().iterator();
+        while (iter.hasNext()) {
+            SimData data = iter.next().getValue();
+            if (data.slotId == slotId && SubscriptionManager.isValidSubscriptionId(data.subId)) {
+                mSimLogger.logInvalidSubId(data.subId);
+                iter.remove();
+            }
+        }
+    }
+
+    /**
      * Handle {@link #MSG_SIM_STATE_CHANGE}
      */
     @VisibleForTesting
     void handleSimStateChange(int subId, int slotId, int state) {
         Assert.isMainThread();
-        mLogger.logSimState(subId, slotId, state);
+        mSimLogger.logSimState(subId, slotId, state);
 
-        boolean becameAbsent = false;
+        boolean becameAbsent = ABSENT_SIM_STATE_LIST.contains(state);
         if (!SubscriptionManager.isValidSubscriptionId(subId)) {
-            mLogger.w("invalid subId in handleSimStateChange()");
+            mSimLogger.w("invalid subId in handleSimStateChange()");
             /* Only handle No SIM(ABSENT) and Card Error(CARD_IO_ERROR) due to
              * handleServiceStateChange() handle other case */
-            if (state == TelephonyManager.SIM_STATE_ABSENT) {
-                updateTelephonyCapable(true);
-                // Even though the subscription is not valid anymore, we need to notify that the
-                // SIM card was removed so we can update the UI.
-                becameAbsent = true;
-                for (SimData data : mSimDatas.values()) {
-                    // Set the SIM state of all SimData associated with that slot to ABSENT se we
-                    // do not move back into PIN/PUK locked and not detect the change below.
-                    if (data.slotId == slotId) {
-                        data.simState = TelephonyManager.SIM_STATE_ABSENT;
-                    }
-                }
-            } else if (state == TelephonyManager.SIM_STATE_CARD_IO_ERROR) {
+            if (state == TelephonyManager.SIM_STATE_ABSENT
+                    || state == TelephonyManager.SIM_STATE_CARD_IO_ERROR) {
                 updateTelephonyCapable(true);
             }
-        }
 
-        becameAbsent |= ABSENT_SIM_STATE_LIST.contains(state);
+            invalidateSlot(slotId);
+        }
 
         // TODO(b/327476182): Preserve SIM_STATE_CARD_IO_ERROR sims in a separate data source.
         SimData data = mSimDatas.get(subId);
@@ -3428,10 +3435,10 @@
      */
     @VisibleForTesting
     void handleServiceStateChange(int subId, ServiceState serviceState) {
-        mLogger.logServiceStateChange(subId, serviceState);
+        mSimLogger.logServiceStateChange(subId, serviceState);
 
         if (!SubscriptionManager.isValidSubscriptionId(subId)) {
-            mLogger.w("invalid subId in handleServiceStateChange()");
+            mSimLogger.w("invalid subId in handleServiceStateChange()");
             return;
         } else {
             updateTelephonyCapable(true);
@@ -3711,7 +3718,7 @@
      */
     @MainThread
     public void reportSimUnlocked(int subId) {
-        mLogger.logSimUnlocked(subId);
+        mSimLogger.logSimUnlocked(subId);
         handleSimStateChange(subId, getSlotId(subId), TelephonyManager.SIM_STATE_READY);
     }
 
@@ -3870,6 +3877,11 @@
     private boolean refreshSimState(int subId, int slotId) {
         int state = mTelephonyManager.getSimState(slotId);
         SimData data = mSimDatas.get(subId);
+
+        if (!SubscriptionManager.isValidSubscriptionId(subId)) {
+            invalidateSlot(slotId);
+        }
+
         final boolean changed;
         if (data == null) {
             data = new SimData(state, slotId, subId);
diff --git a/packages/SystemUI/src/com/android/keyguard/logging/KeyguardUpdateMonitorLogger.kt b/packages/SystemUI/src/com/android/keyguard/logging/KeyguardUpdateMonitorLogger.kt
index 0b58f06..12fc3c2 100644
--- a/packages/SystemUI/src/com/android/keyguard/logging/KeyguardUpdateMonitorLogger.kt
+++ b/packages/SystemUI/src/com/android/keyguard/logging/KeyguardUpdateMonitorLogger.kt
@@ -20,8 +20,6 @@
 import android.hardware.biometrics.BiometricConstants.LockoutMode
 import android.hardware.biometrics.BiometricSourceType
 import android.os.PowerManager
-import android.telephony.ServiceState
-import android.telephony.SubscriptionInfo
 import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
 import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
 import android.telephony.TelephonyManager
@@ -34,7 +32,6 @@
 import com.android.systemui.log.core.LogLevel
 import com.android.systemui.log.core.LogLevel.DEBUG
 import com.android.systemui.log.core.LogLevel.ERROR
-import com.android.systemui.log.core.LogLevel.INFO
 import com.android.systemui.log.core.LogLevel.VERBOSE
 import com.android.systemui.log.core.LogLevel.WARNING
 import com.android.systemui.log.dagger.KeyguardUpdateMonitorLog
@@ -63,7 +60,7 @@
             "ActiveUnlock",
             DEBUG,
             { str1 = reason },
-            { "initiate active unlock triggerReason=$str1" }
+            { "initiate active unlock triggerReason=$str1" },
         )
     }
 
@@ -75,7 +72,7 @@
             {
                 "Skip requesting active unlock from wake reason that doesn't trigger face auth" +
                     " reason=${PowerManager.wakeReasonToString(int1)}"
-            }
+            },
         )
     }
 
@@ -92,7 +89,7 @@
             TAG,
             DEBUG,
             { bool1 = deviceProvisioned },
-            { "DEVICE_PROVISIONED state = $bool1" }
+            { "DEVICE_PROVISIONED state = $bool1" },
         )
     }
 
@@ -108,7 +105,7 @@
                 str1 = originalErrMsg
                 int1 = msgId
             },
-            { "Face error received: $str1 msgId= $int1" }
+            { "Face error received: $str1 msgId= $int1" },
         )
     }
 
@@ -117,7 +114,7 @@
             TAG,
             DEBUG,
             { int1 = authUserId },
-            { "Face authenticated for wrong user: $int1" }
+            { "Face authenticated for wrong user: $int1" },
         )
     }
 
@@ -130,7 +127,7 @@
             FP_LOG_TAG,
             DEBUG,
             { int1 = authUserId },
-            { "Fingerprint authenticated for wrong user: $int1" }
+            { "Fingerprint authenticated for wrong user: $int1" },
         )
     }
 
@@ -139,7 +136,7 @@
             FP_LOG_TAG,
             DEBUG,
             { int1 = userId },
-            { "Fingerprint disabled by DPM for userId: $int1" }
+            { "Fingerprint disabled by DPM for userId: $int1" },
         )
     }
 
@@ -148,7 +145,7 @@
             FP_LOG_TAG,
             DEBUG,
             { int1 = mode },
-            { "handleFingerprintLockoutReset: $int1" }
+            { "handleFingerprintLockoutReset: $int1" },
         )
     }
 
@@ -157,7 +154,7 @@
             FP_LOG_TAG,
             DEBUG,
             { int1 = fingerprintRunningState },
-            { "fingerprintRunningState: $int1" }
+            { "fingerprintRunningState: $int1" },
         )
     }
 
@@ -169,7 +166,7 @@
                 int1 = userId
                 bool1 = isStrongBiometric
             },
-            { "Fingerprint auth successful: userId: $int1, isStrongBiometric: $bool1" }
+            { "Fingerprint auth successful: userId: $int1, isStrongBiometric: $bool1" },
         )
     }
 
@@ -181,7 +178,7 @@
                 int1 = userId
                 bool1 = isStrongBiometric
             },
-            { "Face detected: userId: $int1, isStrongBiometric: $bool1" }
+            { "Face detected: userId: $int1, isStrongBiometric: $bool1" },
         )
     }
 
@@ -193,7 +190,7 @@
                 int1 = userId
                 bool1 = isStrongBiometric
             },
-            { "Fingerprint detected: userId: $int1, isStrongBiometric: $bool1" }
+            { "Fingerprint detected: userId: $int1, isStrongBiometric: $bool1" },
         )
     }
 
@@ -205,22 +202,13 @@
                 str1 = originalErrMsg
                 int1 = msgId
             },
-            { "Fingerprint error received: $str1 msgId= $int1" }
-        )
-    }
-
-    fun logInvalidSubId(subId: Int) {
-        logBuffer.log(
-            TAG,
-            INFO,
-            { int1 = subId },
-            { "Previously active sub id $int1 is now invalid, will remove" }
+            { "Fingerprint error received: $str1 msgId= $int1" },
         )
     }
 
     fun logPrimaryKeyguardBouncerChanged(
         primaryBouncerIsOrWillBeShowing: Boolean,
-        primaryBouncerFullyShown: Boolean
+        primaryBouncerFullyShown: Boolean,
     ) {
         logBuffer.log(
             TAG,
@@ -232,7 +220,7 @@
             {
                 "handlePrimaryBouncerChanged " +
                     "primaryBouncerIsOrWillBeShowing=$bool1 primaryBouncerFullyShown=$bool2"
-            }
+            },
         )
     }
 
@@ -249,7 +237,7 @@
                 bool2 = occluded
                 bool3 = visible
             },
-            { "keyguardShowingChanged(showing=$bool1 occluded=$bool2 visible=$bool3)" }
+            { "keyguardShowingChanged(showing=$bool1 occluded=$bool2 visible=$bool3)" },
         )
     }
 
@@ -258,7 +246,7 @@
             TAG,
             ERROR,
             { int1 = userId },
-            { "No Profile Owner or Device Owner supervision app found for User $int1" }
+            { "No Profile Owner or Device Owner supervision app found for User $int1" },
         )
     }
 
@@ -279,7 +267,7 @@
                 int2 = delay
                 str1 = "$errString"
             },
-            { "Fingerprint scheduling retry auth after $int2 ms due to($int1) -> $str1" }
+            { "Fingerprint scheduling retry auth after $int2 ms due to($int1) -> $str1" },
         )
     }
 
@@ -288,7 +276,7 @@
             TAG,
             WARNING,
             { int1 = retryCount },
-            { "Retrying fingerprint attempt: $int1" }
+            { "Retrying fingerprint attempt: $int1" },
         )
     }
 
@@ -306,32 +294,7 @@
             {
                 "sendPrimaryBouncerChanged primaryBouncerIsOrWillBeShowing=$bool1 " +
                     "primaryBouncerFullyShown=$bool2"
-            }
-        )
-    }
-
-    fun logServiceStateChange(subId: Int, serviceState: ServiceState?) {
-        logBuffer.log(
-            TAG,
-            DEBUG,
-            {
-                int1 = subId
-                str1 = "$serviceState"
             },
-            { "handleServiceStateChange(subId=$int1, serviceState=$str1)" }
-        )
-    }
-
-    fun logServiceStateIntent(action: String?, serviceState: ServiceState?, subId: Int) {
-        logBuffer.log(
-            TAG,
-            VERBOSE,
-            {
-                str1 = action
-                str2 = "$serviceState"
-                int1 = subId
-            },
-            { "action $str1 serviceState=$str2 subId=$int1" }
         )
     }
 
@@ -344,51 +307,16 @@
                 str1 = intent.getStringExtra(TelephonyManager.EXTRA_SPN)
                 str2 = intent.getStringExtra(TelephonyManager.EXTRA_PLMN)
             },
-            { "action SERVICE_PROVIDERS_UPDATED subId=$int1 spn=$str1 plmn=$str2" }
+            { "action SERVICE_PROVIDERS_UPDATED subId=$int1 spn=$str1 plmn=$str2" },
         )
     }
 
-    fun logSimState(subId: Int, slotId: Int, state: Int) {
-        logBuffer.log(
-            TAG,
-            DEBUG,
-            {
-                int1 = subId
-                int2 = slotId
-                long1 = state.toLong()
-            },
-            { "handleSimStateChange(subId=$int1, slotId=$int2, state=$long1)" }
-        )
-    }
-
-    fun logSimStateFromIntent(action: String?, extraSimState: String?, slotId: Int, subId: Int) {
-        logBuffer.log(
-            TAG,
-            VERBOSE,
-            {
-                str1 = action
-                str2 = extraSimState
-                int1 = slotId
-                int2 = subId
-            },
-            { "action $str1 state: $str2 slotId: $int1 subid: $int2" }
-        )
-    }
-
-    fun logSimUnlocked(subId: Int) {
-        logBuffer.log(TAG, VERBOSE, { int1 = subId }, { "reportSimUnlocked(subId=$int1)" })
-    }
-
-    fun logSubInfo(subInfo: SubscriptionInfo?) {
-        logBuffer.log(TAG, DEBUG, { str1 = "$subInfo" }, { "SubInfo:$str1" })
-    }
-
     fun logTimeFormatChanged(newTimeFormat: String?) {
         logBuffer.log(
             TAG,
             DEBUG,
             { str1 = newTimeFormat },
-            { "handleTimeFormatUpdate timeFormat=$str1" }
+            { "handleTimeFormatUpdate timeFormat=$str1" },
         )
     }
 
@@ -402,7 +330,7 @@
 
     fun logUnexpectedFpCancellationSignalState(
         fingerprintRunningState: Int,
-        unlockPossible: Boolean
+        unlockPossible: Boolean,
     ) {
         logBuffer.log(
             TAG,
@@ -414,7 +342,7 @@
             {
                 "Cancellation signal is not null, high chance of bug in " +
                     "fp auth lifecycle management. FP state: $int1, unlockPossible: $bool1"
-            }
+            },
         )
     }
 
@@ -425,7 +353,7 @@
     fun logUserRequestedUnlock(
         requestOrigin: ActiveUnlockConfig.ActiveUnlockRequestOrigin,
         reason: String?,
-        dismissKeyguard: Boolean
+        dismissKeyguard: Boolean,
     ) {
         logBuffer.log(
             "ActiveUnlock",
@@ -435,7 +363,7 @@
                 str2 = reason
                 bool1 = dismissKeyguard
             },
-            { "reportUserRequestedUnlock origin=$str1 reason=$str2 dismissKeyguard=$bool1" }
+            { "reportUserRequestedUnlock origin=$str1 reason=$str2 dismissKeyguard=$bool1" },
         )
     }
 
@@ -443,7 +371,7 @@
         flags: Int,
         newlyUnlocked: Boolean,
         userId: Int,
-        message: String?
+        message: String?,
     ) {
         logBuffer.log(
             TAG,
@@ -457,7 +385,7 @@
             {
                 "trustGrantedWithFlags[user=$int2] newlyUnlocked=$bool1 " +
                     "flags=${TrustGrantFlags(int1)} message=$str1"
-            }
+            },
         )
     }
 
@@ -470,7 +398,7 @@
                 bool2 = isNowTrusted
                 int1 = userId
             },
-            { "onTrustChanged[user=$int1] wasTrusted=$bool1 isNowTrusted=$bool2" }
+            { "onTrustChanged[user=$int1] wasTrusted=$bool1 isNowTrusted=$bool2" },
         )
     }
 
@@ -478,7 +406,7 @@
         secure: Boolean,
         canDismissLockScreen: Boolean,
         trusted: Boolean,
-        trustManaged: Boolean
+        trustManaged: Boolean,
     ) {
         logBuffer.log(
             "KeyguardState",
@@ -492,7 +420,7 @@
             {
                 "#update secure=$bool1 canDismissKeyguard=$bool2" +
                     " trusted=$bool3 trustManaged=$bool4"
-            }
+            },
         )
     }
 
@@ -501,7 +429,7 @@
             TAG,
             VERBOSE,
             { bool1 = assistantVisible },
-            { "TaskStackChanged for ACTIVITY_TYPE_ASSISTANT, assistant visible: $bool1" }
+            { "TaskStackChanged for ACTIVITY_TYPE_ASSISTANT, assistant visible: $bool1" },
         )
     }
 
@@ -510,7 +438,7 @@
             TAG,
             VERBOSE,
             { bool1 = allow },
-            { "allowFingerprintOnCurrentOccludingActivityChanged: $bool1" }
+            { "allowFingerprintOnCurrentOccludingActivityChanged: $bool1" },
         )
     }
 
@@ -519,7 +447,7 @@
             TAG,
             VERBOSE,
             { bool1 = assistantVisible },
-            { "Updating mAssistantVisible to new value: $bool1" }
+            { "Updating mAssistantVisible to new value: $bool1" },
         )
     }
 
@@ -531,7 +459,7 @@
                 bool1 = isStrongBiometric
                 int1 = userId
             },
-            { "reporting successful biometric unlock: isStrongBiometric: $bool1, userId: $int1" }
+            { "reporting successful biometric unlock: isStrongBiometric: $bool1, userId: $int1" },
         )
     }
 
@@ -543,7 +471,7 @@
             {
                 "MSG_BIOMETRIC_AUTHENTICATION_CONTINUE already queued up, " +
                     "ignoring updating FP listening state to $int1"
-            }
+            },
         )
     }
 
@@ -551,7 +479,7 @@
         userId: Int,
         oldValue: Boolean,
         newValue: Boolean,
-        context: String
+        context: String,
     ) {
         logBuffer.log(
             TAG,
@@ -568,7 +496,7 @@
                     "old: $bool1, " +
                     "new: $bool2 " +
                     "context: $str1"
-            }
+            },
         )
     }
 
@@ -591,7 +519,7 @@
                     "plugged=$str1, " +
                     "chargingStatus=$int2, " +
                     "maxChargingWattage= $long2}"
-            }
+            },
         )
     }
 
@@ -604,7 +532,7 @@
             TAG,
             DEBUG,
             { str1 = "$biometricSourceType" },
-            { "notifying about enrollments changed: $str1" }
+            { "notifying about enrollments changed: $str1" },
         )
     }
 
@@ -616,7 +544,7 @@
                 int1 = userId
                 str1 = context
             },
-            { "userCurrentlySwitching: $str1, userId: $int1" }
+            { "userCurrentlySwitching: $str1, userId: $int1" },
         )
     }
 
@@ -628,7 +556,7 @@
                 int1 = userId
                 str1 = context
             },
-            { "userSwitchComplete: $str1, userId: $int1" }
+            { "userSwitchComplete: $str1, userId: $int1" },
         )
     }
 
@@ -637,7 +565,7 @@
             FP_LOG_TAG,
             DEBUG,
             { int1 = acquireInfo },
-            { "fingerprint acquire message: $int1" }
+            { "fingerprint acquire message: $int1" },
         )
     }
 
@@ -646,7 +574,7 @@
             TAG,
             DEBUG,
             { bool1 = keepUnlocked },
-            { "keepUnlockedOnFold changed to: $bool1" }
+            { "keepUnlockedOnFold changed to: $bool1" },
         )
     }
 
@@ -662,7 +590,7 @@
                 int1 = userId
                 bool1 = isUnlocked
             },
-            { "userStopped userId: $int1 isUnlocked: $bool1" }
+            { "userStopped userId: $int1 isUnlocked: $bool1" },
         )
     }
 
@@ -678,7 +606,7 @@
                 int1 = userId
                 bool1 = isUnlocked
             },
-            { "userUnlockedInitialState userId: $int1 isUnlocked: $bool1" }
+            { "userUnlockedInitialState userId: $int1 isUnlocked: $bool1" },
         )
     }
 }
diff --git a/packages/SystemUI/src/com/android/keyguard/logging/SimLogger.kt b/packages/SystemUI/src/com/android/keyguard/logging/SimLogger.kt
new file mode 100644
index 0000000..a81698b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/keyguard/logging/SimLogger.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.keyguard.logging
+
+import android.content.Intent
+import android.telephony.ServiceState
+import android.telephony.SubscriptionInfo
+import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID
+import android.telephony.TelephonyManager
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.core.LogLevel.DEBUG
+import com.android.systemui.log.core.LogLevel.ERROR
+import com.android.systemui.log.core.LogLevel.INFO
+import com.android.systemui.log.core.LogLevel.VERBOSE
+import com.android.systemui.log.core.LogLevel.WARNING
+import com.android.systemui.log.dagger.SimLog
+import com.google.errorprone.annotations.CompileTimeConstant
+import javax.inject.Inject
+
+private const val TAG = "SimLog"
+
+/** Helper class for logging for SIM events */
+class SimLogger @Inject constructor(@SimLog private val logBuffer: LogBuffer) {
+    fun d(@CompileTimeConstant msg: String) = log(msg, DEBUG)
+
+    fun e(@CompileTimeConstant msg: String) = log(msg, ERROR)
+
+    fun v(@CompileTimeConstant msg: String) = log(msg, VERBOSE)
+
+    fun w(@CompileTimeConstant msg: String) = log(msg, WARNING)
+
+    fun log(@CompileTimeConstant msg: String, level: LogLevel) = logBuffer.log(TAG, level, msg)
+
+    fun logInvalidSubId(subId: Int) {
+        logBuffer.log(
+            TAG,
+            INFO,
+            { int1 = subId },
+            { "Previously active sub id $int1 is now invalid, will remove" },
+        )
+    }
+
+    fun logServiceStateChange(subId: Int, serviceState: ServiceState?) {
+        logBuffer.log(
+            TAG,
+            DEBUG,
+            {
+                int1 = subId
+                str1 = "$serviceState"
+            },
+            { "handleServiceStateChange(subId=$int1, serviceState=$str1)" },
+        )
+    }
+
+    fun logServiceStateIntent(action: String?, serviceState: ServiceState?, subId: Int) {
+        logBuffer.log(
+            TAG,
+            VERBOSE,
+            {
+                str1 = action
+                str2 = "$serviceState"
+                int1 = subId
+            },
+            { "action $str1 serviceState=$str2 subId=$int1" },
+        )
+    }
+
+    fun logServiceProvidersUpdated(intent: Intent) {
+        logBuffer.log(
+            TAG,
+            VERBOSE,
+            {
+                int1 = intent.getIntExtra(EXTRA_SUBSCRIPTION_INDEX, INVALID_SUBSCRIPTION_ID)
+                str1 = intent.getStringExtra(TelephonyManager.EXTRA_SPN)
+                str2 = intent.getStringExtra(TelephonyManager.EXTRA_PLMN)
+            },
+            { "action SERVICE_PROVIDERS_UPDATED subId=$int1 spn=$str1 plmn=$str2" },
+        )
+    }
+
+    fun logSimState(subId: Int, slotId: Int, state: Int) {
+        logBuffer.log(
+            TAG,
+            DEBUG,
+            {
+                int1 = subId
+                int2 = slotId
+                long1 = state.toLong()
+            },
+            { "handleSimStateChange(subId=$int1, slotId=$int2, state=$long1)" },
+        )
+    }
+
+    fun logSimStateFromIntent(action: String?, extraSimState: String?, slotId: Int, subId: Int) {
+        logBuffer.log(
+            TAG,
+            VERBOSE,
+            {
+                str1 = action
+                str2 = extraSimState
+                int1 = slotId
+                int2 = subId
+            },
+            { "action $str1 state: $str2 slotId: $int1 subid: $int2" },
+        )
+    }
+
+    fun logSimUnlocked(subId: Int) {
+        logBuffer.log(TAG, VERBOSE, { int1 = subId }, { "reportSimUnlocked(subId=$int1)" })
+    }
+
+    fun logSubInfo(subInfo: SubscriptionInfo?) {
+        logBuffer.log(TAG, DEBUG, { str1 = "$subInfo" }, { "SubInfo:$str1" })
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java
index b39aae9..a5bd559 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java
@@ -19,6 +19,7 @@
 import static android.hardware.biometrics.BiometricAuthenticator.TYPE_FACE;
 import static android.hardware.biometrics.BiometricAuthenticator.TYPE_FINGERPRINT;
 import static android.hardware.fingerprint.FingerprintSensorProperties.TYPE_REAR;
+import static android.view.Display.INVALID_DISPLAY;
 
 import static com.android.systemui.util.ConvenienceExtensionsKt.toKotlinLazy;
 
@@ -54,6 +55,7 @@
 import android.hardware.fingerprint.IUdfpsRefreshRateRequestCallback;
 import android.os.Handler;
 import android.os.RemoteException;
+import android.os.UserHandle;
 import android.os.UserManager;
 import android.util.Log;
 import android.util.RotationUtils;
@@ -211,9 +213,13 @@
         }
     };
 
-    private void closeDialog(String reason) {
+    private void closeDialog(String reasonString) {
+        closeDialog(BiometricPrompt.DISMISSED_REASON_USER_CANCEL, reasonString);
+    }
+
+    private void closeDialog(@DismissedReason int reason, String reasonString) {
         if (isShowing()) {
-            Log.i(TAG, "Close BP, reason :" + reason);
+            Log.i(TAG, "Close BP, reason :" + reasonString);
             mCurrentDialog.dismissWithoutCallback(true /* animate */);
             mCurrentDialog = null;
 
@@ -223,8 +229,7 @@
 
             try {
                 if (mReceiver != null) {
-                    mReceiver.onDialogDismissed(BiometricPrompt.DISMISSED_REASON_USER_CANCEL,
-                            null /* credentialAttestation */);
+                    mReceiver.onDialogDismissed(reason, null /* credentialAttestation */);
                     mReceiver = null;
                 }
             } catch (RemoteException e) {
@@ -251,25 +256,7 @@
 
     private void cancelIfOwnerIsNotInForeground() {
         mExecution.assertIsMainThread();
-        if (mCurrentDialog != null) {
-            try {
-                mCurrentDialog.dismissWithoutCallback(true /* animate */);
-                mCurrentDialog = null;
-
-                for (Callback cb : mCallbacks) {
-                    cb.onBiometricPromptDismissed();
-                }
-
-                if (mReceiver != null) {
-                    mReceiver.onDialogDismissed(
-                            BiometricPrompt.DISMISSED_REASON_USER_CANCEL,
-                            null /* credentialAttestation */);
-                    mReceiver = null;
-                }
-            } catch (RemoteException e) {
-                Log.e(TAG, "Remote exception", e);
-            }
-        }
+        closeDialog("owner not in foreground");
     }
 
     /**
@@ -1271,10 +1258,44 @@
         if (!promptInfo.isAllowBackgroundAuthentication() && isOwnerInBackground()) {
             cancelIfOwnerIsNotInForeground();
         } else {
-            mCurrentDialog.show(mWindowManager);
+            WindowManager wm = getWindowManagerForUser(userId);
+            if (wm != null) {
+                mCurrentDialog.show(wm);
+            } else {
+                closeDialog(BiometricPrompt.DISMISSED_REASON_ERROR_NO_WM,
+                        "unable to get WM instance for user");
+            }
         }
     }
 
+    @Nullable
+    private WindowManager getWindowManagerForUser(int userId) {
+        if (!mUserManager.isVisibleBackgroundUsersSupported()) {
+            return mWindowManager;
+        }
+        UserManager um = mContext.createContextAsUser(UserHandle.of(userId),
+                0 /* flags */).getSystemService(UserManager.class);
+        if (um == null) {
+            Log.e(TAG, "unable to get UserManager for user=" + userId);
+            return null;
+        }
+        if (!um.isUserVisible()) {
+            // not visible user - use default window manager
+            return mWindowManager;
+        }
+        int displayId = um.getMainDisplayIdAssignedToUser();
+        if (displayId == INVALID_DISPLAY) {
+            Log.e(TAG, "unable to get display assigned to user=" + userId);
+            return null;
+        }
+        Display display = mDisplayManager.getDisplay(displayId);
+        if (display == null) {
+            Log.e(TAG, "unable to get Display for user=" + userId);
+            return null;
+        }
+        return mContext.createDisplayContext(display).getSystemService(WindowManager.class);
+    }
+
     private void onDialogDismissed(@DismissedReason int reason) {
         if (DEBUG) Log.d(TAG, "onDialogDismissed: " + reason);
         if (mCurrentDialog == null) {
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt
index 73f75a4..18446f02 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/BiometricViewSizeBinder.kt
@@ -18,13 +18,11 @@
 
 import android.animation.Animator
 import android.animation.AnimatorSet
-import android.animation.ValueAnimator
 import android.graphics.Outline
 import android.graphics.Rect
 import android.transition.AutoTransition
 import android.transition.TransitionManager
 import android.util.TypedValue
-import android.view.Surface
 import android.view.View
 import android.view.ViewGroup
 import android.view.ViewOutlineProvider
@@ -52,7 +50,6 @@
 import com.android.systemui.res.R
 import kotlin.math.abs
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 
 /** Helper for [BiometricViewBinder] to handle resize transitions. */
@@ -98,7 +95,7 @@
             TypedValue.applyDimension(
                 TypedValue.COMPLEX_UNIT_DIP,
                 1f,
-                view.resources.displayMetrics
+                view.resources.displayMetrics,
             )
         val cornerRadiusPx = (pxToDp * cornerRadius).toInt()
 
@@ -114,7 +111,7 @@
                                 0,
                                 view.width + cornerRadiusPx,
                                 view.height,
-                                cornerRadiusPx.toFloat()
+                                cornerRadiusPx.toFloat(),
                             )
                         }
                         PromptPosition.Left -> {
@@ -123,7 +120,7 @@
                                 0,
                                 view.width,
                                 view.height,
-                                cornerRadiusPx.toFloat()
+                                cornerRadiusPx.toFloat(),
                             )
                         }
                         PromptPosition.Bottom,
@@ -133,7 +130,7 @@
                                 0,
                                 view.width,
                                 view.height + cornerRadiusPx,
-                                cornerRadiusPx.toFloat()
+                                cornerRadiusPx.toFloat(),
                             )
                         }
                     }
@@ -160,16 +157,13 @@
             fun setVisibilities(hideSensorIcon: Boolean, size: PromptSize) {
                 viewsToHideWhenSmall.forEach { it.showContentOrHide(forceHide = size.isSmall) }
                 largeConstraintSet.setVisibility(iconHolderView.id, View.GONE)
-                largeConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE)
                 largeConstraintSet.setVisibility(R.id.indicator, View.GONE)
                 largeConstraintSet.setVisibility(R.id.scrollView, View.GONE)
 
                 if (hideSensorIcon) {
                     smallConstraintSet.setVisibility(iconHolderView.id, View.GONE)
-                    smallConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE)
                     smallConstraintSet.setVisibility(R.id.indicator, View.GONE)
                     mediumConstraintSet.setVisibility(iconHolderView.id, View.GONE)
-                    mediumConstraintSet.setVisibility(R.id.biometric_icon_overlay, View.GONE)
                     mediumConstraintSet.setVisibility(R.id.indicator, View.GONE)
                 }
             }
@@ -189,24 +183,24 @@
                                     R.id.biometric_icon,
                                     ConstraintSet.LEFT,
                                     ConstraintSet.PARENT_ID,
-                                    ConstraintSet.LEFT
+                                    ConstraintSet.LEFT,
                                 )
                                 mediumConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.LEFT,
-                                    position.left
+                                    position.left,
                                 )
                                 smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.RIGHT)
                                 smallConstraintSet.connect(
                                     R.id.biometric_icon,
                                     ConstraintSet.LEFT,
                                     ConstraintSet.PARENT_ID,
-                                    ConstraintSet.LEFT
+                                    ConstraintSet.LEFT,
                                 )
                                 smallConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.LEFT,
-                                    position.left
+                                    position.left,
                                 )
                             }
                             if (position.top != 0) {
@@ -216,13 +210,13 @@
                                 mediumConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.TOP,
-                                    position.top
+                                    position.top,
                                 )
                                 smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.BOTTOM)
                                 smallConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.TOP,
-                                    position.top
+                                    position.top,
                                 )
                             }
                             if (position.right != 0) {
@@ -233,24 +227,24 @@
                                     R.id.biometric_icon,
                                     ConstraintSet.RIGHT,
                                     ConstraintSet.PARENT_ID,
-                                    ConstraintSet.RIGHT
+                                    ConstraintSet.RIGHT,
                                 )
                                 mediumConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.RIGHT,
-                                    position.right
+                                    position.right,
                                 )
                                 smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.LEFT)
                                 smallConstraintSet.connect(
                                     R.id.biometric_icon,
                                     ConstraintSet.RIGHT,
                                     ConstraintSet.PARENT_ID,
-                                    ConstraintSet.RIGHT
+                                    ConstraintSet.RIGHT,
                                 )
                                 smallConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.RIGHT,
-                                    position.right
+                                    position.right,
                                 )
                             }
                             if (position.bottom != 0) {
@@ -260,13 +254,13 @@
                                 mediumConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.BOTTOM,
-                                    position.bottom
+                                    position.bottom,
                                 )
                                 smallConstraintSet.clear(R.id.biometric_icon, ConstraintSet.TOP)
                                 smallConstraintSet.setMargin(
                                     R.id.biometric_icon,
                                     ConstraintSet.BOTTOM,
-                                    position.bottom
+                                    position.bottom,
                                 )
                             }
                             iconHolderView.layoutParams = iconParams
@@ -305,11 +299,11 @@
                         } else if (bounds.right < 0) {
                             mediumConstraintSet.setGuidelineBegin(
                                 rightGuideline.id,
-                                abs(bounds.right)
+                                abs(bounds.right),
                             )
                             smallConstraintSet.setGuidelineBegin(
                                 rightGuideline.id,
-                                abs(bounds.right)
+                                abs(bounds.right),
                             )
                         }
 
@@ -362,13 +356,13 @@
                                 R.id.scrollView,
                                 ConstraintSet.LEFT,
                                 R.id.midGuideline,
-                                ConstraintSet.LEFT
+                                ConstraintSet.LEFT,
                             )
                             flipConstraintSet.connect(
                                 R.id.scrollView,
                                 ConstraintSet.RIGHT,
                                 R.id.rightGuideline,
-                                ConstraintSet.RIGHT
+                                ConstraintSet.RIGHT,
                             )
                         } else if (position.isTop) {
                             // Top position is only used for 180 rotation Udfps
@@ -377,24 +371,24 @@
                                 R.id.scrollView,
                                 ConstraintSet.TOP,
                                 R.id.indicator,
-                                ConstraintSet.BOTTOM
+                                ConstraintSet.BOTTOM,
                             )
                             mediumConstraintSet.connect(
                                 R.id.scrollView,
                                 ConstraintSet.BOTTOM,
                                 R.id.button_bar,
-                                ConstraintSet.TOP
+                                ConstraintSet.TOP,
                             )
                             mediumConstraintSet.connect(
                                 R.id.panel,
                                 ConstraintSet.TOP,
                                 R.id.biometric_icon,
-                                ConstraintSet.TOP
+                                ConstraintSet.TOP,
                             )
                             mediumConstraintSet.setMargin(
                                 R.id.panel,
                                 ConstraintSet.TOP,
-                                (-24 * pxToDp).toInt()
+                                (-24 * pxToDp).toInt(),
                             )
                             mediumConstraintSet.setVerticalBias(R.id.scrollView, 0f)
                         }
diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/util/BouncerTestUtils.kt b/packages/SystemUI/src/com/android/systemui/bouncer/util/BouncerTestUtils.kt
new file mode 100644
index 0000000..08a79c9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bouncer/util/BouncerTestUtils.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.systemui.bouncer.util
+
+import android.app.ActivityManager
+import android.content.res.Resources
+import com.android.systemui.res.R
+import java.io.File
+
+private const val ENABLE_MENU_KEY_FILE = "/data/local/enable_menu_key"
+
+/**
+ * In general, we enable unlocking the insecure keyguard with the menu key. However, there are some
+ * cases where we wish to disable it, notably when the menu button placement or technology is prone
+ * to false positives.
+ *
+ * @return true if the menu key should be enabled
+ */
+fun Resources.shouldEnableMenuKey(): Boolean {
+    val configDisabled = getBoolean(R.bool.config_disableMenuKeyInLockScreen)
+    val isTestHarness = ActivityManager.isRunningInTestHarness()
+    val fileOverride = File(ENABLE_MENU_KEY_FILE).exists()
+    return !configDisabled || isTestHarness || fileOverride
+}
diff --git a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java
index aabfbd1..65c01ed 100644
--- a/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java
+++ b/packages/SystemUI/src/com/android/systemui/clipboardoverlay/ClipboardOverlayController.java
@@ -710,9 +710,16 @@
     @Override
     public void onShareButtonTapped() {
         if (clipboardSharedTransitions()) {
-            if (mClipboardModel.getType() != ClipboardModel.Type.OTHER) {
-                finishWithSharedTransition(CLIPBOARD_OVERLAY_SHARE_TAPPED,
-                        IntentCreator.getShareIntent(mClipboardModel.getClipData(), mContext));
+            switch (mClipboardModel.getType()) {
+                case TEXT:
+                case URI:
+                    finish(CLIPBOARD_OVERLAY_SHARE_TAPPED,
+                            IntentCreator.getShareIntent(mClipboardModel.getClipData(), mContext));
+                    break;
+                case IMAGE:
+                    finishWithSharedTransition(CLIPBOARD_OVERLAY_SHARE_TAPPED,
+                            IntentCreator.getShareIntent(mClipboardModel.getClipData(), mContext));
+                    break;
             }
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalDatabase.kt b/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalDatabase.kt
index 8f1854f..17f4f0c 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalDatabase.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalDatabase.kt
@@ -26,7 +26,7 @@
 import androidx.sqlite.db.SupportSQLiteDatabase
 import com.android.systemui.res.R
 
-@Database(entities = [CommunalWidgetItem::class, CommunalItemRank::class], version = 3)
+@Database(entities = [CommunalWidgetItem::class, CommunalItemRank::class], version = 4)
 abstract class CommunalDatabase : RoomDatabase() {
     abstract fun communalWidgetDao(): CommunalWidgetDao
 
@@ -43,19 +43,16 @@
          * @param callback An optional callback registered to the database. Only effective when a
          *   new instance is created.
          */
-        fun getInstance(
-            context: Context,
-            callback: Callback? = null,
-        ): CommunalDatabase {
+        fun getInstance(context: Context, callback: Callback? = null): CommunalDatabase {
             if (instance == null) {
                 instance =
                     Room.databaseBuilder(
                             context,
                             CommunalDatabase::class.java,
-                            context.resources.getString(R.string.config_communalDatabase)
+                            context.resources.getString(R.string.config_communalDatabase),
                         )
                         .also { builder ->
-                            builder.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
+                            builder.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
                             builder.fallbackToDestructiveMigration(dropAllTables = true)
                             callback?.let { callback -> builder.addCallback(callback) }
                         }
@@ -103,5 +100,21 @@
                     )
                 }
             }
+
+        /**
+         * This migration adds a span_y column to the communal_widget_table and sets its default
+         * value to 3.
+         */
+        @VisibleForTesting
+        val MIGRATION_3_4 =
+            object : Migration(3, 4) {
+                override fun migrate(db: SupportSQLiteDatabase) {
+                    Log.i(TAG, "Migrating from version 3 to 4")
+                    db.execSQL(
+                        "ALTER TABLE communal_widget_table " +
+                            "ADD COLUMN span_y INTEGER NOT NULL DEFAULT 3"
+                    )
+                }
+            }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalEntities.kt b/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalEntities.kt
index e33aead..f9d2a84 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalEntities.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalEntities.kt
@@ -40,6 +40,12 @@
      */
     @ColumnInfo(name = "user_serial_number", defaultValue = "$USER_SERIAL_NUMBER_UNDEFINED")
     val userSerialNumber: Int,
+
+    /**
+     * The vertical span of the widget. Span_Y default value corresponds to
+     * CommunalContentSize.HALF.span
+     */
+    @ColumnInfo(name = "span_y", defaultValue = "3") val spanY: Int,
 ) {
     companion object {
         /**
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalWidgetDao.kt b/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalWidgetDao.kt
index 93b86bd..5dd4c1c 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalWidgetDao.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/db/CommunalWidgetDao.kt
@@ -25,6 +25,7 @@
 import androidx.room.Transaction
 import androidx.sqlite.db.SupportSQLiteDatabase
 import com.android.systemui.communal.nano.CommunalHubState
+import com.android.systemui.communal.shared.model.CommunalContentSize
 import com.android.systemui.communal.widgets.CommunalWidgetHost
 import com.android.systemui.communal.widgets.CommunalWidgetModule.Companion.DEFAULT_WIDGETS
 import com.android.systemui.dagger.SysUISingleton
@@ -153,14 +154,15 @@
 
     @Query(
         "INSERT INTO communal_widget_table" +
-            "(widget_id, component_name, item_id, user_serial_number) " +
-            "VALUES(:widgetId, :componentName, :itemId, :userSerialNumber)"
+            "(widget_id, component_name, item_id, user_serial_number, span_y) " +
+            "VALUES(:widgetId, :componentName, :itemId, :userSerialNumber, :spanY)"
     )
     fun insertWidget(
         widgetId: Int,
         componentName: String,
         itemId: Long,
         userSerialNumber: Int,
+        spanY: Int = 3,
     ): Long
 
     @Query("INSERT INTO communal_item_rank_table(rank) VALUES(:rank)")
@@ -169,6 +171,9 @@
     @Query("UPDATE communal_item_rank_table SET rank = :order WHERE uid = :itemUid")
     fun updateItemRank(itemUid: Long, order: Int)
 
+    @Query("UPDATE communal_widget_table SET span_y = :spanY WHERE widget_id = :widgetId")
+    fun updateWidgetSpanY(widgetId: Int, spanY: Int)
+
     @Query("DELETE FROM communal_widget_table") fun clearCommunalWidgetsTable()
 
     @Query("DELETE FROM communal_item_rank_table") fun clearCommunalItemRankTable()
@@ -189,12 +194,14 @@
         provider: ComponentName,
         rank: Int? = null,
         userSerialNumber: Int,
+        spanY: Int = CommunalContentSize.HALF.span,
     ): Long {
         return addWidget(
             widgetId = widgetId,
             componentName = provider.flattenToString(),
             rank = rank,
             userSerialNumber = userSerialNumber,
+            spanY = spanY,
         )
     }
 
@@ -204,6 +211,7 @@
         componentName: String,
         rank: Int? = null,
         userSerialNumber: Int,
+        spanY: Int = 3,
     ): Long {
         val widgets = getWidgetsNow()
 
@@ -224,6 +232,7 @@
             componentName = componentName,
             itemId = insertItemRank(newRank),
             userSerialNumber = userSerialNumber,
+            spanY = spanY,
         )
     }
 
@@ -246,7 +255,8 @@
         clearCommunalItemRankTable()
 
         state.widgets.forEach {
-            addWidget(it.widgetId, it.componentName, it.rank, it.userSerialNumber)
+            val spanY = if (it.spanY != 0) it.spanY else CommunalContentSize.HALF.span
+            addWidget(it.widgetId, it.componentName, it.rank, it.userSerialNumber, spanY)
         }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt
index 6cdd9ff..3312f3c 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt
@@ -92,6 +92,14 @@
 
     /** Aborts the restore process and removes files from disk if necessary. */
     fun abortRestoreWidgets()
+
+    /**
+     * Update the spanY of a widget in the database.
+     *
+     * @param widgetId id of the widget to update.
+     * @param spanY new spanY value for the widget.
+     */
+    fun updateWidgetSpanY(widgetId: Int, spanY: Int)
 }
 
 @SysUISingleton
@@ -118,20 +126,30 @@
 
     /** Widget metadata from database + matching [AppWidgetProviderInfo] if any. */
     private val widgetEntries: Flow<List<CommunalWidgetEntry>> =
-        combine(
-            communalWidgetDao.getWidgets(),
-            communalWidgetHost.appWidgetProviders,
-        ) { entries, providers ->
+        combine(communalWidgetDao.getWidgets(), communalWidgetHost.appWidgetProviders) {
+            entries,
+            providers ->
             entries.mapNotNull { (rank, widget) ->
                 CommunalWidgetEntry(
                     appWidgetId = widget.widgetId,
                     componentName = widget.componentName,
                     rank = rank.rank,
-                    providerInfo = providers[widget.widgetId]
+                    providerInfo = providers[widget.widgetId],
                 )
             }
         }
 
+    override fun updateWidgetSpanY(widgetId: Int, spanY: Int) {
+        bgScope.launch {
+            communalWidgetDao.updateWidgetSpanY(widgetId, spanY)
+            logger.i({ "Updated spanY of widget $int1 to $int2." }) {
+                int1 = widgetId
+                int2 = spanY
+            }
+            backupManager.dataChanged()
+        }
+    }
+
     @OptIn(ExperimentalCoroutinesApi::class)
     override val communalWidgets: Flow<List<CommunalWidgetContentModel>> =
         widgetEntries
@@ -197,6 +215,7 @@
                     provider = provider,
                     rank = rank,
                     userSerialNumber = userManager.getUserSerialNumber(user.identifier),
+                    spanY = 3,
                 )
                 backupManager.dataChanged()
             } else {
@@ -325,6 +344,7 @@
                         componentName = restoredWidget.componentName
                         rank = restoredWidget.rank
                         userSerialNumber = userManager.getUserSerialNumber(newUser.identifier)
+                        spanY = restoredWidget.spanY
                     }
                 }
             val newState = CommunalHubState().apply { widgets = newWidgets.toTypedArray() }
@@ -383,6 +403,7 @@
             appWidgetId = entry.appWidgetId,
             providerInfo = entry.providerInfo!!,
             rank = entry.rank,
+            spanY = entry.spanY,
         )
     }
 
@@ -400,6 +421,7 @@
                 appWidgetId = entry.appWidgetId,
                 providerInfo = entry.providerInfo!!,
                 rank = entry.rank,
+                spanY = entry.spanY,
             )
         }
 
@@ -412,6 +434,7 @@
                 componentName = componentName,
                 icon = session.icon,
                 user = session.user,
+                spanY = entry.spanY,
             )
         } else {
             null
@@ -423,5 +446,6 @@
         val componentName: String,
         val rank: Int,
         var providerInfo: AppWidgetProviderInfo? = null,
+        var spanY: Int = 3,
     )
 }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/proto/communal_hub_state.proto b/packages/SystemUI/src/com/android/systemui/communal/proto/communal_hub_state.proto
index bc14ae1..7602a7a 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/proto/communal_hub_state.proto
+++ b/packages/SystemUI/src/com/android/systemui/communal/proto/communal_hub_state.proto
@@ -38,5 +38,8 @@
 
         // Serial number of the user associated with the widget.
         int32 user_serial_number = 4;
+
+        // The vertical span of the widget
+        int32 span_y = 5;
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/communal/shared/model/CommunalWidgetContentModel.kt b/packages/SystemUI/src/com/android/systemui/communal/shared/model/CommunalWidgetContentModel.kt
index 63b1a14..bcbc8f6 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/shared/model/CommunalWidgetContentModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/shared/model/CommunalWidgetContentModel.kt
@@ -31,6 +31,7 @@
         override val appWidgetId: Int,
         val providerInfo: AppWidgetProviderInfo,
         override val rank: Int,
+        val spanY: Int = 3,
     ) : CommunalWidgetContentModel
 
     /** Widget is pending installation */
@@ -40,5 +41,6 @@
         val componentName: ComponentName,
         val icon: Bitmap?,
         val user: UserHandle,
+        val spanY: Int = 3,
     ) : CommunalWidgetContentModel
 }
diff --git a/packages/SystemUI/src/com/android/systemui/complication/dagger/DreamClockTimeComplicationComponent.kt b/packages/SystemUI/src/com/android/systemui/complication/dagger/DreamClockTimeComplicationComponent.kt
index 099e3fc..4b9ac1d 100644
--- a/packages/SystemUI/src/com/android/systemui/complication/dagger/DreamClockTimeComplicationComponent.kt
+++ b/packages/SystemUI/src/com/android/systemui/complication/dagger/DreamClockTimeComplicationComponent.kt
@@ -21,9 +21,10 @@
 import android.view.View
 import android.widget.TextClock
 import com.android.internal.util.Preconditions
-import com.android.systemui.res.R
+import com.android.systemui.Flags
 import com.android.systemui.complication.DreamClockTimeComplication
 import com.android.systemui.complication.DreamClockTimeComplication.DreamClockTimeViewHolder
+import com.android.systemui.res.R
 import dagger.Module
 import dagger.Provides
 import dagger.Subcomponent
@@ -71,9 +72,13 @@
                             /* root = */ null,
                             /* attachToRoot = */ false,
                         ) as TextClock,
-                        "R.layout.dream_overlay_complication_clock_time did not properly inflate"
+                        "R.layout.dream_overlay_complication_clock_time did not properly inflate",
                     )
-                view.setFontVariationSettings(TAG_WEIGHT + WEIGHT)
+                if (Flags.dreamOverlayUpdatedFont()) {
+                    view.setFontVariationSettings("'wght' 600, 'opsz' 96")
+                } else {
+                    view.setFontVariationSettings(TAG_WEIGHT + WEIGHT)
+                }
                 return view
             }
         }
diff --git a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java
index a94fbd9..a5b2277 100644
--- a/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java
+++ b/packages/SystemUI/src/com/android/systemui/dagger/ReferenceSystemUIModule.java
@@ -56,6 +56,7 @@
 import com.android.systemui.scene.SceneContainerFrameworkModule;
 import com.android.systemui.screenshot.ReferenceScreenshotModule;
 import com.android.systemui.settings.MultiUserUtilsModule;
+import com.android.systemui.settings.UserTracker;
 import com.android.systemui.shade.NotificationShadeWindowControllerImpl;
 import com.android.systemui.shade.ShadeModule;
 import com.android.systemui.startable.Dependencies;
@@ -178,9 +179,9 @@
     @Provides
     @SysUISingleton
     static IndividualSensorPrivacyController provideIndividualSensorPrivacyController(
-            SensorPrivacyManager sensorPrivacyManager) {
+            SensorPrivacyManager sensorPrivacyManager, UserTracker userTracker) {
         IndividualSensorPrivacyController spC = new IndividualSensorPrivacyControllerImpl(
-                sensorPrivacyManager);
+                sensorPrivacyManager, userTracker);
         spC.init();
         return spC;
     }
diff --git a/packages/SystemUI/src/com/android/systemui/display/data/repository/FocusedDisplayRepository.kt b/packages/SystemUI/src/com/android/systemui/display/data/repository/FocusedDisplayRepository.kt
new file mode 100644
index 0000000..dc07cca
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/display/data/repository/FocusedDisplayRepository.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.systemui.display.data.repository
+
+import android.annotation.MainThread
+import android.view.Display.DEFAULT_DISPLAY
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.log.LogBuffer
+import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.dagger.FocusedDisplayRepoLog
+import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
+import com.android.wm.shell.shared.FocusTransitionListener
+import com.android.wm.shell.shared.ShellTransitions
+import java.util.concurrent.Executor
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+
+/** Repository tracking display focus. */
+@SysUISingleton
+@MainThread
+class FocusedDisplayRepository
+@Inject
+constructor(
+    @Application val scope: CoroutineScope,
+    @Main private val mainExecutor: Executor,
+    transitions: ShellTransitions,
+    @FocusedDisplayRepoLog logBuffer: LogBuffer,
+) {
+    val focusedTask: Flow<Int> =
+        conflatedCallbackFlow {
+                val listener = FocusTransitionListener { displayId -> trySend(displayId) }
+                transitions.setFocusTransitionListener(listener, mainExecutor)
+                awaitClose { transitions.unsetFocusTransitionListener(listener) }
+            }
+            .onEach {
+                logBuffer.log(
+                    "FocusedDisplayRepository",
+                    LogLevel.INFO,
+                    { str1 = it.toString() },
+                    { "Newly focused display: $str1" },
+                )
+            }
+
+    /** Provides the currently focused display. */
+    val focusedDisplayId: StateFlow<Int>
+        get() = focusedTask.stateIn(scope, SharingStarted.Eagerly, DEFAULT_DISPLAY)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt b/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt
index 5a008bd..7711c48 100644
--- a/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/inputdevice/data/repository/InputDeviceRepository.kt
@@ -38,7 +38,7 @@
 constructor(
     @Background private val backgroundHandler: Handler,
     @Background private val backgroundScope: CoroutineScope,
-    private val inputManager: InputManager
+    private val inputManager: InputManager,
 ) {
 
     sealed interface DeviceChange
@@ -50,11 +50,11 @@
     data object FreshStart : DeviceChange
 
     /**
-     * Emits collection of all currently connected keyboards and what was the last [DeviceChange].
-     * It emits collection so that every new subscriber to this SharedFlow can get latest state of
-     * all keyboards. Otherwise we might get into situation where subscriber timing on
-     * initialization matter and later subscriber will only get latest device and will miss all
-     * previous devices.
+     * Emits collection of all currently connected input devices and what was the last
+     * [DeviceChange]. It emits collection so that every new subscriber to this SharedFlow can get
+     * latest state of all input devices. Otherwise we might get into situation where subscriber
+     * timing on initialization matter and later subscriber will only get latest device and will
+     * miss all previous devices.
      */
     // TODO(b/351984587): Replace with StateFlow
     @SuppressLint("SharedFlowCreation")
@@ -79,11 +79,7 @@
                 inputManager.registerInputDeviceListener(listener, backgroundHandler)
                 awaitClose { inputManager.unregisterInputDeviceListener(listener) }
             }
-            .shareIn(
-                scope = backgroundScope,
-                started = SharingStarted.Lazily,
-                replay = 1,
-            )
+            .shareIn(scope = backgroundScope, started = SharingStarted.Lazily, replay = 1)
 
     private fun <T> SendChannel<T>.sendWithLogging(element: T) {
         trySendWithFailureLogging(element, TAG)
diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt
index f49cfdd..021c069 100644
--- a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/CommandLineKeyboardRepository.kt
@@ -50,6 +50,8 @@
     private val _newlyConnectedKeyboard: MutableStateFlow<Keyboard?> = MutableStateFlow(null)
     override val newlyConnectedKeyboard: Flow<Keyboard> = _newlyConnectedKeyboard.filterNotNull()
 
+    override val connectedKeyboards: Flow<Set<Keyboard>> = MutableStateFlow(emptySet())
+
     init {
         Log.i(TAG, "initializing shell command $COMMAND")
         commandRegistry.registerCommand(COMMAND) { KeyboardCommand() }
diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt
index a20dfa5..3329fe2 100644
--- a/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyboard/data/repository/KeyboardRepository.kt
@@ -61,6 +61,9 @@
      */
     val newlyConnectedKeyboard: Flow<Keyboard>
 
+    /** Emits set of currently connected keyboards */
+    val connectedKeyboards: Flow<Set<Keyboard>>
+
     /**
      * Emits [BacklightModel] whenever user changes backlight level from keyboard press. Can only
      * happen when physical keyboard is connected
@@ -74,7 +77,7 @@
 constructor(
     @Background private val backgroundDispatcher: CoroutineDispatcher,
     private val inputManager: InputManager,
-    inputDeviceRepository: InputDeviceRepository
+    inputDeviceRepository: InputDeviceRepository,
 ) : KeyboardRepository {
 
     @FlowPreview
@@ -93,6 +96,13 @@
             .mapNotNull { deviceIdToKeyboard(it) }
             .flowOn(backgroundDispatcher)
 
+    override val connectedKeyboards: Flow<Set<Keyboard>> =
+        inputDeviceRepository.deviceChange
+            .map { (deviceIds, _) -> deviceIds }
+            .map { deviceIds -> deviceIds.filter { isPhysicalFullKeyboard(it) } }
+            .distinctUntilChanged()
+            .map { deviceIds -> deviceIds.mapNotNull { deviceIdToKeyboard(it) }.toSet() }
+
     override val isAnyKeyboardConnected: Flow<Boolean> =
         inputDeviceRepository.deviceChange
             .map { (ids, _) -> ids.any { id -> isPhysicalFullKeyboard(id) } }
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractor.kt
index 9443570..1497026 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/LightRevealScrimInteractor.kt
@@ -19,10 +19,12 @@
 import com.android.keyguard.logging.ScrimLogger
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.keyguard.data.repository.DEFAULT_REVEAL_DURATION
 import com.android.systemui.keyguard.data.repository.LightRevealScrimRepository
 import com.android.systemui.keyguard.shared.model.KeyguardState
 import com.android.systemui.power.domain.interactor.PowerInteractor
 import com.android.systemui.power.shared.model.ScreenPowerState
+import com.android.systemui.power.shared.model.WakeSleepReason
 import com.android.systemui.statusbar.LightRevealEffect
 import com.android.systemui.util.kotlin.sample
 import dagger.Lazy
@@ -50,11 +52,29 @@
         scope.launch {
             transitionInteractor.startedKeyguardTransitionStep.collect {
                 scrimLogger.d(TAG, "listenForStartedKeyguardTransitionStep", it)
-                lightRevealScrimRepository.startRevealAmountAnimator(willBeRevealedInState(it.to))
+                val animationDuration =
+                    if (it.to == KeyguardState.AOD && isLastSleepDueToFold) {
+                        // Do not animate the scrim when folding as we want to cover the screen
+                        // with the scrim immediately while displays are switching.
+                        // This is needed to play the fold to AOD animation which starts with
+                        // fully black screen (see FoldAodAnimationController)
+                        0L
+                    } else {
+                        DEFAULT_REVEAL_DURATION
+                    }
+
+                lightRevealScrimRepository.startRevealAmountAnimator(
+                    willBeRevealedInState(it.to),
+                    duration = animationDuration
+                )
             }
         }
     }
 
+    private val isLastSleepDueToFold: Boolean
+        get() = powerInteractor.get().detailedWakefulness.value
+            .lastSleepReason == WakeSleepReason.FOLD
+
     /**
      * Whenever a keyguard transition starts, sample the latest reveal effect from the repository
      * and use that for the starting transition.
diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardPreviewClockViewBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardPreviewClockViewBinder.kt
index 8386628..57cb10f 100644
--- a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardPreviewClockViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardPreviewClockViewBinder.kt
@@ -121,7 +121,10 @@
     private fun applyClockDefaultConstraints(context: Context, constraints: ConstraintSet) {
         constraints.apply {
             constrainWidth(R.id.lockscreen_clock_view_large, ConstraintSet.WRAP_CONTENT)
-            constrainHeight(R.id.lockscreen_clock_view_large, ConstraintSet.MATCH_CONSTRAINT)
+            // The following two lines make lockscreen_clock_view_large is constrained to available
+            // height when it goes beyond constraints; otherwise, it use WRAP_CONTENT
+            constrainHeight(R.id.lockscreen_clock_view_large, WRAP_CONTENT)
+            constrainMaxHeight(R.id.lockscreen_clock_view_large, 0)
             val largeClockTopMargin =
                 SystemBarUtils.getStatusBarHeight(context) +
                     context.resources.getDimensionPixelSize(
@@ -138,7 +141,7 @@
                 R.id.lockscreen_clock_view_large,
                 ConstraintSet.END,
                 PARENT_ID,
-                ConstraintSet.END
+                ConstraintSet.END,
             )
 
             // In preview, we'll show UDFPS icon for UDFPS devices
@@ -160,14 +163,14 @@
                     BOTTOM,
                     PARENT_ID,
                     BOTTOM,
-                    clockBottomMargin
+                    clockBottomMargin,
                 )
             }
 
             constrainWidth(R.id.lockscreen_clock_view, WRAP_CONTENT)
             constrainHeight(
                 R.id.lockscreen_clock_view,
-                context.resources.getDimensionPixelSize(customizationR.dimen.small_clock_height)
+                context.resources.getDimensionPixelSize(customizationR.dimen.small_clock_height),
             )
             connect(
                 R.id.lockscreen_clock_view,
@@ -175,7 +178,7 @@
                 PARENT_ID,
                 START,
                 context.resources.getDimensionPixelSize(customizationR.dimen.clock_padding_start) +
-                    context.resources.getDimensionPixelSize(R.dimen.status_view_margin_horizontal)
+                    context.resources.getDimensionPixelSize(R.dimen.status_view_margin_horizontal),
             )
             val smallClockTopMargin =
                 context.resources.getDimensionPixelSize(R.dimen.keyguard_clock_top_margin) +
@@ -188,7 +191,7 @@
         context: Context,
         rootView: ConstraintLayout,
         previewClock: ClockController,
-        viewModel: KeyguardPreviewClockViewModel
+        viewModel: KeyguardPreviewClockViewModel,
     ) {
         val cs = ConstraintSet().apply { clone(rootView) }
         applyClockDefaultConstraints(context, cs)
diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/FocusedDisplayRepoLog.kt b/packages/SystemUI/src/com/android/systemui/log/dagger/FocusedDisplayRepoLog.kt
new file mode 100644
index 0000000..302f962
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/log/dagger/FocusedDisplayRepoLog.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2023 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.systemui.log.dagger
+
+import javax.inject.Qualifier
+
+/** A [com.android.systemui.log.LogBuffer] for display metrics related logging. */
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class FocusedDisplayRepoLog
diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
index 2053b53..4e975ff 100644
--- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
+++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
@@ -480,6 +480,16 @@
     }
 
     /**
+     * Provides a {@link LogBuffer} for use by SIM events.
+     */
+    @Provides
+    @SysUISingleton
+    @SimLog
+    public static LogBuffer provideSimLogBuffer(LogBufferFactory factory) {
+        return factory.create("SimLog", 500);
+    }
+
+    /**
      * Provides a {@link LogBuffer} for use by {@link com.android.keyguard.KeyguardUpdateMonitor}.
      */
     @Provides
@@ -655,6 +665,14 @@
         return factory.create("DisplayMetricsRepo", 50);
     }
 
+    /** Provides a {@link LogBuffer} for focus related logs. */
+    @Provides
+    @SysUISingleton
+    @FocusedDisplayRepoLog
+    public static LogBuffer provideFocusedDisplayRepoLogBuffer(LogBufferFactory factory) {
+        return factory.create("FocusedDisplayRepo", 50);
+    }
+
     /** Provides a {@link LogBuffer} for the scene framework. */
     @Provides
     @SysUISingleton
diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/SimLog.kt b/packages/SystemUI/src/com/android/systemui/log/dagger/SimLog.kt
new file mode 100644
index 0000000..64fc6e7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/log/dagger/SimLog.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.systemui.log.dagger
+
+import javax.inject.Qualifier
+
+/** A [com.android.systemui.log.LogBuffer] for SIM events. */
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class SimLog
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt
index 222d783..4528b04 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/LegacyMediaDataManagerImpl.kt
@@ -417,6 +417,7 @@
     override fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
         if (useQsMediaPlayer && isMediaNotification(sbn)) {
             var isNewlyActiveEntry = false
+            var isConvertingToActive = false
             Assert.isMainThread()
             val oldKey = findExistingEntry(key, sbn.packageName)
             if (oldKey == null) {
@@ -433,9 +434,10 @@
                 // Resume -> active conversion; move to new key
                 val oldData = mediaEntries.remove(oldKey)!!
                 isNewlyActiveEntry = true
+                isConvertingToActive = true
                 mediaEntries.put(key, oldData)
             }
-            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
+            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive)
         } else {
             onNotificationRemoved(key)
         }
@@ -535,10 +537,11 @@
         sbn: StatusBarNotification,
         oldKey: String?,
         isNewlyActiveEntry: Boolean = false,
+        isConvertingToActive: Boolean = false,
     ) {
         if (Flags.mediaLoadMetadataViaMediaDataLoader()) {
             applicationScope.launch {
-                loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry)
+                loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive)
             }
         } else {
             backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
@@ -550,10 +553,11 @@
         sbn: StatusBarNotification,
         oldKey: String?,
         isNewlyActiveEntry: Boolean = false,
+        isConvertingToActive: Boolean = false,
     ) =
         withContext(backgroundDispatcher) {
             val lastActive = systemClock.elapsedRealtime()
-            val result = mediaDataLoader.get().loadMediaData(key, sbn)
+            val result = mediaDataLoader.get().loadMediaData(key, sbn, isConvertingToActive)
             if (result == null) {
                 Log.d(TAG, "No result from loadMediaData")
                 return@withContext
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt
index 7b55dac8..7b8703d 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt
@@ -111,16 +111,26 @@
      * If a new [loadMediaData] is issued while existing load is in progress, the existing (old)
      * load will be cancelled.
      */
-    suspend fun loadMediaData(key: String, sbn: StatusBarNotification): MediaDataLoaderResult? {
-        val loadMediaJob = backgroundScope.async { loadMediaDataInBackground(key, sbn) }
+    suspend fun loadMediaData(
+        key: String,
+        sbn: StatusBarNotification,
+        isConvertingToActive: Boolean = false,
+    ): MediaDataLoaderResult? {
+        val loadMediaJob =
+            backgroundScope.async { loadMediaDataInBackground(key, sbn, isConvertingToActive) }
         loadMediaJob.invokeOnCompletion {
             // We need to make sure we're removing THIS job after cancellation, not
             // a job that we created later.
             mediaProcessingJobs.remove(key, loadMediaJob)
         }
-        val existingJob = mediaProcessingJobs.put(key, loadMediaJob)
+        var existingJob: Job? = null
+        // Do not cancel loading jobs that convert resume players to active.
+        if (!isConvertingToActive) {
+            existingJob = mediaProcessingJobs.put(key, loadMediaJob)
+            existingJob?.cancel("New processing job incoming.")
+        }
         logD(TAG) { "Loading media data for $key... / existing job: $existingJob" }
-        existingJob?.cancel("New processing job incoming.")
+
         return loadMediaJob.await()
     }
 
@@ -129,12 +139,16 @@
     private suspend fun loadMediaDataInBackground(
         key: String,
         sbn: StatusBarNotification,
+        isConvertingToActive: Boolean = false,
     ): MediaDataLoaderResult? =
         traceCoroutine("MediaDataLoader#loadMediaData") {
             // We have apps spamming us with quick notification updates which can cause
             // us to spend significant CPU time loading duplicate data. This debounces
             // those requests at the cost of a bit of latency.
-            delay(DEBOUNCE_DELAY_MS)
+            // No delay needed to load jobs converting resume players to active.
+            if (!isConvertingToActive) {
+                delay(DEBOUNCE_DELAY_MS)
+            }
 
             val token =
                 sbn.notification.extras.getParcelable(
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt
index fd7b6dc..affc7b7 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataProcessor.kt
@@ -330,6 +330,7 @@
     fun onNotificationAdded(key: String, sbn: StatusBarNotification) {
         if (useQsMediaPlayer && isMediaNotification(sbn)) {
             var isNewlyActiveEntry = false
+            var isConvertingToActive = false
             Assert.isMainThread()
             val oldKey = findExistingEntry(key, sbn.packageName)
             if (oldKey == null) {
@@ -347,9 +348,10 @@
                 // Resume -> active conversion; move to new key
                 val oldData = mediaDataRepository.removeMediaEntry(oldKey)!!
                 isNewlyActiveEntry = true
+                isConvertingToActive = true
                 mediaDataRepository.addMediaEntry(key, oldData)
             }
-            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry)
+            loadMediaData(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive)
         } else {
             onNotificationRemoved(key)
         }
@@ -488,10 +490,11 @@
         sbn: StatusBarNotification,
         oldKey: String?,
         isNewlyActiveEntry: Boolean = false,
+        isConvertingToActive: Boolean = false,
     ) {
         if (Flags.mediaLoadMetadataViaMediaDataLoader()) {
             applicationScope.launch {
-                loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry)
+                loadMediaDataWithLoader(key, sbn, oldKey, isNewlyActiveEntry, isConvertingToActive)
             }
         } else {
             backgroundExecutor.execute { loadMediaDataInBg(key, sbn, oldKey, isNewlyActiveEntry) }
@@ -835,10 +838,11 @@
         sbn: StatusBarNotification,
         oldKey: String?,
         isNewlyActiveEntry: Boolean = false,
+        isConvertingToActive: Boolean = false,
     ) =
         withContext(backgroundDispatcher) {
             val lastActive = systemClock.elapsedRealtime()
-            val result = mediaDataLoader.get().loadMediaData(key, sbn)
+            val result = mediaDataLoader.get().loadMediaData(key, sbn, isConvertingToActive)
             if (result == null) {
                 Log.d(TAG, "No result from loadMediaData")
                 return@withContext
diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt
index 078d534..f563f87 100644
--- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverLogger.kt
@@ -26,18 +26,11 @@
 
 /** A logger for all events related to the media tap-to-transfer receiver experience. */
 @SysUISingleton
-class MediaTttReceiverLogger
-@Inject
-constructor(
-    @MediaTttReceiverLogBuffer buffer: LogBuffer,
-) : TemporaryViewLogger<ChipReceiverInfo>(buffer, TAG) {
+class MediaTttReceiverLogger @Inject constructor(@MediaTttReceiverLogBuffer buffer: LogBuffer) :
+    TemporaryViewLogger<ChipReceiverInfo>(buffer, TAG) {
 
     /** Logs a change in the chip state for the given [mediaRouteId]. */
-    fun logStateChange(
-        stateName: String,
-        mediaRouteId: String,
-        packageName: String?,
-    ) {
+    fun logStateChange(stateName: String, mediaRouteId: String, packageName: String?) {
         MediaTttLoggerUtils.logStateChange(buffer, TAG, stateName, mediaRouteId, packageName)
     }
 
@@ -51,12 +44,27 @@
         MediaTttLoggerUtils.logPackageNotFound(buffer, TAG, packageName)
     }
 
-    fun logRippleAnimationEnd(id: Int) {
+    fun logRippleAnimationEnd(id: Int, type: String) {
         buffer.log(
             tag,
             LogLevel.DEBUG,
-            { int1 = id },
-            { "ripple animation for view with id: $int1 is ended" }
+            {
+                int1 = id
+                str1 = type
+            },
+            { "ripple animation for view with id=$int1 is ended, animation type=$str1" },
+        )
+    }
+
+    fun logRippleAnimationStart(id: Int, type: String) {
+        buffer.log(
+            tag,
+            LogLevel.DEBUG,
+            {
+                int1 = id
+                str1 = type
+            },
+            { "ripple animation for view with id=$int1 is started, animation type=$str1" },
         )
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt
index a232971..9d00435 100644
--- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttReceiverRippleController.kt
@@ -69,7 +69,9 @@
         )
         rippleView.addOnAttachStateChangeListener(
             object : View.OnAttachStateChangeListener {
-                override fun onViewDetachedFromWindow(view: View) {}
+                override fun onViewDetachedFromWindow(view: View) {
+                    view.visibility = View.GONE
+                }
 
                 override fun onViewAttachedToWindow(view: View) {
                     if (view == null) {
@@ -81,7 +83,7 @@
                     } else {
                         layoutRipple(attachedRippleView)
                     }
-                    attachedRippleView.expandRipple()
+                    attachedRippleView.expandRipple(mediaTttReceiverLogger)
                     attachedRippleView.removeOnAttachStateChangeListener(this)
                 }
             }
@@ -126,7 +128,7 @@
         iconRippleView.setMaxSize(radius * 0.8f, radius * 0.8f)
         iconRippleView.setCenter(
             width * 0.5f,
-            height - getReceiverIconSize() * 0.5f - getReceiverIconBottomMargin()
+            height - getReceiverIconSize() * 0.5f - getReceiverIconBottomMargin(),
         )
         iconRippleView.setColor(getRippleColor(), RIPPLE_OPACITY)
     }
diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt
index 81059e3..cd733ec 100644
--- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/ReceiverChipRippleView.kt
@@ -37,10 +37,14 @@
         isStarted = false
     }
 
-    fun expandRipple(onAnimationEnd: Runnable? = null) {
+    fun expandRipple(logger: MediaTttReceiverLogger, onAnimationEnd: Runnable? = null) {
         duration = DEFAULT_DURATION
         isStarted = true
-        super.startRipple(onAnimationEnd)
+        super.startRipple {
+            logger.logRippleAnimationEnd(id, EXPAND)
+            onAnimationEnd?.run()
+        }
+        logger.logRippleAnimationStart(id, EXPAND)
     }
 
     /** Used to animate out the ripple. No-op if the ripple was never started via [startRipple]. */
@@ -53,10 +57,14 @@
         animator.removeAllListeners()
         animator.addListener(
             object : AnimatorListenerAdapter() {
+                override fun onAnimationCancel(animation: Animator) {
+                    onAnimationEnd(animation)
+                }
+
                 override fun onAnimationEnd(animation: Animator) {
                     animation?.let {
                         visibility = GONE
-                        logger.logRippleAnimationEnd(id)
+                        logger.logRippleAnimationEnd(id, COLLAPSE)
                     }
                     onAnimationEnd?.run()
                     isStarted = false
@@ -64,13 +72,14 @@
             }
         )
         animator.reverse()
+        logger.logRippleAnimationStart(id, COLLAPSE)
     }
 
     // Expands the ripple to cover full screen.
     fun expandToFull(
         newHeight: Float,
         logger: MediaTttReceiverLogger,
-        onAnimationEnd: Runnable? = null
+        onAnimationEnd: Runnable? = null,
     ) {
         if (!isStarted) {
             return
@@ -95,10 +104,14 @@
         }
         animator.addListener(
             object : AnimatorListenerAdapter() {
+                override fun onAnimationCancel(animation: Animator) {
+                    onAnimationEnd(animation)
+                }
+
                 override fun onAnimationEnd(animation: Animator) {
                     animation?.let {
                         visibility = GONE
-                        logger.logRippleAnimationEnd(id)
+                        logger.logRippleAnimationEnd(id, EXPAND_TO_FULL)
                     }
                     onAnimationEnd?.run()
                     isStarted = false
@@ -106,6 +119,7 @@
             }
         )
         animator.start()
+        logger.logRippleAnimationStart(id, EXPAND_TO_FULL)
     }
 
     // Calculates the actual starting percentage according to ripple shader progress set method.
@@ -151,5 +165,8 @@
     companion object {
         const val DEFAULT_DURATION = 333L
         const val EXPAND_TO_FULL_DURATION = 1000L
+        private const val COLLAPSE = "collapse"
+        private const val EXPAND_TO_FULL = "expand to full"
+        private const val EXPAND = "expand"
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java
index f7a505a..5048a5d 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java
@@ -326,6 +326,11 @@
                     logGesture(mInRejectedExclusion
                             ? SysUiStatsLog.BACK_GESTURE__TYPE__COMPLETED_REJECTED
                             : SysUiStatsLog.BACK_GESTURE__TYPE__COMPLETED);
+                    if (!mInRejectedExclusion) {
+                        // Log successful back gesture to contextual edu stats
+                        mOverviewProxyService.updateContextualEduStats(mIsTrackpadThreeFingerSwipe,
+                                GestureType.BACK);
+                    }
                 }
 
                 @Override
@@ -1153,8 +1158,6 @@
                         if (mAllowGesture) {
                             if (mBackAnimation != null) {
                                 mBackAnimation.onThresholdCrossed();
-                                mOverviewProxyService.updateContextualEduStats(
-                                        mIsTrackpadThreeFingerSwipe, GestureType.BACK);
                             } else {
                                 pilferPointers();
                             }
diff --git a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
index 66ac01a..51d2329 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt
@@ -52,7 +52,6 @@
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.layout.onPlaced
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.layout.positionInRoot
@@ -62,7 +61,6 @@
 import androidx.compose.ui.semantics.CustomAccessibilityAction
 import androidx.compose.ui.semantics.customActions
 import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.round
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -191,58 +189,22 @@
         val context = inflater.context
         val composeView =
             ComposeView(context).apply {
-                setBackPressedDispatcher()
-                setContent {
-                    PlatformTheme {
-                        val visible by viewModel.qsVisible.collectAsStateWithLifecycle()
-
-                        AnimatedVisibility(
-                            visible = visible,
-                            modifier =
-                                Modifier.windowInsetsPadding(WindowInsets.navigationBars)
-                                    .thenIf(notificationScrimClippingParams.isEnabled) {
-                                        Modifier.notificationScrimClip(
-                                            notificationScrimClippingParams.leftInset,
-                                            notificationScrimClippingParams.top,
-                                            notificationScrimClippingParams.rightInset,
-                                            notificationScrimClippingParams.bottom,
-                                            notificationScrimClippingParams.radius,
+                repeatWhenAttached {
+                    repeatOnLifecycle(Lifecycle.State.CREATED) {
+                        setViewTreeOnBackPressedDispatcherOwner(
+                            object : OnBackPressedDispatcherOwner {
+                                override val onBackPressedDispatcher =
+                                    OnBackPressedDispatcher().apply {
+                                        setOnBackInvokedDispatcher(
+                                            it.viewRootImpl.onBackInvokedDispatcher
                                         )
                                     }
-                                    .graphicsLayer { elevation = 4.dp.toPx() },
-                        ) {
-                            val isEditing by
-                                viewModel.containerViewModel.editModeViewModel.isEditing
-                                    .collectAsStateWithLifecycle()
-                            val animationSpecEditMode = tween<Float>(EDIT_MODE_TIME_MILLIS)
-                            AnimatedContent(
-                                targetState = isEditing,
-                                transitionSpec = {
-                                    fadeIn(animationSpecEditMode) togetherWith
-                                        fadeOut(animationSpecEditMode)
-                                },
-                                label = "EditModeAnimatedContent",
-                            ) { editing ->
-                                if (editing) {
-                                    val qqsPadding by
-                                        viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
-                                    EditMode(
-                                        viewModel = viewModel.containerViewModel.editModeViewModel,
-                                        modifier =
-                                            Modifier.fillMaxWidth()
-                                                .padding(top = { qqsPadding })
-                                                .padding(
-                                                    horizontal = {
-                                                        QuickSettingsShade.Dimensions.Padding
-                                                            .roundToPx()
-                                                    }
-                                                ),
-                                    )
-                                } else {
-                                    CollapsableQuickSettingsSTL()
-                                }
+
+                                override val lifecycle: Lifecycle =
+                                    this@repeatWhenAttached.lifecycle
                             }
-                        }
+                        )
+                        setContent { this@QSFragmentCompose.Content() }
                     }
                 }
             }
@@ -261,6 +223,58 @@
         return frame
     }
 
+    @Composable
+    private fun Content() {
+        PlatformTheme {
+            val visible by viewModel.qsVisible.collectAsStateWithLifecycle()
+
+            AnimatedVisibility(
+                visible = visible,
+                modifier =
+                    Modifier.windowInsetsPadding(WindowInsets.navigationBars).thenIf(
+                        notificationScrimClippingParams.isEnabled
+                    ) {
+                        Modifier.notificationScrimClip(
+                            notificationScrimClippingParams.leftInset,
+                            notificationScrimClippingParams.top,
+                            notificationScrimClippingParams.rightInset,
+                            notificationScrimClippingParams.bottom,
+                            notificationScrimClippingParams.radius,
+                        )
+                    },
+            ) {
+                val isEditing by
+                    viewModel.containerViewModel.editModeViewModel.isEditing
+                        .collectAsStateWithLifecycle()
+                val animationSpecEditMode = tween<Float>(EDIT_MODE_TIME_MILLIS)
+                AnimatedContent(
+                    targetState = isEditing,
+                    transitionSpec = {
+                        fadeIn(animationSpecEditMode) togetherWith fadeOut(animationSpecEditMode)
+                    },
+                    label = "EditModeAnimatedContent",
+                ) { editing ->
+                    if (editing) {
+                        val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
+                        EditMode(
+                            viewModel = viewModel.containerViewModel.editModeViewModel,
+                            modifier =
+                                Modifier.fillMaxWidth()
+                                    .padding(top = { qqsPadding })
+                                    .padding(
+                                        horizontal = {
+                                            QuickSettingsShade.Dimensions.Padding.roundToPx()
+                                        }
+                                    ),
+                        )
+                    } else {
+                        CollapsableQuickSettingsSTL()
+                    }
+                }
+            }
+        }
+    }
+
     /**
      * STL that contains both QQS (tiles) and QS (brightness, tiles, footer actions), but no Edit
      * mode. It tracks [QSFragmentComposeViewModel.expansionState] to drive the transition between
@@ -649,23 +663,6 @@
     }
 }
 
-private fun View.setBackPressedDispatcher() {
-    repeatWhenAttached {
-        repeatOnLifecycle(Lifecycle.State.CREATED) {
-            setViewTreeOnBackPressedDispatcherOwner(
-                object : OnBackPressedDispatcherOwner {
-                    override val onBackPressedDispatcher =
-                        OnBackPressedDispatcher().apply {
-                            setOnBackInvokedDispatcher(it.viewRootImpl.onBackInvokedDispatcher)
-                        }
-
-                    override val lifecycle: Lifecycle = this@repeatWhenAttached.lifecycle
-                }
-            )
-        }
-    }
-}
-
 private suspend inline fun <Listener : Any, Data> setListenerJob(
     listenerFlow: MutableStateFlow<Listener?>,
     dataFlow: Flow<Data>,
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/DndTile.java b/packages/SystemUI/src/com/android/systemui/qs/tiles/DndTile.java
index a4fe4e3..ad76b4f 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/DndTile.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/DndTile.java
@@ -169,50 +169,34 @@
 
     private void enableZenMode(@Nullable Expandable expandable) {
         int zenDuration = mSettingZenDuration.getValue();
-        boolean showOnboarding = Settings.Secure.getInt(mContext.getContentResolver(),
-                Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0) != 0
-                && Settings.Secure.getInt(mContext.getContentResolver(),
-                Settings.Secure.ZEN_SETTINGS_UPDATED, 0) != 1;
-        if (showOnboarding) {
-            // don't show on-boarding again or notification ever
-            Settings.Secure.putInt(mContext.getContentResolver(),
-                    Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0);
-            // turn on DND
-            mController.setZen(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS, null, TAG);
-            // show on-boarding screen
-            Intent intent = new Intent(Settings.ZEN_MODE_ONBOARDING);
-            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
-            mActivityStarter.postStartActivityDismissingKeyguard(intent, 0);
-        } else {
-            switch (zenDuration) {
-                case Settings.Secure.ZEN_DURATION_PROMPT:
-                    mUiHandler.post(() -> {
-                        Dialog dialog = makeZenModeDialog();
-                        if (expandable != null) {
-                            DialogTransitionAnimator.Controller controller =
-                                    expandable.dialogTransitionController(new DialogCuj(
-                                            InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
-                                            INTERACTION_JANK_TAG));
-                            if (controller != null) {
-                                mDialogTransitionAnimator.show(dialog,
-                                        controller, /* animateBackgroundBoundsChange= */ false);
-                            } else {
-                                dialog.show();
-                            }
+        switch (zenDuration) {
+            case Settings.Secure.ZEN_DURATION_PROMPT:
+                mUiHandler.post(() -> {
+                    Dialog dialog = makeZenModeDialog();
+                    if (expandable != null) {
+                        DialogTransitionAnimator.Controller controller =
+                                expandable.dialogTransitionController(new DialogCuj(
+                                        InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
+                                        INTERACTION_JANK_TAG));
+                        if (controller != null) {
+                            mDialogTransitionAnimator.show(dialog,
+                                    controller, /* animateBackgroundBoundsChange= */ false);
                         } else {
                             dialog.show();
                         }
-                    });
-                    break;
-                case Settings.Secure.ZEN_DURATION_FOREVER:
-                    mController.setZen(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS, null, TAG);
-                    break;
-                default:
-                    Uri conditionId = ZenModeConfig.toTimeCondition(mContext, zenDuration,
-                            mHost.getUserId(), true).id;
-                    mController.setZen(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS,
-                            conditionId, TAG);
-            }
+                    } else {
+                        dialog.show();
+                    }
+                });
+                break;
+            case Settings.Secure.ZEN_DURATION_FOREVER:
+                mController.setZen(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS, null, TAG);
+                break;
+            default:
+                Uri conditionId = ZenModeConfig.toTimeCondition(mContext, zenDuration,
+                        mHost.getUserId(), true).id;
+                mController.setZen(Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS,
+                        conditionId, TAG);
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt
index 4fdd90b..fb7c34f 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotData.kt
@@ -4,10 +4,10 @@
 import android.graphics.Bitmap
 import android.graphics.Insets
 import android.graphics.Rect
-import android.net.Uri
 import android.os.Process
 import android.os.UserHandle
 import android.view.Display
+import android.view.WindowManager
 import android.view.WindowManager.ScreenshotSource
 import android.view.WindowManager.ScreenshotType
 import androidx.annotation.VisibleForTesting
@@ -15,22 +15,20 @@
 
 /** [ScreenshotData] represents the current state of a single screenshot being acquired. */
 data class ScreenshotData(
-    @ScreenshotType var type: Int,
-    @ScreenshotSource var source: Int,
+    @ScreenshotType val type: Int,
+    @ScreenshotSource val source: Int,
     /** UserHandle for the owner of the app being screenshotted, if known. */
-    var userHandle: UserHandle?,
+    val userHandle: UserHandle?,
     /** ComponentName of the top-most app in the screenshot. */
-    var topComponent: ComponentName?,
+    val topComponent: ComponentName?,
     var screenBounds: Rect?,
-    var taskId: Int,
+    val taskId: Int,
     var insets: Insets,
     var bitmap: Bitmap?,
-    var displayId: Int,
-    /** App-provided URL representing the content the user was looking at in the screenshot. */
-    var contextUrl: Uri? = null,
+    val displayId: Int,
 ) {
-    val packageNameString: String
-        get() = if (topComponent == null) "" else topComponent!!.packageName
+    val packageNameString
+        get() = topComponent?.packageName ?: ""
 
     fun getUserOrDefault(): UserHandle {
         return userHandle ?: Process.myUserHandle()
@@ -52,16 +50,21 @@
             )
 
         @VisibleForTesting
-        fun forTesting() =
+        fun forTesting(
+            userHandle: UserHandle? = null,
+            source: Int = ScreenshotSource.SCREENSHOT_KEY_CHORD,
+            topComponent: ComponentName? = null,
+            bitmap: Bitmap? = null,
+        ) =
             ScreenshotData(
-                type = 0,
-                source = 0,
-                userHandle = null,
-                topComponent = null,
+                type = WindowManager.TAKE_SCREENSHOT_FULLSCREEN,
+                source = source,
+                userHandle = userHandle,
+                topComponent = topComponent,
                 screenBounds = null,
                 taskId = 0,
                 insets = Insets.NONE,
-                bitmap = null,
+                bitmap = bitmap,
                 displayId = Display.DEFAULT_DISPLAY,
             )
     }
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt b/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt
index 448f7c4..ab8a953 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotExecutor.kt
@@ -20,9 +20,11 @@
 import android.os.Trace
 import android.util.Log
 import android.view.Display
+import android.view.WindowManager.ScreenshotSource
 import android.view.WindowManager.TAKE_SCREENSHOT_PROVIDED_IMAGE
 import com.android.internal.logging.UiEventLogger
 import com.android.internal.util.ScreenshotRequest
+import com.android.systemui.Flags.screenshotMultidisplayFocusChange
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.display.data.repository.DisplayRepository
@@ -40,7 +42,7 @@
     suspend fun executeScreenshots(
         screenshotRequest: ScreenshotRequest,
         onSaved: (Uri?) -> Unit,
-        requestCallback: RequestCallback
+        requestCallback: RequestCallback,
     )
 
     fun onCloseSystemDialogsReceived()
@@ -52,7 +54,7 @@
     fun executeScreenshotsAsync(
         screenshotRequest: ScreenshotRequest,
         onSaved: Consumer<Uri?>,
-        requestCallback: RequestCallback
+        requestCallback: RequestCallback,
     )
 }
 
@@ -60,7 +62,7 @@
     fun handleScreenshot(
         screenshot: ScreenshotData,
         finisher: Consumer<Uri?>,
-        requestCallback: RequestCallback
+        requestCallback: RequestCallback,
     )
 }
 
@@ -75,7 +77,7 @@
 @Inject
 constructor(
     private val interactiveScreenshotHandlerFactory: InteractiveScreenshotHandler.Factory,
-    displayRepository: DisplayRepository,
+    private val displayRepository: DisplayRepository,
     @Application private val mainScope: CoroutineScope,
     private val screenshotRequestProcessor: ScreenshotRequestProcessor,
     private val uiEventLogger: UiEventLogger,
@@ -95,31 +97,44 @@
     override suspend fun executeScreenshots(
         screenshotRequest: ScreenshotRequest,
         onSaved: (Uri?) -> Unit,
-        requestCallback: RequestCallback
+        requestCallback: RequestCallback,
     ) {
-        val displays = getDisplaysToScreenshot(screenshotRequest.type)
-        val resultCallbackWrapper = MultiResultCallbackWrapper(requestCallback)
-        if (displays.isEmpty()) {
-            Log.wtf(TAG, "No displays found for screenshot.")
-        }
-        displays.forEach { display ->
-            val displayId = display.displayId
-            var screenshotHandler: ScreenshotHandler =
-                if (displayId == Display.DEFAULT_DISPLAY) {
-                    getScreenshotController(display)
-                } else {
-                    headlessScreenshotHandler
-                }
-            Log.d(TAG, "Executing screenshot for display $displayId")
+        if (screenshotMultidisplayFocusChange()) {
+            val display = getDisplayToScreenshot(screenshotRequest)
+            val screenshotHandler = getScreenshotController(display)
             dispatchToController(
                 screenshotHandler,
-                rawScreenshotData = ScreenshotData.fromRequest(screenshotRequest, displayId),
-                onSaved =
-                    if (displayId == Display.DEFAULT_DISPLAY) {
-                        onSaved
-                    } else { _ -> },
-                callback = resultCallbackWrapper.createCallbackForId(displayId)
+                ScreenshotData.fromRequest(screenshotRequest, display.displayId),
+                onSaved,
+                requestCallback,
             )
+        } else {
+            val displays = getDisplaysToScreenshot(screenshotRequest.type)
+            val resultCallbackWrapper = MultiResultCallbackWrapper(requestCallback)
+            if (displays.isEmpty()) {
+                Log.e(TAG, "No displays found for screenshot.")
+            }
+
+            displays.forEach { display ->
+                val displayId = display.displayId
+                var screenshotHandler: ScreenshotHandler =
+                    if (displayId == Display.DEFAULT_DISPLAY) {
+                        getScreenshotController(display)
+                    } else {
+                        headlessScreenshotHandler
+                    }
+
+                Log.d(TAG, "Executing screenshot for display $displayId")
+                dispatchToController(
+                    screenshotHandler,
+                    rawScreenshotData = ScreenshotData.fromRequest(screenshotRequest, displayId),
+                    onSaved =
+                        if (displayId == Display.DEFAULT_DISPLAY) {
+                            onSaved
+                        } else { _ -> },
+                    callback = resultCallbackWrapper.createCallbackForId(displayId),
+                )
+            }
         }
     }
 
@@ -128,7 +143,7 @@
         screenshotHandler: ScreenshotHandler,
         rawScreenshotData: ScreenshotData,
         onSaved: (Uri?) -> Unit,
-        callback: RequestCallback
+        callback: RequestCallback,
     ) {
         // Let's wait before logging "screenshot requested", as we should log the processed
         // ScreenshotData.
@@ -160,13 +175,13 @@
         uiEventLogger.log(
             ScreenshotEvent.getScreenshotSource(screenshotData.source),
             0,
-            screenshotData.packageNameString
+            screenshotData.packageNameString,
         )
     }
 
     private fun onFailedScreenshotRequest(
         screenshotData: ScreenshotData,
-        callback: RequestCallback
+        callback: RequestCallback,
     ) {
         uiEventLogger.log(SCREENSHOT_CAPTURE_FAILED, 0, screenshotData.packageNameString)
         getNotificationController(screenshotData.displayId)
@@ -184,6 +199,31 @@
         }
     }
 
+    // Return the single display to be screenshot based upon the request.
+    private suspend fun getDisplayToScreenshot(screenshotRequest: ScreenshotRequest): Display {
+        return when (screenshotRequest.source) {
+            ScreenshotSource.SCREENSHOT_OVERVIEW ->
+                // Show on the display where overview was shown if available.
+                displayRepository.getDisplay(screenshotRequest.displayId)
+                    ?: displayRepository.getDisplay(Display.DEFAULT_DISPLAY)
+                    ?: error("Can't find default display")
+
+            // Key chord and vendor gesture occur on the device itself, so screenshot the device's
+            // display
+            ScreenshotSource.SCREENSHOT_KEY_CHORD,
+            ScreenshotSource.SCREENSHOT_VENDOR_GESTURE ->
+                displayRepository.getDisplay(Display.DEFAULT_DISPLAY)
+                    ?: error("Can't find default display")
+
+            // All other invocations use the focused display
+            else -> focusedDisplay()
+        }
+    }
+
+    // TODO(b/367394043): Determine the focused display here.
+    private suspend fun focusedDisplay() =
+        displayRepository.getDisplay(Display.DEFAULT_DISPLAY) ?: error("Can't find default display")
+
     /** Propagates the close system dialog signal to the ScreenshotController. */
     override fun onCloseSystemDialogsReceived() {
         if (screenshotController?.isPendingSharedTransition() == false) {
@@ -214,7 +254,7 @@
     override fun executeScreenshotsAsync(
         screenshotRequest: ScreenshotRequest,
         onSaved: Consumer<Uri?>,
-        requestCallback: RequestCallback
+        requestCallback: RequestCallback,
     ) {
         mainScope.launch {
             executeScreenshots(screenshotRequest, { uri -> onSaved.accept(uri) }, requestCallback)
@@ -235,9 +275,7 @@
      * - If any finished with an error, [reportError] of [originalCallback] is called
      * - Otherwise, [onFinish] is called.
      */
-    private class MultiResultCallbackWrapper(
-        private val originalCallback: RequestCallback,
-    ) {
+    private class MultiResultCallbackWrapper(private val originalCallback: RequestCallback) {
         private val idsPending = mutableSetOf<Int>()
         private val idsWithErrors = mutableSetOf<Int>()
 
@@ -290,7 +328,7 @@
                 Display.TYPE_EXTERNAL,
                 Display.TYPE_INTERNAL,
                 Display.TYPE_OVERLAY,
-                Display.TYPE_WIFI
+                Display.TYPE_WIFI,
             )
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java
index 4f47536..f83548d 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java
@@ -994,7 +994,9 @@
         // be dropped, causing the shade expansion to fail silently. Since the shade doesn't open,
         // it doesn't become visible, and the bounds will never update. Therefore, we must detect
         // the incorrect bounds here and force the update so that touches are routed correctly.
-        if (SceneContainerFlag.isEnabled() && mWindowRootView.getVisibility() == View.INVISIBLE) {
+        if (SceneContainerFlag.isEnabled()
+                && mWindowRootView != null
+                && mWindowRootView.getVisibility() == View.INVISIBLE) {
             Rect bounds = newConfig.windowConfiguration.getBounds();
             if (mWindowRootView.getWidth() != bounds.width()) {
                 mLogger.logConfigChangeWidthAdjust(mWindowRootView.getWidth(), bounds.width());
diff --git a/packages/SystemUI/src/com/android/systemui/shade/OWNERS b/packages/SystemUI/src/com/android/systemui/shade/OWNERS
index bbcf10b..5b82772 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/OWNERS
+++ b/packages/SystemUI/src/com/android/systemui/shade/OWNERS
@@ -13,10 +13,9 @@
 per-file *Repository* = set noparent
 per-file *Repository* = justinweir@google.com, syeonlee@google.com, nijamkin@google.com
 
-per-file NotificationShadeWindowViewController.java = pixel@google.com, cinek@google.com, juliacr@google.com
-per-file NotificationShadeWindowView.java = pixel@google.com, cinek@google.com, juliacr@google.com
+per-file NotificationShadeWindow* = pixel@google.com, cinek@google.com, juliacr@google.com, justinweir@google.com, syeonlee@google.com
 
 per-file NotificationPanelUnfoldAnimationController.kt = alexflo@google.com, jeffdq@google.com, juliacr@google.com
 
-per-file NotificationPanelView.java = pixel@google.com, cinek@google.com, juliacr@google.com, justinweir@google.com
-per-file NotificationPanelViewController.java = pixel@google.com, cinek@google.com, juliacr@google.com, justinweir@google.com
+per-file NotificationPanelView.java = pixel@google.com, cinek@google.com, juliacr@google.com, justinweir@google.com, syeonlee@google.com
+per-file NotificationPanelViewController.java = pixel@google.com, cinek@google.com, juliacr@google.com, justinweir@google.com, syeonlee@google.com
diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModel.kt
index cc6e8c2..3113dc4 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeUserActionsViewModel.kt
@@ -32,6 +32,7 @@
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filter
 
 /**
  * Models the UI state for the user actions that the user can perform to navigate to other scenes.
@@ -50,7 +51,9 @@
         combine(
                 shadeInteractor.shadeMode,
                 qsSceneAdapter.isCustomizerShowing,
-                sceneBackInteractor.backScene.map { it ?: SceneFamilies.Home },
+                sceneBackInteractor.backScene
+                    .filter { it != Scenes.Shade }
+                    .map { it ?: SceneFamilies.Home },
             ) { shadeMode, isCustomizerShowing, backScene ->
                 buildMap<UserAction, UserActionResult> {
                     if (!isCustomizerShowing) {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerImpl.java
index 7244f8a..e47952f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationLockscreenUserManagerImpl.java
@@ -62,11 +62,13 @@
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.dagger.qualifiers.Background;
 import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor;
 import com.android.systemui.dump.DumpManager;
 import com.android.systemui.flags.FeatureFlagsClassic;
 import com.android.systemui.plugins.statusbar.StatusBarStateController;
 import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener;
 import com.android.systemui.recents.OverviewProxyService;
+import com.android.systemui.scene.shared.flag.SceneContainerFlag;
 import com.android.systemui.settings.UserTracker;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection;
@@ -286,6 +288,8 @@
     protected ContentObserver mLockscreenSettingsObserver;
     protected ContentObserver mSettingsObserver;
 
+    private final Lazy<DeviceUnlockedInteractor> mDeviceUnlockedInteractorLazy;
+
     @Inject
     public NotificationLockscreenUserManagerImpl(Context context,
             BroadcastDispatcher broadcastDispatcher,
@@ -305,7 +309,8 @@
             SecureSettings secureSettings,
             DumpManager dumpManager,
             LockPatternUtils lockPatternUtils,
-            FeatureFlagsClassic featureFlags) {
+            FeatureFlagsClassic featureFlags,
+            Lazy<DeviceUnlockedInteractor> deviceUnlockedInteractorLazy) {
         mContext = context;
         mMainExecutor = mainExecutor;
         mBackgroundExecutor = backgroundExecutor;
@@ -325,6 +330,7 @@
         mSecureSettings = secureSettings;
         mKeyguardStateController = keyguardStateController;
         mFeatureFlags = featureFlags;
+        mDeviceUnlockedInteractorLazy = deviceUnlockedInteractorLazy;
 
         mLockScreenUris.add(SHOW_LOCKSCREEN);
         mLockScreenUris.add(SHOW_PRIVATE_LOCKSCREEN);
@@ -748,8 +754,13 @@
         // camera on the keyguard has a state of SHADE but the keyguard is still showing.
         final boolean showingKeyguard = mState != StatusBarState.SHADE
                 || mKeyguardStateController.isShowing();
-        final boolean devicePublic = showingKeyguard && mKeyguardStateController.isMethodSecure();
-
+        final boolean devicePublic;
+        if (SceneContainerFlag.isEnabled()) {
+            devicePublic = !mDeviceUnlockedInteractorLazy.get()
+                    .getDeviceUnlockStatus().getValue().isUnlocked();
+        } else {
+            devicePublic = showingKeyguard && mKeyguardStateController.isMethodSecure();
+        }
 
         // Look for public mode users. Users are considered public in either case of:
         //   - device keyguard is shown in secure mode;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java
index 5d14be8..73ad0e5 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/StatusBarStateControllerImpl.java
@@ -272,7 +272,7 @@
      * Updates the {@link StatusBarState} and notifies registered listeners, if needed.
      */
     private void updateStateAndNotifyListeners(int state) {
-        if (state != mUpcomingState) {
+        if (state != mUpcomingState && !SceneContainerFlag.isEnabled()) {
             Log.d(TAG, "setState: requested state " + StatusBarState.toString(state)
                     + "!= upcomingState: " + StatusBarState.toString(mUpcomingState) + ". "
                     + "This usually means the status bar state transition was interrupted before "
@@ -728,20 +728,23 @@
         // doesn't work well for clients of this class (like remote input) that expect the device to
         // be fully and properly unlocked when the state changes to SHADE.
         //
-        // Therefore, we calculate the device to be in a locked-ish state (KEYGUARD or SHADE_LOCKED,
+        // Therefore, we consider the device to be in a keyguardish state (KEYGUARD or SHADE_LOCKED,
         // but not SHADE) if *any* of these are still true:
         // 1. deviceUnlockStatus.isUnlocked is false.
-        // 2. We are on (currentScene equals) a locked-ish scene (Lockscreen, Bouncer, or Communal).
-        // 3. We are over (backStack contains) a locked-ish scene (Lockscreen or Communal).
+        // 2. currentScene is a keyguardish scene (Lockscreen, Bouncer, or Communal).
+        // 3. backStack contains a keyguardish scene (Lockscreen or Communal).
+
+        final boolean onKeyguardish = onLockscreen || onBouncer || onCommunal;
+        final boolean overKeyguardish = overLockscreen || overCommunal;
 
         if (isOccluded) {
             // Occlusion is special; even though the device is still technically on the lockscreen,
             // the UI behaves as if it is unlocked.
             newState = StatusBarState.SHADE;
-        } else if (onLockscreen || onBouncer || onCommunal || overLockscreen || overCommunal) {
-            // We get here if we are on or over a locked-ish scene, even if isUnlocked is true; we
+        } else if (onKeyguardish || overKeyguardish) {
+            // We get here if we are on or over a keyguardish scene, even if isUnlocked is true; we
             // want to return SHADE_LOCKED or KEYGUARD until we are also neither on nor over a
-            // locked-ish scene.
+            // keyguardish scene.
             if (onShade || onQuickSettings || overShade || overlaidShade || overlaidQuickSettings) {
                 newState = StatusBarState.SHADE_LOCKED;
             } else {
@@ -751,7 +754,7 @@
             newState = StatusBarState.SHADE;
         } else if (onShade || onQuickSettings) {
             // We get here if deviceUnlockStatus.isUnlocked is false but we are no longer on or over
-            // a locked-ish scene; we want to return SHADE_LOCKED until isUnlocked is also true.
+            // a keyguardish scene; we want to return SHADE_LOCKED until isUnlocked is also true.
             newState = StatusBarState.SHADE_LOCKED;
         } else {
             throw new IllegalArgumentException(
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceController.kt b/packages/SystemUI/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceController.kt
index 97add30..a24f267 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceController.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceController.kt
@@ -91,7 +91,6 @@
 constructor(
         private val context: Context,
         private val featureFlags: FeatureFlags,
-        private val smartspaceManager: SmartspaceManager?,
         private val activityStarter: ActivityStarter,
         private val falsingManager: FalsingManager,
         private val systemClock: SystemClock,
@@ -124,6 +123,7 @@
         private const val MAX_RECENT_SMARTSPACE_DATA_FOR_DUMP = 5
     }
 
+    private var userSmartspaceManager: SmartspaceManager? = null
     private var session: SmartspaceSession? = null
     private val datePlugin: BcSmartspaceDataPlugin? = optionalDatePlugin.orElse(null)
     private val weatherPlugin: BcSmartspaceDataPlugin? = optionalWeatherPlugin.orElse(null)
@@ -461,7 +461,11 @@
     }
 
     private fun connectSession() {
-        if (smartspaceManager == null) return
+        if (userSmartspaceManager == null) {
+            userSmartspaceManager =
+                userTracker.userContext.getSystemService(SmartspaceManager::class.java)
+        }
+        if (userSmartspaceManager == null) return
         if (datePlugin == null && weatherPlugin == null && plugin == null) return
         if (session != null || smartspaceViews.isEmpty()) {
             return
@@ -474,12 +478,14 @@
             return
         }
 
-        val newSession = smartspaceManager.createSmartspaceSession(
-                SmartspaceConfig.Builder(
-                        context, BcSmartspaceDataPlugin.UI_SURFACE_LOCK_SCREEN_AOD).build())
+        val newSession = userSmartspaceManager?.createSmartspaceSession(
+            SmartspaceConfig.Builder(
+                userTracker.userContext, BcSmartspaceDataPlugin.UI_SURFACE_LOCK_SCREEN_AOD
+            ).build()
+        )
         Log.d(TAG, "Starting smartspace session for " +
                 BcSmartspaceDataPlugin.UI_SURFACE_LOCK_SCREEN_AOD)
-        newSession.addOnTargetsAvailableListener(uiExecutor, sessionListener)
+        newSession?.addOnTargetsAvailableListener(uiExecutor, sessionListener)
         this.session = newSession
 
         deviceProvisionedController.removeCallback(deviceProvisionedListener)
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
index 5f4f72f..0474344 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java
@@ -594,7 +594,9 @@
     private final ColorExtractor.OnColorsChangedListener mOnColorsChangedListener =
             (extractor, which) -> updateTheme();
     private final BrightnessMirrorShowingInteractor mBrightnessMirrorShowingInteractor;
-    private final GlanceableHubContainerController mGlanceableHubContainerController;
+
+    // Only use before the scene container. Null if scene container is enabled.
+    @Nullable private final GlanceableHubContainerController mGlanceableHubContainerController;
 
     private final EmergencyGestureIntentFactory mEmergencyGestureIntentFactory;
 
@@ -807,7 +809,11 @@
         mFingerprintManager = fingerprintManager;
         mActivityStarter = activityStarter;
         mBrightnessMirrorShowingInteractor = brightnessMirrorShowingInteractor;
-        mGlanceableHubContainerController = glanceableHubContainerController;
+        if (!SceneContainerFlag.isEnabled()) {
+            mGlanceableHubContainerController = glanceableHubContainerController;
+        } else {
+            mGlanceableHubContainerController = null;
+        }
         mEmergencyGestureIntentFactory = emergencyGestureIntentFactory;
 
         mLockscreenShadeTransitionController = lockscreenShadeTransitionController;
@@ -2972,7 +2978,9 @@
 
     @Override
     public void handleCommunalHubTouch(MotionEvent event) {
-        mGlanceableHubContainerController.onTouchEvent(event);
+        if (mGlanceableHubContainerController != null) {
+            mGlanceableHubContainerController.onTouchEvent(event);
+        }
     }
 
     @Override
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java
index 1ea26e5..5ae24f7 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarKeyguardViewManager.java
@@ -63,6 +63,7 @@
 import com.android.systemui.bouncer.domain.interactor.PrimaryBouncerInteractor;
 import com.android.systemui.bouncer.shared.flag.ComposeBouncerFlags;
 import com.android.systemui.bouncer.ui.BouncerView;
+import com.android.systemui.bouncer.util.BouncerTestUtilsKt;
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.dagger.qualifiers.Main;
 import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor;
@@ -1552,8 +1553,10 @@
     }
 
     public boolean shouldDismissOnMenuPressed() {
-        return mPrimaryBouncerView.getDelegate() != null
-                && mPrimaryBouncerView.getDelegate().shouldDismissOnMenuPressed();
+        return (mPrimaryBouncerView.getDelegate() != null
+                && mPrimaryBouncerView.getDelegate().shouldDismissOnMenuPressed()) || (
+                ComposeBouncerFlags.INSTANCE.isEnabled() && BouncerTestUtilsKt.shouldEnableMenuKey(
+                        mContext.getResources()));
     }
 
     public boolean interceptMediaKey(KeyEvent event) {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallback.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallback.java
index b1754fd..200f080 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallback.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallback.java
@@ -34,11 +34,15 @@
 
 import androidx.annotation.Nullable;
 
+import com.android.compose.animation.scene.ObservableTransitionState;
 import com.android.systemui.ActivityIntentHelper;
 import com.android.systemui.dagger.SysUISingleton;
 import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor;
 import com.android.systemui.plugins.ActivityStarter;
 import com.android.systemui.plugins.statusbar.StatusBarStateController;
+import com.android.systemui.scene.domain.interactor.SceneInteractor;
+import com.android.systemui.scene.shared.flag.SceneContainerFlag;
 import com.android.systemui.shade.ShadeController;
 import com.android.systemui.statusbar.ActionClickLogger;
 import com.android.systemui.statusbar.CommandQueue;
@@ -52,6 +56,9 @@
 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout;
 import com.android.systemui.statusbar.policy.KeyguardStateController;
+import com.android.systemui.util.kotlin.JavaAdapter;
+
+import dagger.Lazy;
 
 import java.util.concurrent.Executor;
 
@@ -80,6 +87,8 @@
     private final ActionClickLogger mActionClickLogger;
     private int mDisabled2;
     protected BroadcastReceiver mChallengeReceiver = new ChallengeReceiver();
+    private final Lazy<DeviceUnlockedInteractor> mDeviceUnlockedInteractorLazy;
+    private final Lazy<SceneInteractor> mSceneInteractorLazy;
 
     /**
      */
@@ -95,7 +104,10 @@
             ShadeController shadeController,
             CommandQueue commandQueue,
             ActionClickLogger clickLogger,
-            @Main Executor executor) {
+            @Main Executor executor,
+            Lazy<DeviceUnlockedInteractor> deviceUnlockedInteractorLazy,
+            Lazy<SceneInteractor> sceneInteractorLazy,
+            JavaAdapter javaAdapter) {
         mContext = context;
         mStatusBarKeyguardViewManager = statusBarKeyguardViewManager;
         mShadeController = shadeController;
@@ -113,20 +125,28 @@
         mActionClickLogger = clickLogger;
         mActivityIntentHelper = new ActivityIntentHelper(mContext);
         mGroupExpansionManager = groupExpansionManager;
+        mDeviceUnlockedInteractorLazy = deviceUnlockedInteractorLazy;
+        mSceneInteractorLazy = sceneInteractorLazy;
+
+        if (SceneContainerFlag.isEnabled()) {
+            javaAdapter.alwaysCollectFlow(
+                    mDeviceUnlockedInteractorLazy.get().getDeviceUnlockStatus(),
+                    deviceUnlockStatus -> onStateChanged(mStatusBarStateController.getState()));
+            javaAdapter.alwaysCollectFlow(
+                    mSceneInteractorLazy.get().getTransitionState(),
+                    deviceUnlockStatus -> onStateChanged(mStatusBarStateController.getState()));
+        }
     }
 
     @Override
     public void onStateChanged(int state) {
-        boolean hasPendingRemoteInput = mPendingRemoteInputView != null;
-        if (state == StatusBarState.SHADE
-                && (mStatusBarStateController.leaveOpenOnKeyguardHide() || hasPendingRemoteInput)) {
-            if (!mStatusBarStateController.isKeyguardRequested()
-                    && mKeyguardStateController.isUnlocked()) {
-                if (hasPendingRemoteInput) {
-                    mExecutor.execute(mPendingRemoteInputView::callOnClick);
-                }
-                mPendingRemoteInputView = null;
-            }
+        if (mPendingRemoteInputView == null) {
+            return;
+        }
+
+        if (state == StatusBarState.SHADE && canRetryPendingRemoteInput()) {
+            mExecutor.execute(mPendingRemoteInputView::callOnClick);
+            mPendingRemoteInputView = null;
         }
     }
 
@@ -320,6 +340,23 @@
         }
     }
 
+    /**
+     * Returns {@code true} if it is safe to retry a pending remote input. The exact criteria for
+     * this vary depending whether the scene container is enabled.
+     */
+    private boolean canRetryPendingRemoteInput() {
+        if (SceneContainerFlag.isEnabled()) {
+            final boolean isUnlocked = mDeviceUnlockedInteractorLazy.get()
+                    .getDeviceUnlockStatus().getValue().isUnlocked();
+            final boolean isIdle = mSceneInteractorLazy.get()
+                    .getTransitionState().getValue() instanceof ObservableTransitionState.Idle;
+            return isUnlocked && isIdle;
+        } else {
+            return mKeyguardStateController.isUnlocked()
+                    && !mStatusBarStateController.isKeyguardRequested();
+        }
+    }
+
     protected class ChallengeReceiver extends BroadcastReceiver {
         @Override
         public void onReceive(Context context, Intent intent) {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/IndividualSensorPrivacyControllerImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/IndividualSensorPrivacyControllerImpl.java
index da928a3..3cf2066 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/IndividualSensorPrivacyControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/IndividualSensorPrivacyControllerImpl.java
@@ -32,6 +32,7 @@
 import androidx.annotation.NonNull;
 
 import com.android.internal.camera.flags.Flags;
+import com.android.systemui.settings.UserTracker;
 import com.android.systemui.util.ListenerSet;
 
 import java.util.Set;
@@ -41,14 +42,17 @@
     private static final int[] SENSORS = new int[] {CAMERA, MICROPHONE};
 
     private final @NonNull SensorPrivacyManager mSensorPrivacyManager;
+    private final @NonNull UserTracker mUserTracker;
     private final SparseBooleanArray mSoftwareToggleState = new SparseBooleanArray();
     private final SparseBooleanArray mHardwareToggleState = new SparseBooleanArray();
     private Boolean mRequiresAuthentication;
     private final ListenerSet<Callback> mCallbacks = new ListenerSet<>();
 
     public IndividualSensorPrivacyControllerImpl(
-            @NonNull SensorPrivacyManager sensorPrivacyManager) {
+            @NonNull SensorPrivacyManager sensorPrivacyManager,
+            @NonNull UserTracker userTracker) {
         mSensorPrivacyManager = sensorPrivacyManager;
+        mUserTracker = userTracker;
     }
 
     @Override
@@ -94,12 +98,14 @@
 
     @Override
     public void setSensorBlocked(@Source int source, @Sensor int sensor, boolean blocked) {
-        mSensorPrivacyManager.setSensorPrivacyForProfileGroup(source, sensor, blocked);
+        mSensorPrivacyManager.setSensorPrivacyForProfileGroup(source, sensor, blocked,
+                mUserTracker.getUserId());
     }
 
     @Override
     public void suppressSensorPrivacyReminders(int sensor, boolean suppress) {
-        mSensorPrivacyManager.suppressSensorPrivacyReminders(sensor, suppress);
+        mSensorPrivacyManager.suppressSensorPrivacyReminders(sensor, suppress,
+                mUserTracker.getUserId());
     }
 
     @Override
diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/JavaAdapter.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/JavaAdapter.kt
index 7c055c8..7f90242 100644
--- a/packages/SystemUI/src/com/android/systemui/util/kotlin/JavaAdapter.kt
+++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/JavaAdapter.kt
@@ -135,3 +135,15 @@
 ): Flow<R> {
     return combine(flow, flow2, flow3, flow4, flow5, transform)
 }
+
+fun <T1, T2, T3, T4, T5, T6, R> combineFlows(
+    flow: Flow<T1>,
+    flow2: Flow<T2>,
+    flow3: Flow<T3>,
+    flow4: Flow<T4>,
+    flow5: Flow<T5>,
+    flow6: Flow<T6>,
+    transform: (T1, T2, T3, T4, T5, T6) -> R,
+): Flow<R> {
+    return combine(flow, flow2, flow3, flow4, flow5, flow6, transform)
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java
index 079c72f..1f92bc1 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java
@@ -37,11 +37,8 @@
 import android.media.AudioSystem;
 import android.media.IAudioService;
 import android.media.IVolumeController;
-import android.media.MediaRoute2Info;
 import android.media.MediaRouter2Manager;
-import android.media.RoutingSessionInfo;
 import android.media.VolumePolicy;
-import android.media.session.MediaController;
 import android.media.session.MediaController.PlaybackInfo;
 import android.media.session.MediaSession.Token;
 import android.net.Uri;
@@ -88,7 +85,6 @@
 
 import java.io.PrintWriter;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
@@ -217,7 +213,7 @@
                 VolumeDialogControllerImpl.class.getSimpleName());
         mWorker = new W(mWorkerLooper);
         mRouter2Manager = MediaRouter2Manager.getInstance(mContext);
-        mMediaSessionsCallbacksW = new MediaSessionsCallbacks(mContext);
+        mMediaSessionsCallbacksW = new MediaSessionsCallbacks();
         mMediaSessions = createMediaSessions(mContext, mWorkerLooper, mMediaSessionsCallbacksW);
         mAudioSharingInteractor = audioSharingInteractor;
         mJavaAdapter = javaAdapter;
@@ -1360,16 +1356,9 @@
         private final HashMap<Token, Integer> mRemoteStreams = new HashMap<>();
 
         private int mNextStream = DYNAMIC_STREAM_REMOTE_START_INDEX;
-        private final boolean mVolumeAdjustmentForRemoteGroupSessions;
-
-        public MediaSessionsCallbacks(Context context) {
-            mVolumeAdjustmentForRemoteGroupSessions = context.getResources().getBoolean(
-                    com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions);
-        }
 
         @Override
         public void onRemoteUpdate(Token token, String name, PlaybackInfo pi) {
-            if (showForSession(token)) {
                 addStream(token, "onRemoteUpdate");
 
                 int stream = 0;
@@ -1396,12 +1385,10 @@
                     Log.d(TAG, "onRemoteUpdate: " + name + ": " + ss.level + " of " + ss.levelMax);
                     mCallbacks.onStateChanged(mState);
                 }
-            }
         }
 
         @Override
         public void onRemoteVolumeChanged(Token token, int flags) {
-            if (showForSession(token)) {
                 addStream(token, "onRemoteVolumeChanged");
                 int stream = 0;
                 synchronized (mRemoteStreams) {
@@ -1420,27 +1407,27 @@
                 if (showUI) {
                     onShowRequestedW(Events.SHOW_REASON_REMOTE_VOLUME_CHANGED);
                 }
-            }
         }
 
         @Override
         public void onRemoteRemoved(Token token) {
-            if (showForSession(token)) {
-                int stream = 0;
-                synchronized (mRemoteStreams) {
-                    if (!mRemoteStreams.containsKey(token)) {
-                        Log.d(TAG, "onRemoteRemoved: stream doesn't exist, "
-                                + "aborting remote removed for token:" + token.toString());
-                        return;
-                    }
-                    stream = mRemoteStreams.get(token);
+            int stream;
+            synchronized (mRemoteStreams) {
+                if (!mRemoteStreams.containsKey(token)) {
+                    Log.d(
+                            TAG,
+                            "onRemoteRemoved: stream doesn't exist, "
+                                    + "aborting remote removed for token:"
+                                    + token.toString());
+                    return;
                 }
-                mState.states.remove(stream);
-                if (mState.activeStream == stream) {
-                    updateActiveStreamW(-1);
-                }
-                mCallbacks.onStateChanged(mState);
+                stream = mRemoteStreams.get(token);
             }
+            mState.states.remove(stream);
+            if (mState.activeStream == stream) {
+                updateActiveStreamW(-1);
+            }
+            mCallbacks.onStateChanged(mState);
         }
 
         public void setStreamVolume(int stream, int level) {
@@ -1449,39 +1436,7 @@
                 Log.w(TAG, "setStreamVolume: No token found for stream: " + stream);
                 return;
             }
-            if (showForSession(token)) {
-                mMediaSessions.setVolume(token, level);
-            }
-        }
-
-        private boolean showForSession(Token token) {
-            if (mVolumeAdjustmentForRemoteGroupSessions) {
-                if (DEBUG) {
-                    Log.d(TAG, "Volume adjustment for remote group sessions allowed,"
-                            + " showForSession: true");
-                }
-                return true;
-            }
-            MediaController ctr = new MediaController(mContext, token);
-            String packageName = ctr.getPackageName();
-            List<RoutingSessionInfo> sessions =
-                    mRouter2Manager.getRoutingSessions(packageName);
-            if (DEBUG) {
-                Log.d(TAG, "Found " + sessions.size() + " routing sessions for package name "
-                        + packageName);
-            }
-            for (RoutingSessionInfo session : sessions) {
-                if (DEBUG) {
-                    Log.d(TAG, "Found routingSessionInfo: " + session);
-                }
-                if (!session.isSystemSession()
-                        && session.getVolumeHandling() != MediaRoute2Info.PLAYBACK_VOLUME_FIXED) {
-                    return true;
-                }
-            }
-
-            Log.d(TAG, "No routing session for " + packageName);
-            return false;
+            mMediaSessions.setVolume(token, level);
         }
 
         private Token findToken(int stream) {
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dagger/VolumeModule.java b/packages/SystemUI/src/com/android/systemui/volume/dagger/VolumeModule.java
index ebb9ce9..ed8de69 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dagger/VolumeModule.java
+++ b/packages/SystemUI/src/com/android/systemui/volume/dagger/VolumeModule.java
@@ -23,6 +23,7 @@
 
 import com.android.internal.jank.InteractionJankMonitor;
 import com.android.systemui.CoreStartable;
+import com.android.systemui.Flags;
 import com.android.systemui.dump.DumpManager;
 import com.android.systemui.media.dialog.MediaOutputDialogManager;
 import com.android.systemui.plugins.VolumeDialog;
@@ -40,6 +41,8 @@
 import com.android.systemui.volume.VolumeDialogImpl;
 import com.android.systemui.volume.VolumePanelDialogReceiver;
 import com.android.systemui.volume.VolumeUI;
+import com.android.systemui.volume.dialog.VolumeDialogPlugin;
+import com.android.systemui.volume.dialog.dagger.VolumeDialogPluginComponent;
 import com.android.systemui.volume.domain.interactor.VolumeDialogInteractor;
 import com.android.systemui.volume.domain.interactor.VolumePanelNavigationInteractor;
 import com.android.systemui.volume.panel.dagger.VolumePanelComponent;
@@ -66,7 +69,8 @@
                 SpatializerModule.class,
         },
         subcomponents = {
-                VolumePanelComponent.class
+                VolumePanelComponent.class,
+                VolumeDialogPluginComponent.class,
         }
 )
 public interface VolumeModule {
@@ -101,6 +105,7 @@
     /**  */
     @Provides
     static VolumeDialog provideVolumeDialog(
+            Lazy<VolumeDialogPlugin> volumeDialogProvider,
             Context context,
             VolumeDialogController volumeDialogController,
             AccessibilityManagerWrapper accessibilityManagerWrapper,
@@ -118,29 +123,33 @@
             VibratorHelper vibratorHelper,
             SystemClock systemClock,
             VolumeDialogInteractor interactor) {
-        VolumeDialogImpl impl = new VolumeDialogImpl(
-                context,
-                volumeDialogController,
-                accessibilityManagerWrapper,
-                deviceProvisionedController,
-                configurationController,
-                mediaOutputDialogManager,
-                interactionJankMonitor,
-                volumePanelNavigationInteractor,
-                volumeNavigator,
-                true, /* should listen for jank */
-                csdFactory,
-                devicePostureController,
-                Looper.getMainLooper(),
-                volumePanelFlag,
-                dumpManager,
-                secureSettings,
-                vibratorHelper,
-                systemClock,
-                interactor);
-        impl.setStreamImportant(AudioManager.STREAM_SYSTEM, false);
-        impl.setAutomute(true);
-        impl.setSilentMode(false);
-        return impl;
+        if (Flags.volumeRedesign()) {
+            return volumeDialogProvider.get();
+        } else {
+            VolumeDialogImpl impl = new VolumeDialogImpl(
+                    context,
+                    volumeDialogController,
+                    accessibilityManagerWrapper,
+                    deviceProvisionedController,
+                    configurationController,
+                    mediaOutputDialogManager,
+                    interactionJankMonitor,
+                    volumePanelNavigationInteractor,
+                    volumeNavigator,
+                    true, /* should listen for jank */
+                    csdFactory,
+                    devicePostureController,
+                    Looper.getMainLooper(),
+                    volumePanelFlag,
+                    dumpManager,
+                    secureSettings,
+                    vibratorHelper,
+                    systemClock,
+                    interactor);
+            impl.setStreamImportant(AudioManager.STREAM_SYSTEM, false);
+            impl.setAutomute(true);
+            impl.setSilentMode(false);
+            return impl;
+        }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/NewVolumeDialog.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/NewVolumeDialog.kt
new file mode 100644
index 0000000..869b3c6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/NewVolumeDialog.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.systemui.volume.dialog
+
+import android.app.Dialog
+import android.content.Context
+import android.os.Bundle
+import android.view.ContextThemeWrapper
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.res.R
+import javax.inject.Inject
+
+class NewVolumeDialog @Inject constructor(@Application context: Context) :
+    Dialog(ContextThemeWrapper(context, R.style.volume_dialog_theme)) {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.volume_dialog)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/NewVolumeDialogPlugin.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/NewVolumeDialogPlugin.kt
new file mode 100644
index 0000000..b93714a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/NewVolumeDialogPlugin.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.systemui.volume.dialog
+
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.plugins.VolumeDialog
+import com.android.systemui.volume.dialog.dagger.VolumeDialogComponent
+import com.android.systemui.volume.dialog.dagger.VolumeDialogPluginComponent
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+class NewVolumeDialogPlugin
+@Inject
+constructor(
+    @Application private val applicationCoroutineScope: CoroutineScope,
+    private val volumeDialogPluginComponentFactory: VolumeDialogPluginComponent.Factory,
+) : VolumeDialog {
+
+    private var volumeDialogPluginComponent: VolumeDialogPluginComponent? = null
+    private var job: Job? = null
+
+    override fun init(windowType: Int, callback: VolumeDialog.Callback?) {
+        job =
+            applicationCoroutineScope.launch {
+                coroutineScope {
+                    volumeDialogPluginComponent = volumeDialogPluginComponentFactory.create(this)
+                }
+            }
+    }
+
+    private fun showDialog() {
+        val volumeDialogPluginComponent =
+            volumeDialogPluginComponent ?: error("Creating dialog before init was called")
+        volumeDialogPluginComponent.coroutineScope().launch {
+            coroutineScope {
+                val volumeDialogComponent: VolumeDialogComponent =
+                    volumeDialogPluginComponent.volumeDialogComponentFactory().create(this)
+                with(volumeDialogComponent.volumeDialog()) {
+                    setOnDismissListener { volumeDialogComponent.coroutineScope().cancel() }
+                    show()
+                }
+            }
+        }
+    }
+
+    override fun destroy() {
+        job?.cancel()
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/VolumeDialog.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/VolumeDialog.kt
new file mode 100644
index 0000000..74e823e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/VolumeDialog.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.systemui.volume.dialog
+
+import android.app.Dialog
+import android.content.Context
+import android.os.Bundle
+import android.view.ContextThemeWrapper
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.res.R
+import javax.inject.Inject
+
+class VolumeDialog @Inject constructor(@Application context: Context) :
+    Dialog(ContextThemeWrapper(context, R.style.volume_dialog_theme)) {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.volume_dialog)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/VolumeDialogPlugin.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/VolumeDialogPlugin.kt
new file mode 100644
index 0000000..a2e81d9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/VolumeDialogPlugin.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.systemui.volume.dialog
+
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.plugins.VolumeDialog
+import com.android.systemui.volume.dialog.dagger.VolumeDialogComponent
+import com.android.systemui.volume.dialog.dagger.VolumeDialogPluginComponent
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+class VolumeDialogPlugin
+@Inject
+constructor(
+    @Application private val applicationCoroutineScope: CoroutineScope,
+    private val volumeDialogPluginComponentFactory: VolumeDialogPluginComponent.Factory,
+) : VolumeDialog {
+
+    private var volumeDialogPluginComponent: VolumeDialogPluginComponent? = null
+    private var job: Job? = null
+
+    override fun init(windowType: Int, callback: VolumeDialog.Callback?) {
+        job =
+            applicationCoroutineScope.launch {
+                coroutineScope {
+                    volumeDialogPluginComponent = volumeDialogPluginComponentFactory.create(this)
+                }
+            }
+    }
+
+    private fun showDialog() {
+        val volumeDialogPluginComponent =
+            volumeDialogPluginComponent ?: error("Creating dialog before init was called")
+        volumeDialogPluginComponent.coroutineScope().launch {
+            coroutineScope {
+                val volumeDialogComponent: VolumeDialogComponent =
+                    volumeDialogPluginComponent.volumeDialogComponentFactory().create(this)
+                with(volumeDialogComponent.volumeDialog()) {
+                    setOnDismissListener { volumeDialogComponent.coroutineScope().cancel() }
+                    show()
+                }
+            }
+        }
+    }
+
+    override fun destroy() {
+        job?.cancel()
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/VolumeDialogComponent.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/VolumeDialogComponent.kt
new file mode 100644
index 0000000..f7ad320
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/VolumeDialogComponent.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.systemui.volume.dialog.dagger
+
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialog
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
+import dagger.BindsInstance
+import dagger.Subcomponent
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * Core Volume Dialog dagger component. It's managed by
+ * [com.android.systemui.volume.dialog.VolumeDialogPlugin] and lives alongside it.
+ */
+@VolumeDialogScope
+@Subcomponent(modules = [])
+interface VolumeDialogComponent {
+
+    /**
+     * Provides a coroutine scope to use inside [VolumeDialogScope].
+     * [com.android.systemui.volume.dialog.VolumeDialogPlugin] manages the lifecycle of this scope.
+     * It's cancelled when the dialog is disposed. This helps to free occupied resources when volume
+     * dialog is not shown.
+     */
+    @VolumeDialog fun coroutineScope(): CoroutineScope
+
+    @VolumeDialogScope fun volumeDialog(): com.android.systemui.volume.dialog.VolumeDialog
+
+    @Subcomponent.Factory
+    interface Factory {
+
+        fun create(@BindsInstance @VolumeDialog scope: CoroutineScope): VolumeDialogComponent
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/VolumeDialogPluginComponent.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/VolumeDialogPluginComponent.kt
new file mode 100644
index 0000000..82612a7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/VolumeDialogPluginComponent.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.systemui.volume.dialog.dagger
+
+import com.android.systemui.volume.dialog.dagger.module.VolumeDialogPluginModule
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPlugin
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPluginScope
+import dagger.BindsInstance
+import dagger.Subcomponent
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * Volume Dialog plugin dagger component. It's managed by
+ * [com.android.systemui.volume.dialog.VolumeDialogPlugin] and lives alongside it.
+ */
+@VolumeDialogPluginScope
+@Subcomponent(modules = [VolumeDialogPluginModule::class])
+interface VolumeDialogPluginComponent {
+
+    /**
+     * Provides a coroutine scope to use inside [VolumeDialogPluginScope].
+     * [com.android.systemui.volume.dialog.VolumeDialogPlugin] manages the lifecycle of this scope.
+     * It's cancelled when the dialog is disposed. This helps to free occupied resources when volume
+     * dialog is not shown.
+     */
+    @VolumeDialogPlugin fun coroutineScope(): CoroutineScope
+
+    fun volumeDialogComponentFactory(): VolumeDialogComponent.Factory
+
+    @Subcomponent.Factory
+    interface Factory {
+
+        fun create(
+            @BindsInstance @VolumeDialogPlugin scope: CoroutineScope
+        ): VolumeDialogPluginComponent
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt
new file mode 100644
index 0000000..3fdf86a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.systemui.volume.dialog.dagger.module
+
+import com.android.systemui.volume.dialog.dagger.VolumeDialogComponent
+import dagger.Module
+
+@Module(subcomponents = [VolumeDialogComponent::class]) interface VolumeDialogPluginModule
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialog.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialog.kt
new file mode 100644
index 0000000..34bddb4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialog.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.systemui.volume.dialog.dagger.scope
+
+import javax.inject.Qualifier
+
+/**
+ * Volume Dialog qualifier.
+ *
+ * @see VolumeDialogScope
+ */
+@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class VolumeDialog
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogPlugin.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogPlugin.kt
new file mode 100644
index 0000000..1038c30
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogPlugin.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.systemui.volume.dialog.dagger.scope
+
+import javax.inject.Qualifier
+
+/**
+ * Volume Dialog plugin qualifier.
+ *
+ * @see VolumeDialogPluginScope
+ */
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class VolumeDialogPlugin
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogPluginScope.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogPluginScope.kt
new file mode 100644
index 0000000..6c5f672
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogPluginScope.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.systemui.volume.dialog.dagger.scope
+
+import javax.inject.Scope
+
+/**
+ * Volume Dialog plugin dependency injection scope. This scope is created alongside Volume Dialog
+ * plugin is initialized and destroyed alongside it. This is effectively almost similar
+ * to @Application now.
+ */
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+@Scope
+annotation class VolumeDialogPluginScope
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogScope.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogScope.kt
new file mode 100644
index 0000000..52caa6a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/scope/VolumeDialogScope.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.systemui.volume.dialog.dagger.scope
+
+import javax.inject.Scope
+
+/**
+ * Volume Panel dependency injection scope. This scope is created alongside Volume Panel and
+ * destroyed when it's lo longer present.
+ */
+@MustBeDocumented @Retention(AnnotationRetention.RUNTIME) @Scope annotation class VolumeDialogScope
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogCallbacksInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogCallbacksInteractor.kt
new file mode 100644
index 0000000..ec7c6ce
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogCallbacksInteractor.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.systemui.volume.dialog.domain.interactor
+
+import android.annotation.SuppressLint
+import android.os.Handler
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.plugins.VolumeDialogController
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialog
+import com.android.systemui.volume.dialog.domain.model.VolumeDialogEventModel
+import com.android.systemui.volume.dialog.domain.model.VolumeDialogStateModel
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.ProducerScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.shareIn
+
+private const val BUFFER_CAPACITY = 16
+
+/**
+ * Exposes [VolumeDialogController] callback events in the [event].
+ *
+ * @see VolumeDialogController.Callbacks
+ */
+@VolumeDialog
+class VolumeDialogCallbacksInteractor
+@Inject
+constructor(
+    private val volumeDialogController: VolumeDialogController,
+    @VolumeDialog private val coroutineScope: CoroutineScope,
+    @Background private val bgHandler: Handler,
+) {
+
+    @SuppressLint("SharedFlowCreation") // event-but needed
+    val event: Flow<VolumeDialogEventModel> =
+        callbackFlow {
+                val producer = VolumeDialogEventModelProducer(this)
+                volumeDialogController.addCallback(producer, bgHandler)
+                awaitClose { volumeDialogController.removeCallback(producer) }
+            }
+            .buffer(BUFFER_CAPACITY)
+            .shareIn(replay = 0, scope = coroutineScope, started = SharingStarted.WhileSubscribed())
+
+    private class VolumeDialogEventModelProducer(
+        private val scope: ProducerScope<VolumeDialogEventModel>
+    ) : VolumeDialogController.Callbacks {
+        override fun onShowRequested(reason: Int, keyguardLocked: Boolean, lockTaskModeState: Int) {
+            scope.trySend(
+                VolumeDialogEventModel.ShowRequested(
+                    reason = reason,
+                    keyguardLocked = keyguardLocked,
+                    lockTaskModeState = lockTaskModeState,
+                )
+            )
+        }
+
+        override fun onDismissRequested(reason: Int) {
+            scope.trySend(VolumeDialogEventModel.DismissRequested(reason))
+        }
+
+        override fun onStateChanged(state: VolumeDialogController.State?) {
+            if (state != null) {
+                scope.trySend(VolumeDialogEventModel.StateChanged(VolumeDialogStateModel(state)))
+            }
+        }
+
+        override fun onLayoutDirectionChanged(layoutDirection: Int) {
+            scope.trySend(VolumeDialogEventModel.LayoutDirectionChanged(layoutDirection))
+        }
+
+        // Configuration change is never emitted by the VolumeDialogControllerImpl now.
+        override fun onConfigurationChanged() = Unit
+
+        override fun onShowVibrateHint() {
+            scope.trySend(VolumeDialogEventModel.ShowVibrateHint)
+        }
+
+        override fun onShowSilentHint() {
+            scope.trySend(VolumeDialogEventModel.ShowSilentHint)
+        }
+
+        override fun onScreenOff() {
+            scope.trySend(VolumeDialogEventModel.ScreenOff)
+        }
+
+        override fun onShowSafetyWarning(flags: Int) {
+            scope.trySend(VolumeDialogEventModel.ShowSafetyWarning(flags))
+        }
+
+        override fun onAccessibilityModeChanged(showA11yStream: Boolean) {
+            scope.trySend(VolumeDialogEventModel.AccessibilityModeChanged(showA11yStream))
+        }
+
+        // Captions button is remove from the Volume Dialog
+        override fun onCaptionComponentStateChanged(
+            isComponentEnabled: Boolean,
+            fromTooltip: Boolean,
+        ) = Unit
+
+        // Captions button is remove from the Volume Dialog
+        override fun onCaptionEnabledStateChanged(isEnabled: Boolean, checkBeforeSwitch: Boolean) =
+            Unit
+
+        override fun onShowCsdWarning(csdWarning: Int, durationMs: Int) {
+            scope.trySend(
+                VolumeDialogEventModel.ShowCsdWarning(
+                    csdWarning = csdWarning,
+                    durationMs = durationMs,
+                )
+            )
+        }
+
+        override fun onVolumeChangedFromKey() {
+            scope.trySend(VolumeDialogEventModel.VolumeChangedFromKey)
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogStateInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogStateInteractor.kt
new file mode 100644
index 0000000..dd51108
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogStateInteractor.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.systemui.volume.dialog.domain.interactor
+
+import com.android.systemui.plugins.VolumeDialogController
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialog
+import com.android.systemui.volume.dialog.domain.model.VolumeDialogEventModel
+import com.android.systemui.volume.dialog.domain.model.VolumeDialogStateModel
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+
+/**
+ * Exposes [VolumeDialogController.getState] in the [volumeDialogState].
+ *
+ * @see [VolumeDialogController]
+ */
+@VolumeDialog
+class VolumeDialogStateInteractor
+@Inject
+constructor(
+    volumeDialogCallbacksInteractor: VolumeDialogCallbacksInteractor,
+    private val volumeDialogController: VolumeDialogController,
+    @VolumeDialog private val coroutineScope: CoroutineScope,
+) {
+
+    val volumeDialogState: Flow<VolumeDialogStateModel> =
+        volumeDialogCallbacksInteractor.event
+            .onStart { volumeDialogController.getState() }
+            .filterIsInstance(VolumeDialogEventModel.StateChanged::class)
+            .map { it.state }
+            .stateIn(scope = coroutineScope, started = SharingStarted.Eagerly, initialValue = null)
+            .filterNotNull()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogEventModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogEventModel.kt
new file mode 100644
index 0000000..ca0310e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogEventModel.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.systemui.volume.dialog.domain.model
+
+import android.media.AudioManager
+
+/**
+ * Models VolumeDialogController callback events.
+ *
+ * @see VolumeDialogController.Callbacks
+ */
+sealed interface VolumeDialogEventModel {
+
+    data class ShowRequested(
+        val reason: Int,
+        val keyguardLocked: Boolean,
+        val lockTaskModeState: Int,
+    ) : VolumeDialogEventModel
+
+    data class DismissRequested(val reason: Int) : VolumeDialogEventModel
+
+    data class StateChanged(val state: VolumeDialogStateModel) : VolumeDialogEventModel
+
+    data class LayoutDirectionChanged(val layoutDirection: Int) : VolumeDialogEventModel
+
+    data object ShowVibrateHint : VolumeDialogEventModel
+
+    data object ShowSilentHint : VolumeDialogEventModel
+
+    data object ScreenOff : VolumeDialogEventModel
+
+    data class ShowSafetyWarning(val flags: Int) : VolumeDialogEventModel
+
+    data class AccessibilityModeChanged(val showA11yStream: Boolean) : VolumeDialogEventModel
+
+    data class ShowCsdWarning(@AudioManager.CsdWarning val csdWarning: Int, val durationMs: Int) :
+        VolumeDialogEventModel
+
+    data object VolumeChangedFromKey : VolumeDialogEventModel
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogStateModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogStateModel.kt
new file mode 100644
index 0000000..f1443e3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogStateModel.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.systemui.volume.dialog.domain.model
+
+import android.content.ComponentName
+import android.util.SparseArray
+import androidx.core.util.keyIterator
+import com.android.systemui.plugins.VolumeDialogController
+
+/** Models a state of the Volume Dialog. */
+data class VolumeDialogStateModel(
+    val states: Map<Int, VolumeDialogStreamStateModel>,
+    val ringerModeInternal: Int = 0,
+    val ringerModeExternal: Int = 0,
+    val zenMode: Int = 0,
+    val effectsSuppressor: ComponentName? = null,
+    val effectsSuppressorName: String? = null,
+    val activeStream: Int = NO_ACTIVE_STREAM,
+    val disallowAlarms: Boolean = false,
+    val disallowMedia: Boolean = false,
+    val disallowSystem: Boolean = false,
+    val disallowRinger: Boolean = false,
+) {
+
+    constructor(
+        legacyState: VolumeDialogController.State
+    ) : this(
+        states = legacyState.states.mapToMap { VolumeDialogStreamStateModel(it) },
+        ringerModeInternal = legacyState.ringerModeInternal,
+        ringerModeExternal = legacyState.ringerModeExternal,
+        zenMode = legacyState.zenMode,
+        effectsSuppressor = legacyState.effectsSuppressor,
+        effectsSuppressorName = legacyState.effectsSuppressorName,
+        activeStream = legacyState.activeStream,
+        disallowAlarms = legacyState.disallowAlarms,
+        disallowMedia = legacyState.disallowMedia,
+        disallowSystem = legacyState.disallowSystem,
+        disallowRinger = legacyState.disallowRinger,
+    )
+
+    companion object {
+        const val NO_ACTIVE_STREAM: Int = -1
+    }
+}
+
+private fun <INPUT, OUTPUT> SparseArray<INPUT>.mapToMap(map: (INPUT) -> OUTPUT): Map<Int, OUTPUT> {
+    val resultMap = mutableMapOf<Int, OUTPUT>()
+    for (key in keyIterator()) {
+        val mappedValue: OUTPUT = map(get(key)!!)
+        resultMap[key] = mappedValue
+    }
+    return resultMap
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogStreamStateModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogStreamStateModel.kt
new file mode 100644
index 0000000..a9d367d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogStreamStateModel.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.systemui.volume.dialog.domain.model
+
+import android.annotation.IntegerRes
+import com.android.systemui.plugins.VolumeDialogController
+
+/** Models a state of an audio stream of the Volume Dialog. */
+data class VolumeDialogStreamStateModel(
+    val isDynamic: Boolean = false,
+    val level: Int = 0,
+    val levelMin: Int = 0,
+    val levelMax: Int = 0,
+    val muted: Boolean = false,
+    val muteSupported: Boolean = false,
+    @IntegerRes val name: Int = 0,
+    val remoteLabel: String? = null,
+    val routedToBluetooth: Boolean = false,
+) {
+    constructor(
+        legacyState: VolumeDialogController.StreamState
+    ) : this(
+        isDynamic = legacyState.dynamic,
+        level = legacyState.level,
+        levelMin = legacyState.levelMin,
+        levelMax = legacyState.levelMax,
+        muted = legacyState.muted,
+        muteSupported = legacyState.muteSupported,
+        name = legacyState.name,
+        remoteLabel = legacyState.remoteLabel,
+        routedToBluetooth = legacyState.routedToBluetooth,
+    )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt
new file mode 100644
index 0000000..700225d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.systemui.volume.dialog.ui.binder
+
+import android.view.View
+import com.android.systemui.lifecycle.WindowLifecycleState
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.lifecycle.setSnapshotBinding
+import com.android.systemui.lifecycle.viewModel
+import com.android.systemui.volume.dialog.ui.viewmodel.VolumeDialogViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.awaitCancellation
+
+class VolumeDialogViewBinder
+@Inject
+constructor(private val volumeDialogViewModelFactory: VolumeDialogViewModel.Factory) {
+
+    suspend fun bind(view: View) {
+        view.repeatWhenAttached {
+            view.viewModel(
+                traceName = "VolumeDialogViewBinder",
+                minWindowLifecycleState = WindowLifecycleState.ATTACHED,
+                factory = { volumeDialogViewModelFactory.create() },
+            ) { viewModel ->
+                view.setSnapshotBinding {}
+
+                awaitCancellation()
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt
new file mode 100644
index 0000000..f9e91ae
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.systemui.volume.dialog.ui.viewmodel
+
+import com.android.systemui.lifecycle.ExclusiveActivatable
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+class VolumeDialogViewModel @AssistedInject constructor() : ExclusiveActivatable() {
+
+    override suspend fun onActivated(): Nothing {
+        TODO("Not yet implemented")
+    }
+
+    @AssistedFactory
+    interface Factory {
+        fun create(): VolumeDialogViewModel
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/keyguard/KeyguardUpdateMonitorTest.java b/packages/SystemUI/tests/src/com/android/keyguard/KeyguardUpdateMonitorTest.java
index e609d5f..34ebba8 100644
--- a/packages/SystemUI/tests/src/com/android/keyguard/KeyguardUpdateMonitorTest.java
+++ b/packages/SystemUI/tests/src/com/android/keyguard/KeyguardUpdateMonitorTest.java
@@ -127,6 +127,7 @@
 import com.android.internal.widget.LockPatternUtils;
 import com.android.keyguard.KeyguardUpdateMonitor.BiometricAuthenticated;
 import com.android.keyguard.logging.KeyguardUpdateMonitorLogger;
+import com.android.keyguard.logging.SimLogger;
 import com.android.settingslib.fuelgauge.BatteryStatus;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.biometrics.AuthController;
@@ -267,6 +268,8 @@
     @Mock
     private KeyguardUpdateMonitorLogger mKeyguardUpdateMonitorLogger;
     @Mock
+    private SimLogger mSimLogger;
+    @Mock
     private SessionTracker mSessionTracker;
     @Mock
     private UiEventLogger mUiEventLogger;
@@ -2234,6 +2237,36 @@
     }
 
     @Test
+    public void testOnSimStateChanged_LockedToNotReadyToLocked() {
+        int validSubId = 10;
+        int slotId = 0;
+
+        KeyguardUpdateMonitorCallback keyguardUpdateMonitorCallback = spy(
+                KeyguardUpdateMonitorCallback.class);
+        mKeyguardUpdateMonitor.registerCallback(keyguardUpdateMonitorCallback);
+        // Initially locked
+        mKeyguardUpdateMonitor.handleSimStateChange(validSubId, slotId,
+                TelephonyManager.SIM_STATE_PIN_REQUIRED);
+        verify(keyguardUpdateMonitorCallback).onSimStateChanged(validSubId, slotId,
+                TelephonyManager.SIM_STATE_PIN_REQUIRED);
+
+        reset(keyguardUpdateMonitorCallback);
+        // Not ready, with invalid sub id
+        mKeyguardUpdateMonitor.handleSimStateChange(SubscriptionManager.INVALID_SUBSCRIPTION_ID,
+                slotId, TelephonyManager.SIM_STATE_NOT_READY);
+        verify(keyguardUpdateMonitorCallback).onSimStateChanged(
+                SubscriptionManager.INVALID_SUBSCRIPTION_ID, slotId,
+                TelephonyManager.SIM_STATE_NOT_READY);
+
+        reset(keyguardUpdateMonitorCallback);
+        // Back to PIN required, which notifies listeners
+        mKeyguardUpdateMonitor.handleSimStateChange(validSubId, slotId,
+                TelephonyManager.SIM_STATE_PIN_REQUIRED);
+        verify(keyguardUpdateMonitorCallback).onSimStateChanged(validSubId, slotId,
+                TelephonyManager.SIM_STATE_PIN_REQUIRED);
+    }
+
+    @Test
     public void onAuthEnrollmentChangesCallbacksAreNotified() {
         KeyguardUpdateMonitorCallback callback = mock(KeyguardUpdateMonitorCallback.class);
         ArgumentCaptor<AuthController.Callback> authCallback = ArgumentCaptor.forClass(
@@ -2487,7 +2520,7 @@
                     mStatusBarStateController, mLockPatternUtils,
                     mAuthController, mTelephonyListenerManager,
                     mInteractionJankMonitor, mLatencyTracker, mActiveUnlockConfig,
-                    mKeyguardUpdateMonitorLogger, mUiEventLogger, () -> mSessionTracker,
+                    mKeyguardUpdateMonitorLogger, mSimLogger, mUiEventLogger, () -> mSessionTracker,
                     mTrustManager, mSubscriptionManager, mUserManager,
                     mDreamManager, mDevicePolicyManager, mSensorPrivacyManager, mTelephonyManager,
                     mPackageManager, mFingerprintManager, mBiometricManager,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java
index 5fc1971..8075d11 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/clipboardoverlay/ClipboardOverlayControllerTest.java
@@ -104,6 +104,8 @@
 
     @Mock
     private Animator mAnimator;
+    @Mock
+    private Animator mEndAnimator;
     private ArgumentCaptor<Animator.AnimatorListener> mAnimatorListenerCaptor =
             ArgumentCaptor.forClass(Animator.AnimatorListener.class);
 
@@ -123,7 +125,7 @@
         MockitoAnnotations.initMocks(this);
 
         when(mClipboardOverlayView.getEnterAnimation()).thenReturn(mAnimator);
-        when(mClipboardOverlayView.getExitAnimation()).thenReturn(mAnimator);
+        when(mClipboardOverlayView.getExitAnimation()).thenReturn(mEndAnimator);
         when(mClipboardOverlayView.getFadeOutAnimation()).thenReturn(mAnimator);
         when(mClipboardOverlayWindow.getWindowInsets()).thenReturn(
                 getImeInsets(new Rect(0, 0, 0, 0)));
@@ -318,11 +320,11 @@
         mOverlayController.setClipData(mSampleClipData, "");
 
         mCallbacks.onShareButtonTapped();
-        verify(mAnimator).addListener(mAnimatorListenerCaptor.capture());
-        mAnimatorListenerCaptor.getValue().onAnimationEnd(mAnimator);
+        verify(mEndAnimator).addListener(mAnimatorListenerCaptor.capture());
+        mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator);
 
         verify(mUiEventLogger, times(1)).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "");
-        verify(mClipboardOverlayView, times(1)).getFadeOutAnimation();
+        verify(mClipboardOverlayView, times(1)).getExitAnimation();
     }
 
     @Test
@@ -343,8 +345,8 @@
         initController();
 
         mCallbacks.onDismissButtonTapped();
-        verify(mAnimator).addListener(mAnimatorListenerCaptor.capture());
-        mAnimatorListenerCaptor.getValue().onAnimationEnd(mAnimator);
+        verify(mEndAnimator).addListener(mAnimatorListenerCaptor.capture());
+        mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator);
 
         // package name is null since we haven't actually set a source for this test
         verify(mUiEventLogger, times(1)).log(CLIPBOARD_OVERLAY_DISMISS_TAPPED, 0, null);
@@ -403,14 +405,18 @@
 
         mOverlayController.setClipData(mSampleClipData, "first.package");
         mCallbacks.onShareButtonTapped();
+        verify(mEndAnimator).addListener(mAnimatorListenerCaptor.capture());
+        mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator);
 
         mOverlayController.setClipData(mSampleClipData, "second.package");
         mCallbacks.onShareButtonTapped();
+        verify(mEndAnimator, times(2)).addListener(mAnimatorListenerCaptor.capture());
+        mAnimatorListenerCaptor.getValue().onAnimationEnd(mEndAnimator);
 
-        verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "first.package");
-        verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "second.package");
         verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHOWN_EXPANDED, 0, "first.package");
+        verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "first.package");
         verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHOWN_EXPANDED, 0, "second.package");
+        verify(mUiEventLogger).log(CLIPBOARD_OVERLAY_SHARE_TAPPED, 0, "second.package");
         verifyNoMoreInteractions(mUiEventLogger);
     }
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/communal/data/db/CommunalDatabaseMigrationsTest.kt b/packages/SystemUI/tests/src/com/android/systemui/communal/data/db/CommunalDatabaseMigrationsTest.kt
index ad25502..7d5a334 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/communal/data/db/CommunalDatabaseMigrationsTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/communal/data/db/CommunalDatabaseMigrationsTest.kt
@@ -148,6 +148,31 @@
         )
     }
 
+    @Test
+    fun migrate3To4_addSpanYColumn_defaultValuePopulated() {
+        val databaseV3 = migrationTestHelper.createDatabase(DATABASE_NAME, version = 3)
+
+        val fakeWidgetsV3 =
+            listOf(
+                FakeCommunalWidgetItemV3(1, "test_widget_1", 11, 0),
+                FakeCommunalWidgetItemV3(2, "test_widget_2", 12, 10),
+                FakeCommunalWidgetItemV3(3, "test_widget_3", 13, 0),
+            )
+        databaseV3.insertWidgetsV3(fakeWidgetsV3)
+
+        databaseV3.verifyWidgetsV3(fakeWidgetsV3)
+
+        val databaseV4 =
+            migrationTestHelper.runMigrationsAndValidate(
+                name = DATABASE_NAME,
+                version = 4,
+                validateDroppedTables = false,
+                CommunalDatabase.MIGRATION_3_4,
+            )
+
+        databaseV4.verifyWidgetsV4(fakeWidgetsV3.map { it.getV4() })
+    }
+
     private fun SupportSQLiteDatabase.insertWidgetsV1(widgets: List<FakeCommunalWidgetItemV1>) {
         widgets.forEach { widget ->
             execSQL(
@@ -157,6 +182,22 @@
         }
     }
 
+    private fun SupportSQLiteDatabase.insertWidgetsV3(widgets: List<FakeCommunalWidgetItemV3>) {
+        widgets.forEach { widget ->
+            execSQL(
+                "INSERT INTO communal_widget_table(" +
+                    "widget_id, " +
+                    "component_name, " +
+                    "item_id, " +
+                    "user_serial_number) " +
+                    "VALUES(${widget.widgetId}, " +
+                    "'${widget.componentName}', " +
+                    "${widget.itemId}, " +
+                    "${widget.userSerialNumber})"
+            )
+        }
+    }
+
     private fun SupportSQLiteDatabase.verifyWidgetsV1(widgets: List<FakeCommunalWidgetItemV1>) {
         val cursor = query("SELECT * FROM communal_widget_table")
         assertThat(cursor.moveToFirst()).isTrue()
@@ -193,6 +234,42 @@
         assertThat(cursor.isAfterLast).isTrue()
     }
 
+    private fun SupportSQLiteDatabase.verifyWidgetsV3(widgets: List<FakeCommunalWidgetItemV3>) {
+        val cursor = query("SELECT * FROM communal_widget_table")
+        assertThat(cursor.moveToFirst()).isTrue()
+
+        widgets.forEach { widget ->
+            assertThat(cursor.getInt(cursor.getColumnIndex("widget_id"))).isEqualTo(widget.widgetId)
+            assertThat(cursor.getString(cursor.getColumnIndex("component_name")))
+                .isEqualTo(widget.componentName)
+            assertThat(cursor.getInt(cursor.getColumnIndex("item_id"))).isEqualTo(widget.itemId)
+            assertThat(cursor.getInt(cursor.getColumnIndex("user_serial_number")))
+                .isEqualTo(widget.userSerialNumber)
+
+            cursor.moveToNext()
+        }
+        assertThat(cursor.isAfterLast).isTrue()
+    }
+
+    private fun SupportSQLiteDatabase.verifyWidgetsV4(widgets: List<FakeCommunalWidgetItemV4>) {
+        val cursor = query("SELECT * FROM communal_widget_table")
+        assertThat(cursor.moveToFirst()).isTrue()
+
+        widgets.forEach { widget ->
+            assertThat(cursor.getInt(cursor.getColumnIndex("widget_id"))).isEqualTo(widget.widgetId)
+            assertThat(cursor.getString(cursor.getColumnIndex("component_name")))
+                .isEqualTo(widget.componentName)
+            assertThat(cursor.getInt(cursor.getColumnIndex("item_id"))).isEqualTo(widget.itemId)
+            assertThat(cursor.getInt(cursor.getColumnIndex("user_serial_number")))
+                .isEqualTo(widget.userSerialNumber)
+            assertThat(cursor.getInt(cursor.getColumnIndex("span_y"))).isEqualTo(widget.spanY)
+
+            cursor.moveToNext()
+        }
+
+        assertThat(cursor.isAfterLast).isTrue()
+    }
+
     private fun SupportSQLiteDatabase.insertRanks(ranks: List<FakeCommunalItemRank>) {
         ranks.forEach { rank ->
             execSQL("INSERT INTO communal_item_rank_table(rank) VALUES(${rank.rank})")
@@ -238,10 +315,27 @@
         val userSerialNumber: Int,
     )
 
-    private data class FakeCommunalItemRank(
-        val rank: Int,
+    private fun FakeCommunalWidgetItemV3.getV4(): FakeCommunalWidgetItemV4 {
+        return FakeCommunalWidgetItemV4(widgetId, componentName, itemId, userSerialNumber, 3)
+    }
+
+    private data class FakeCommunalWidgetItemV3(
+        val widgetId: Int,
+        val componentName: String,
+        val itemId: Int,
+        val userSerialNumber: Int,
     )
 
+    private data class FakeCommunalWidgetItemV4(
+        val widgetId: Int,
+        val componentName: String,
+        val itemId: Int,
+        val userSerialNumber: Int,
+        val spanY: Int,
+    )
+
+    private data class FakeCommunalItemRank(val rank: Int)
+
     companion object {
         private const val DATABASE_NAME = "communal_db"
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDialogDelegateControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDialogDelegateControllerTest.java
index 2f8f45c..0b9c06f 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDialogDelegateControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/InternetDialogDelegateControllerTest.java
@@ -901,7 +901,7 @@
 
         // 1st time is onStart(), 2nd time is getActiveAutoSwitchNonDdsSubId()
         verify(mTelephonyManager, times(2)).registerTelephonyCallback(any(), any());
-        assertThat(mInternetDialogController.mSubIdTelephonyCallbackMap.size() == 2);
+        assertThat(mInternetDialogController.mSubIdTelephonyCallbackMap.size()).isEqualTo(2);
 
         // Adds non DDS subId again
         doReturn(SUB_ID2).when(info).getSubscriptionId();
@@ -912,7 +912,7 @@
 
         // Does not add due to cached subInfo in mSubIdTelephonyCallbackMap.
         verify(mTelephonyManager, times(2)).registerTelephonyCallback(any(), any());
-        assertThat(mInternetDialogController.mSubIdTelephonyCallbackMap.size() == 2);
+        assertThat(mInternetDialogController.mSubIdTelephonyCallbackMap.size()).isEqualTo(2);
     }
 
     @Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt
index 148a2e5..52266ee 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/DefaultScreenshotActionsProviderTest.kt
@@ -29,7 +29,6 @@
 import java.util.UUID
 import kotlin.test.Test
 import kotlinx.coroutines.test.runTest
-import org.junit.Before
 import org.junit.runner.RunWith
 import org.mockito.Mockito.verifyNoMoreInteractions
 import org.mockito.kotlin.any
@@ -47,16 +46,11 @@
     private val uiEventLogger = mock<UiEventLogger>()
     private val actionsCallback = mock<ScreenshotActionsController.ActionsCallback>()
 
-    private val request = ScreenshotData.forTesting()
+    private val request = ScreenshotData.forTesting(userHandle = UserHandle.OWNER)
     private val validResult = ScreenshotSavedResult(Uri.EMPTY, Process.myUserHandle(), 0)
 
     private lateinit var actionsProvider: ScreenshotActionsProvider
 
-    @Before
-    fun setUp() {
-        request.userHandle = UserHandle.OWNER
-    }
-
     @Test
     fun previewActionAccessed_beforeScreenshotCompleted_doesNothing() {
         actionsProvider = createActionsProvider()
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/MessageContainerControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/screenshot/MessageContainerControllerTest.kt
index 15da77d..4000d6c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/MessageContainerControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/MessageContainerControllerTest.kt
@@ -47,7 +47,7 @@
     lateinit var screenshotView: ViewGroup
 
     val userHandle = UserHandle.of(5)
-    val screenshotData = ScreenshotData.forTesting()
+    val screenshotData = ScreenshotData.forTesting(userHandle = userHandle)
 
     val appName = "app name"
     lateinit var workProfileData: WorkProfileMessageController.WorkProfileFirstRunData
@@ -61,7 +61,7 @@
                 workProfileMessageController,
                 profileMessageController,
                 screenshotDetectionController,
-                TestScope(UnconfinedTestDispatcher())
+                TestScope(UnconfinedTestDispatcher()),
             )
         screenshotView = ConstraintLayout(mContext)
         workProfileData = WorkProfileMessageController.WorkProfileFirstRunData(appName, icon)
@@ -83,8 +83,6 @@
         container.addView(detectionNoticeView)
 
         messageContainer.setView(screenshotView)
-
-        screenshotData.userHandle = userHandle
     }
 
     @Test
@@ -92,7 +90,7 @@
         val profileData =
             ProfileMessageController.ProfileFirstRunData(
                 LabeledIcon(appName, icon),
-                ProfileMessageController.FirstRunProfile.PRIVATE
+                ProfileMessageController.FirstRunProfile.PRIVATE,
             )
         whenever(profileMessageController.onScreenshotTaken(eq(userHandle))).thenReturn(profileData)
         messageContainer.onScreenshotTaken(screenshotData)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScreenshotDetectionControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScreenshotDetectionControllerTest.kt
index 1538c72..c5070286 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScreenshotDetectionControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScreenshotDetectionControllerTest.kt
@@ -61,8 +61,8 @@
 
     @Test
     fun testMaybeNotifyOfScreenshot_ignoresOverview() {
-        val data = ScreenshotData.forTesting()
-        data.source = WindowManager.ScreenshotSource.SCREENSHOT_OVERVIEW
+        val data =
+            ScreenshotData.forTesting(source = WindowManager.ScreenshotSource.SCREENSHOT_OVERVIEW)
 
         val list = controller.maybeNotifyOfScreenshot(data)
 
@@ -72,8 +72,8 @@
 
     @Test
     fun testMaybeNotifyOfScreenshot_emptySet() {
-        val data = ScreenshotData.forTesting()
-        data.source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD
+        val data =
+            ScreenshotData.forTesting(source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD)
 
         whenever(windowManager.notifyScreenshotListeners(eq(Display.DEFAULT_DISPLAY)))
             .thenReturn(listOf())
@@ -85,8 +85,8 @@
 
     @Test
     fun testMaybeNotifyOfScreenshot_oneApp() {
-        val data = ScreenshotData.forTesting()
-        data.source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD
+        val data =
+            ScreenshotData.forTesting(source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD)
 
         val component = ComponentName("package1", "class1")
         val appName = "app name"
@@ -95,7 +95,7 @@
         whenever(
                 packageManager.getActivityInfo(
                     eq(component),
-                    any(PackageManager.ComponentInfoFlags::class.java)
+                    any(PackageManager.ComponentInfoFlags::class.java),
                 )
             )
             .thenReturn(activityInfo)
@@ -112,8 +112,8 @@
 
     @Test
     fun testMaybeNotifyOfScreenshot_multipleApps() {
-        val data = ScreenshotData.forTesting()
-        data.source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD
+        val data =
+            ScreenshotData.forTesting(source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD)
 
         val component1 = ComponentName("package1", "class1")
         val component2 = ComponentName("package2", "class2")
@@ -129,21 +129,21 @@
         whenever(
                 packageManager.getActivityInfo(
                     eq(component1),
-                    any(PackageManager.ComponentInfoFlags::class.java)
+                    any(PackageManager.ComponentInfoFlags::class.java),
                 )
             )
             .thenReturn(activityInfo1)
         whenever(
                 packageManager.getActivityInfo(
                     eq(component2),
-                    any(PackageManager.ComponentInfoFlags::class.java)
+                    any(PackageManager.ComponentInfoFlags::class.java),
                 )
             )
             .thenReturn(activityInfo2)
         whenever(
                 packageManager.getActivityInfo(
                     eq(component3),
-                    any(PackageManager.ComponentInfoFlags::class.java)
+                    any(PackageManager.ComponentInfoFlags::class.java),
                 )
             )
             .thenReturn(activityInfo3)
@@ -165,11 +165,13 @@
 
     private fun includesFlagBits(@PackageManager.ComponentInfoFlagsBits mask: Int) =
         ComponentInfoFlagMatcher(mask, mask)
+
     private fun excludesFlagBits(@PackageManager.ComponentInfoFlagsBits mask: Int) =
         ComponentInfoFlagMatcher(mask, 0)
 
     private class ComponentInfoFlagMatcher(
-        @PackageManager.ComponentInfoFlagsBits val mask: Int, val value: Int
+        @PackageManager.ComponentInfoFlagsBits val mask: Int,
+        val value: Int,
     ) : ArgumentMatcher<PackageManager.ComponentInfoFlags> {
         override fun matches(flags: PackageManager.ComponentInfoFlags?): Boolean {
             return flags != null && (mask.toLong() and flags.value) == value.toLong()
@@ -182,26 +184,28 @@
 
     @Test
     fun testMaybeNotifyOfScreenshot_disabledApp() {
-        val data = ScreenshotData.forTesting()
-        data.source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD
+        val data =
+            ScreenshotData.forTesting(source = WindowManager.ScreenshotSource.SCREENSHOT_KEY_CHORD)
 
         val component = ComponentName("package1", "class1")
         val appName = "app name"
         val activityInfo = mock(ActivityInfo::class.java)
 
         whenever(
-            packageManager.getActivityInfo(
-                eq(component),
-                argThat(includesFlagBits(MATCH_DISABLED_COMPONENTS or MATCH_ANY_USER))
+                packageManager.getActivityInfo(
+                    eq(component),
+                    argThat(includesFlagBits(MATCH_DISABLED_COMPONENTS or MATCH_ANY_USER)),
+                )
             )
-        ).thenReturn(activityInfo)
+            .thenReturn(activityInfo)
 
         whenever(
-            packageManager.getActivityInfo(
-                eq(component),
-                argThat(excludesFlagBits(MATCH_DISABLED_COMPONENTS))
+                packageManager.getActivityInfo(
+                    eq(component),
+                    argThat(excludesFlagBits(MATCH_DISABLED_COMPONENTS)),
+                )
             )
-        ).thenThrow(PackageManager.NameNotFoundException::class.java)
+            .thenThrow(PackageManager.NameNotFoundException::class.java)
 
         whenever(windowManager.notifyScreenshotListeners(eq(Display.DEFAULT_DISPLAY)))
             .thenReturn(listOf(component))
diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt
index a295981..0bea560 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/TakeScreenshotExecutorTest.kt
@@ -3,6 +3,8 @@
 import android.content.ComponentName
 import android.graphics.Bitmap
 import android.net.Uri
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
 import android.view.Display
 import android.view.Display.TYPE_EXTERNAL
 import android.view.Display.TYPE_INTERNAL
@@ -15,6 +17,7 @@
 import androidx.test.filters.SmallTest
 import com.android.internal.logging.testing.UiEventLoggerFake
 import com.android.internal.util.ScreenshotRequest
+import com.android.systemui.Flags
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.display.data.repository.FakeDisplayRepository
 import com.android.systemui.display.data.repository.display
@@ -75,6 +78,7 @@
     }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_severalDisplays_callsControllerForEachOne() =
         testScope.runTest {
             val internalDisplay = display(TYPE_INTERNAL, id = 0)
@@ -106,6 +110,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_providedImageType_callsOnlyDefaultDisplayController() =
         testScope.runTest {
             val internalDisplay = display(TYPE_INTERNAL, id = 0)
@@ -115,7 +120,7 @@
             screenshotExecutor.executeScreenshots(
                 createScreenshotRequest(TAKE_SCREENSHOT_PROVIDED_IMAGE),
                 onSaved,
-                callback
+                callback,
             )
 
             verify(controllerFactory).create(eq(internalDisplay))
@@ -137,6 +142,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_onlyVirtualDisplays_noInteractionsWithControllers() =
         testScope.runTest {
             setDisplays(display(TYPE_VIRTUAL, id = 0), display(TYPE_VIRTUAL, id = 1))
@@ -149,6 +155,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_allowedTypes_allCaptured() =
         testScope.runTest {
             whenever(controllerFactory.create(any())).thenReturn(controller)
@@ -157,7 +164,7 @@
                 display(TYPE_INTERNAL, id = 0),
                 display(TYPE_EXTERNAL, id = 1),
                 display(TYPE_OVERLAY, id = 2),
-                display(TYPE_WIFI, id = 3)
+                display(TYPE_WIFI, id = 3),
             )
             val onSaved = { _: Uri? -> }
             screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback)
@@ -168,6 +175,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_reportsOnFinishedOnlyWhenBothFinished() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
@@ -193,6 +201,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_oneFinishesOtherFails_reportFailsOnlyAtTheEnd() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
@@ -220,6 +229,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_allDisplaysFail_reportsFail() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
@@ -247,6 +257,58 @@
         }
 
     @Test
+    @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_fromOverview_honorsDisplay() =
+        testScope.runTest {
+            val displayId = 1
+            setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = displayId))
+            val onSaved = { _: Uri? -> }
+            screenshotExecutor.executeScreenshots(
+                createScreenshotRequest(
+                    displayId = displayId,
+                    source = WindowManager.ScreenshotSource.SCREENSHOT_OVERVIEW,
+                ),
+                onSaved,
+                callback,
+            )
+
+            val dataCaptor = ArgumentCaptor<ScreenshotData>()
+
+            verify(controller).handleScreenshot(dataCaptor.capture(), any(), any())
+
+            assertThat(dataCaptor.value.displayId).isEqualTo(displayId)
+
+            screenshotExecutor.onDestroy()
+        }
+
+    @Test
+    @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_fromOverviewInvalidDisplay_usesDefault() =
+        testScope.runTest {
+            setDisplays(
+                display(TYPE_INTERNAL, id = Display.DEFAULT_DISPLAY),
+                display(TYPE_EXTERNAL, id = 1),
+            )
+            val onSaved = { _: Uri? -> }
+            screenshotExecutor.executeScreenshots(
+                createScreenshotRequest(
+                    displayId = 5,
+                    source = WindowManager.ScreenshotSource.SCREENSHOT_OVERVIEW,
+                ),
+                onSaved,
+                callback,
+            )
+
+            val dataCaptor = ArgumentCaptor<ScreenshotData>()
+
+            verify(controller).handleScreenshot(dataCaptor.capture(), any(), any())
+
+            assertThat(dataCaptor.value.displayId).isEqualTo(Display.DEFAULT_DISPLAY)
+
+            screenshotExecutor.onDestroy()
+        }
+
+    @Test
     fun onDestroy_propagatedToControllers() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
@@ -319,6 +381,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_errorFromProcessor_logsScreenshotRequested() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
@@ -336,6 +399,7 @@
         }
 
     @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
     fun executeScreenshots_errorFromProcessor_logsUiError() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
@@ -379,7 +443,8 @@
         }
 
     @Test
-    fun executeScreenshots_errorFromScreenshotController_reportsRequested() =
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_errorFromScreenshotController_multidisplay_reportsRequested() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
             val onSaved = { _: Uri? -> }
@@ -399,7 +464,27 @@
         }
 
     @Test
-    fun executeScreenshots_errorFromScreenshotController_reportsError() =
+    @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_errorFromScreenshotController_reportsRequested() =
+        testScope.runTest {
+            setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
+            val onSaved = { _: Uri? -> }
+            whenever(controller.handleScreenshot(any(), any(), any()))
+                .thenThrow(IllegalStateException::class.java)
+
+            screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback)
+
+            val screenshotRequested =
+                eventLogger.logs.filter {
+                    it.eventId == ScreenshotEvent.SCREENSHOT_REQUESTED_KEY_OTHER.id
+                }
+            assertThat(screenshotRequested).hasSize(1)
+            screenshotExecutor.onDestroy()
+        }
+
+    @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_errorFromScreenshotController_multidisplay_reportsError() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
             val onSaved = { _: Uri? -> }
@@ -419,7 +504,27 @@
         }
 
     @Test
-    fun executeScreenshots_errorFromScreenshotController_showsErrorNotification() =
+    @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_errorFromScreenshotController_reportsError() =
+        testScope.runTest {
+            setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
+            val onSaved = { _: Uri? -> }
+            whenever(controller.handleScreenshot(any(), any(), any()))
+                .thenThrow(IllegalStateException::class.java)
+
+            screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback)
+
+            val screenshotRequested =
+                eventLogger.logs.filter {
+                    it.eventId == ScreenshotEvent.SCREENSHOT_CAPTURE_FAILED.id
+                }
+            assertThat(screenshotRequested).hasSize(1)
+            screenshotExecutor.onDestroy()
+        }
+
+    @Test
+    @DisableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_errorFromScreenshotController_multidisplay_showsErrorNotification() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
             val onSaved = { _: Uri? -> }
@@ -436,6 +541,21 @@
         }
 
     @Test
+    @EnableFlags(Flags.FLAG_SCREENSHOT_MULTIDISPLAY_FOCUS_CHANGE)
+    fun executeScreenshots_errorFromScreenshotController_showsErrorNotification() =
+        testScope.runTest {
+            setDisplays(display(TYPE_INTERNAL, id = 0), display(TYPE_EXTERNAL, id = 1))
+            val onSaved = { _: Uri? -> }
+            whenever(controller.handleScreenshot(any(), any(), any()))
+                .thenThrow(IllegalStateException::class.java)
+
+            screenshotExecutor.executeScreenshots(createScreenshotRequest(), onSaved, callback)
+
+            verify(notificationsController0).notifyScreenshotError(any())
+            screenshotExecutor.onDestroy()
+        }
+
+    @Test
     fun executeScreenshots_finisherCalledWithNullUri_succeeds() =
         testScope.runTest {
             setDisplays(display(TYPE_INTERNAL, id = 0))
@@ -459,9 +579,14 @@
         runCurrent()
     }
 
-    private fun createScreenshotRequest(type: Int = WindowManager.TAKE_SCREENSHOT_FULLSCREEN) =
-        ScreenshotRequest.Builder(type, WindowManager.ScreenshotSource.SCREENSHOT_KEY_OTHER)
+    private fun createScreenshotRequest(
+        type: Int = WindowManager.TAKE_SCREENSHOT_FULLSCREEN,
+        source: Int = WindowManager.ScreenshotSource.SCREENSHOT_KEY_OTHER,
+        displayId: Int = Display.DEFAULT_DISPLAY,
+    ) =
+        ScreenshotRequest.Builder(type, source)
             .setTopComponent(topComponent)
+            .setDisplayId(displayId)
             .also {
                 if (type == TAKE_SCREENSHOT_PROVIDED_IMAGE) {
                     it.setBitmap(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888))
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextViewTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextViewTest.kt
new file mode 100644
index 0000000..040a9e9
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextViewTest.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+import android.testing.AndroidTestingRunner
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.shared.clocks.FontTextStyle
+import com.android.systemui.shared.clocks.LogUtil
+import com.android.systemui.shared.clocks.view.SimpleDigitalClockTextView
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class SimpleDigitalClockTextViewTest : SysuiTestCase() {
+    private val messageBuffer = LogUtil.DEBUG_MESSAGE_BUFFER
+    private lateinit var underTest: SimpleDigitalClockTextView
+    private val defaultLargeClockTextSize = 500F
+    private val smallerTextSize = 300F
+    private val largerTextSize = 800F
+    private val firstMeasureTextSize = 100F
+
+    @Before
+    fun setup() {
+        underTest = SimpleDigitalClockTextView(context, messageBuffer)
+        underTest.textStyle = FontTextStyle()
+        underTest.aodStyle = FontTextStyle()
+        underTest.text = "0"
+        underTest.applyTextSize(defaultLargeClockTextSize)
+    }
+
+    @Test
+    fun applySmallerConstrainedTextSize_applyConstrainedTextSize() {
+        underTest.applyTextSize(smallerTextSize, constrainedByHeight = true)
+        assertEquals(smallerTextSize, underTest.textSize * underTest.fontSizeAdjustFactor)
+    }
+
+    @Test
+    fun applyLargerConstrainedTextSize_applyUnconstrainedTextSize() {
+        underTest.applyTextSize(largerTextSize, constrainedByHeight = true)
+        assertEquals(defaultLargeClockTextSize, underTest.textSize)
+    }
+
+    @Test
+    fun applyFirstMeasureConstrainedTextSize_getConstrainedTextSize() {
+        underTest.applyTextSize(firstMeasureTextSize, constrainedByHeight = true)
+        underTest.applyTextSize(smallerTextSize, constrainedByHeight = true)
+        assertEquals(smallerTextSize, underTest.textSize * underTest.fontSizeAdjustFactor)
+    }
+
+    @Test
+    fun applySmallFirstMeasureConstrainedSizeAndLargerConstrainedTextSize_applyDefaultSize() {
+        underTest.applyTextSize(firstMeasureTextSize, constrainedByHeight = true)
+        underTest.applyTextSize(largerTextSize, constrainedByHeight = true)
+        assertEquals(defaultLargeClockTextSize, underTest.textSize)
+    }
+
+    @Test
+    fun applyFirstMeasureConstrainedTextSize_applyUnconstrainedTextSize() {
+        underTest.applyTextSize(firstMeasureTextSize, constrainedByHeight = true)
+        underTest.applyTextSize(defaultLargeClockTextSize)
+        assertEquals(defaultLargeClockTextSize, underTest.textSize)
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceControllerTest.kt
index 7a8533e..fe287ef 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceControllerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/lockscreen/LockscreenSmartspaceControllerTest.kt
@@ -23,6 +23,7 @@
 import android.app.smartspace.SmartspaceTarget
 import android.content.ComponentName
 import android.content.ContentResolver
+import android.content.Context
 import android.content.pm.UserInfo
 import android.database.ContentObserver
 import android.graphics.drawable.Drawable
@@ -207,6 +208,9 @@
     private val userHandleManaged: UserHandle = UserHandle(2)
     private val userHandleSecondary: UserHandle = UserHandle(3)
 
+    @Mock private lateinit var userContextPrimary: Context
+    @Mock private lateinit var userContextSecondary: Context
+
     private val userList = listOf(
             mockUserInfo(userHandlePrimary, isManagedProfile = false),
             mockUserInfo(userHandleManaged, isManagedProfile = true),
@@ -234,7 +238,11 @@
         `when`(deviceProvisionedController.isDeviceProvisioned).thenReturn(true)
         `when`(deviceProvisionedController.isCurrentUserSetup).thenReturn(true)
 
-        setActiveUser(userHandlePrimary)
+        `when`(userContextPrimary.getSystemService(SmartspaceManager::class.java)).thenReturn(
+            smartspaceManager
+        )
+
+        setActiveUser(userHandlePrimary, userContextPrimary)
         setAllowPrivateNotifications(userHandlePrimary, true)
         setAllowPrivateNotifications(userHandleManaged, true)
         setAllowPrivateNotifications(userHandleSecondary, true)
@@ -252,7 +260,6 @@
         controller = LockscreenSmartspaceController(
                 context,
                 featureFlags,
-                smartspaceManager,
                 activityStarter,
                 falsingManager,
                 clock,
@@ -709,7 +716,8 @@
         connectSession()
 
         // WHEN the secondary user becomes the active user
-        setActiveUser(userHandleSecondary)
+        // Note: it doesn't switch to the SmartspaceManager for userContextSecondary
+        setActiveUser(userHandleSecondary, userContextSecondary)
         userListener.onUserChanged(userHandleSecondary.identifier, context)
 
         // WHEN we receive a new list of targets
@@ -912,9 +920,10 @@
         clearInvocations(smartspaceView)
     }
 
-    private fun setActiveUser(userHandle: UserHandle) {
+    private fun setActiveUser(userHandle: UserHandle, userContext: Context) {
         `when`(userTracker.userId).thenReturn(userHandle.identifier)
         `when`(userTracker.userHandle).thenReturn(userHandle)
+        `when`(userTracker.userContext).thenReturn(userContext)
     }
 
     private fun mockUserInfo(userHandle: UserHandle, isManagedProfile: Boolean): UserInfo {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallbackTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallbackTest.java
index c523819..81c40dc 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallbackTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarRemoteInputCallbackTest.java
@@ -36,7 +36,9 @@
 import androidx.test.filters.SmallTest;
 
 import com.android.systemui.SysuiTestCase;
+import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor;
 import com.android.systemui.plugins.ActivityStarter;
+import com.android.systemui.scene.domain.interactor.SceneInteractor;
 import com.android.systemui.settings.FakeDisplayTracker;
 import com.android.systemui.shade.ShadeController;
 import com.android.systemui.statusbar.ActionClickLogger;
@@ -50,8 +52,11 @@
 import com.android.systemui.statusbar.policy.DeviceProvisionedController;
 import com.android.systemui.statusbar.policy.KeyguardStateController;
 import com.android.systemui.util.concurrency.FakeExecutor;
+import com.android.systemui.util.kotlin.JavaAdapter;
 import com.android.systemui.util.time.FakeSystemClock;
 
+import dagger.Lazy;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -71,8 +76,14 @@
     @Mock private SysuiStatusBarStateController mStatusBarStateController;
     @Mock private StatusBarKeyguardViewManager mStatusBarKeyguardViewManager;
     @Mock private ActivityStarter mActivityStarter;
+    @Mock private Lazy<DeviceUnlockedInteractor> mDeviceUnlockedInteractorLazy;
+    @Mock private Lazy<SceneInteractor> mSceneInteractorLazy;
+    @Mock private JavaAdapter mJavaAdapter;
     private final FakeExecutor mFakeExecutor = new FakeExecutor(new FakeSystemClock());
 
+    @Mock private DeviceUnlockedInteractor mDeviceUnlockedInteractor;
+    @Mock private SceneInteractor mSceneInteractor;
+
     private int mCurrentUserId = 0;
     private StatusBarRemoteInputCallback mRemoteInputCallback;
 
@@ -90,7 +101,8 @@
                 mKeyguardStateController, mStatusBarStateController, mStatusBarKeyguardViewManager,
                 mActivityStarter, mShadeController,
                 new CommandQueue(mContext, new FakeDisplayTracker(mContext)),
-                mock(ActionClickLogger.class), mFakeExecutor));
+                mock(ActionClickLogger.class), mFakeExecutor, mDeviceUnlockedInteractorLazy,
+                mSceneInteractorLazy, mJavaAdapter));
         mRemoteInputCallback.mChallengeReceiver = mRemoteInputCallback.new ChallengeReceiver();
     }
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java b/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java
index f62beeb..beba0f0 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/volume/VolumeDialogControllerImplTest.java
@@ -133,10 +133,6 @@
         when(mRingerModeInternalLiveData.getValue()).thenReturn(-1);
         when(mUserTracker.getUserId()).thenReturn(ActivityManager.getCurrentUser());
         when(mUserTracker.getUserContext()).thenReturn(mContext);
-        // Enable group volume adjustments
-        mContext.getOrCreateTestableResources().addOverride(
-                com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions,
-                true);
 
         mCallback = mock(VolumeDialogControllerImpl.C.class);
         mThreadFactory.setLooper(TestableLooper.get(this).getLooper());
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt
index 5d7e7c7..1302faa 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt
@@ -31,7 +31,7 @@
         provider: ComponentName,
         user: UserHandle,
         rank: Int?,
-        configurator: WidgetConfigurator?
+        configurator: WidgetConfigurator?,
     ) {
         coroutineScope.launch {
             val id = nextWidgetId++
@@ -93,6 +93,22 @@
         _communalWidgets.value = fakeDatabase.values.toList()
     }
 
+    override fun updateWidgetSpanY(widgetId: Int, spanY: Int) {
+        coroutineScope.launch {
+            fakeDatabase[widgetId]?.let { widget ->
+                when (widget) {
+                    is CommunalWidgetContentModel.Available -> {
+                        fakeDatabase[widgetId] = widget.copy(spanY = spanY)
+                    }
+                    is CommunalWidgetContentModel.Pending -> {
+                        fakeDatabase[widgetId] = widget.copy(spanY = spanY)
+                    }
+                }
+                _communalWidgets.value = fakeDatabase.values.toList()
+            }
+        }
+    }
+
     override fun restoreWidgets(oldToNewWidgetIdMap: Map<Int, Int>) {}
 
     override fun abortRestoreWidgets() {}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt
index b37cac1..ba31683 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyboard/data/repository/FakeKeyboardRepository.kt
@@ -19,8 +19,10 @@
 
 import com.android.systemui.keyboard.data.model.Keyboard
 import com.android.systemui.keyboard.shared.model.BacklightModel
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.filterNotNull
 
 class FakeKeyboardRepository : KeyboardRepository {
@@ -32,8 +34,14 @@
     // filtering to make sure backlight doesn't have default initial value
     override val backlight: Flow<BacklightModel> = _backlightState.filterNotNull()
 
-    private val _newlyConnectedKeyboard: MutableStateFlow<Keyboard?> = MutableStateFlow(null)
-    override val newlyConnectedKeyboard: Flow<Keyboard> = _newlyConnectedKeyboard.filterNotNull()
+    // implemented as channel because original implementation is modeling events: it doesn't hold
+    // state so it won't always emit once connected. And it's bad if some tests depend on that
+    // incorrect behaviour.
+    private val _newlyConnectedKeyboard: Channel<Keyboard> = Channel()
+    override val newlyConnectedKeyboard: Flow<Keyboard> = _newlyConnectedKeyboard.consumeAsFlow()
+
+    private val _connectedKeyboards: MutableStateFlow<Set<Keyboard>> = MutableStateFlow(setOf())
+    override val connectedKeyboards: Flow<Set<Keyboard>> = _connectedKeyboards
 
     fun setBacklight(state: BacklightModel) {
         _backlightState.value = state
@@ -43,7 +51,14 @@
         _isAnyKeyboardConnected.value = connected
     }
 
+    fun setConnectedKeyboards(keyboards: Set<Keyboard>) {
+        _connectedKeyboards.value = keyboards
+        _isAnyKeyboardConnected.value = keyboards.isNotEmpty()
+    }
+
     fun setNewlyConnectedKeyboard(keyboard: Keyboard) {
-        _newlyConnectedKeyboard.value = keyboard
+        _newlyConnectedKeyboard.trySend(keyboard)
+        _connectedKeyboards.value += keyboard
+        _isAnyKeyboardConnected.value = true
     }
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeLightRevealScrimRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeLightRevealScrimRepository.kt
index f26bb83..805a710 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeLightRevealScrimRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeLightRevealScrimRepository.kt
@@ -35,6 +35,8 @@
     private val _revealAmount: MutableStateFlow<Float> = MutableStateFlow(0.0f)
     override val revealAmount: Flow<Float> = _revealAmount
 
+    val revealAnimatorRequests: MutableList<RevealAnimatorRequest> = arrayListOf()
+
     override val isAnimating: Boolean
         get() = false
 
@@ -44,5 +46,12 @@
         } else {
             _revealAmount.value = 0.0f
         }
+
+        revealAnimatorRequests.add(RevealAnimatorRequest(reveal, duration))
     }
+
+    data class RevealAnimatorRequest(
+        val reveal: Boolean,
+        val duration: Long
+    )
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRule.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRule.kt
new file mode 100644
index 0000000..e4c793d
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRule.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.systemui.util
+
+import android.testing.UiThreadTest
+import org.junit.Assert.fail
+import org.junit.rules.MethodRule
+import org.junit.runners.model.FrameworkMethod
+import org.junit.runners.model.Statement
+
+/**
+ * A Test rule which prevents us from using the UiThreadTest annotation. See
+ * go/android_junit4_uithreadtest (b/352170965)
+ */
+public class NoUiThreadTestRule : MethodRule {
+    override fun apply(base: Statement, method: FrameworkMethod, target: Any): Statement? {
+        if (hasUiThreadAnnotation(method, target)) {
+            fail("UiThreadTest doesn't actually run on the UiThread")
+        }
+        return base
+    }
+
+    private fun hasUiThreadAnnotation(method: FrameworkMethod, target: Any): Boolean {
+        if (method.getAnnotation(UiThreadTest::class.java) != null) {
+            return true
+        } else {
+            return target.javaClass.getAnnotation(UiThreadTest::class.java) != null
+        }
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRuleTest.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRuleTest.kt
new file mode 100644
index 0000000..70dd103
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/NoUiThreadTestRuleTest.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.systemui.util
+
+import android.testing.UiThreadTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import java.lang.AssertionError
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.model.FrameworkMethod
+import org.junit.runners.model.Statement
+
+/**
+ * Test that NoUiThreadTestRule asserts when it finds a framework method with a UiThreadTest
+ * annotation.
+ */
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+public class NoUiThreadTestRuleTest : SysuiTestCase() {
+
+    class TestStatement : Statement() {
+        override fun evaluate() {}
+    }
+
+    inner class TestInner {
+        @Test @UiThreadTest fun simpleUiTest() {}
+
+        @Test fun simpleTest() {}
+    }
+
+    /**
+     * Test that NoUiThreadTestRule throws an asserts false if a test is annotated
+     * with @UiThreadTest
+     */
+    @Test(expected = AssertionError::class)
+    fun testNoUiThreadFail() {
+        val method = TestInner::class.java.getDeclaredMethod("simpleUiTest")
+        val frameworkMethod = FrameworkMethod(method)
+        val noUiThreadTestRule = NoUiThreadTestRule()
+        val testStatement = TestStatement()
+        // target needs to be non-null
+        val obj = Object()
+        noUiThreadTestRule.apply(testStatement, frameworkMethod, obj)
+    }
+
+    /**
+     * Test that NoUiThreadTestRule throws an asserts false if a test is annotated
+     * with @UiThreadTest
+     */
+    fun testNoUiThreadOK() {
+        val method = TestInner::class.java.getDeclaredMethod("simpleUiTest")
+        val frameworkMethod = FrameworkMethod(method)
+        val noUiThreadTestRule = NoUiThreadTestRule()
+        val testStatement = TestStatement()
+
+        // because target needs to be non-null
+        val obj = Object()
+        val newStatement = noUiThreadTestRule.apply(testStatement, frameworkMethod, obj)
+        Assert.assertEquals(newStatement, testStatement)
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThread.java b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThread.java
new file mode 100644
index 0000000..f81b7de
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThread.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2023 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.systemui.util;
+
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+/**
+ * A class to launch runnables on the UI thread explicitly.
+ */
+public class UiThread {
+    private static final String TAG = "UiThread";
+
+    /**
+     * Run a runnable on the UI thread using instrumentation.runOnMainSync.
+     *
+     * @param runnable code to run on the UI thread.
+     * @throws Throwable if the code threw an exception, so it can be reported
+     * to the test.
+     */
+    public static void runOnUiThread(final Runnable runnable) throws Throwable {
+        if (Looper.myLooper() == Looper.getMainLooper()) {
+            Log.w(
+                    TAG,
+                    "UiThread.runOnUiThread() should not be called from the "
+                        + "main application thread");
+            runnable.run();
+        } else {
+            FutureTask<Void> task = new FutureTask<>(runnable, null);
+            InstrumentationRegistry.getInstrumentation().runOnMainSync(task);
+            try {
+                task.get();
+            } catch (ExecutionException e) {
+                // Expose the original exception
+                throw e.getCause();
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThreadRunTest.java b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThreadRunTest.java
new file mode 100644
index 0000000..abf2e8d
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/UiThreadRunTest.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2023 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.systemui.util;
+
+import android.os.Looper;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+
+/**
+ * Test that UiThread.runOnUiThread() actually runs on the UI Thread.
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class UiThreadRunTest extends SysuiTestCase {
+
+    @Test
+    public void testUiThread() throws Throwable {
+        UiThread.runOnUiThread(() -> {
+            Assert.assertEquals(Looper.getMainLooper().getThread(), Thread.currentThread());
+        });
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/Android.bp b/packages/SystemUI/utils/kairos/Android.bp
new file mode 100644
index 0000000..1442591
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/Android.bp
@@ -0,0 +1,49 @@
+//
+// 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 {
+    default_team: "trendy_team_system_ui_please_use_a_more_specific_subteam_if_possible_",
+    default_applicable_licenses: ["frameworks_base_packages_SystemUI_license"],
+}
+
+java_library {
+    name: "kairos",
+    host_supported: true,
+    kotlincflags: ["-opt-in=com.android.systemui.kairos.ExperimentalFrpApi"],
+    srcs: ["src/**/*.kt"],
+    static_libs: [
+        "kotlin-stdlib",
+        "kotlinx_coroutines",
+    ],
+}
+
+java_test {
+    name: "kairos-test",
+    optimize: {
+        enabled: false,
+    },
+    srcs: [
+        "test/**/*.kt",
+    ],
+    static_libs: [
+        "kairos",
+        "junit",
+        "kotlin-stdlib",
+        "kotlin-test",
+        "kotlinx_coroutines",
+        "kotlinx_coroutines_test",
+    ],
+}
diff --git a/packages/SystemUI/utils/kairos/OWNERS b/packages/SystemUI/utils/kairos/OWNERS
new file mode 100644
index 0000000..8876ad6
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/OWNERS
@@ -0,0 +1,3 @@
+steell@google.com
+nijamkin@google.com
+evanlaird@google.com
diff --git a/packages/SystemUI/utils/kairos/README.md b/packages/SystemUI/utils/kairos/README.md
new file mode 100644
index 0000000..85f622c
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/README.md
@@ -0,0 +1,64 @@
+# Kairos
+
+A functional reactive programming (FRP) library for Kotlin.
+
+This library is **experimental** and should not be used for general production
+code. The APIs within are subject to change, and there may be bugs.
+
+## About FRP
+
+Functional reactive programming is a type of reactive programming system that
+follows a set of clear and composable rules, without sacrificing consistency.
+FRP exposes an API that should be familiar to those versed in Kotlin `Flow`.
+
+### Details for nerds
+
+`Kairos` implements an applicative / monadic flavor of FRP, using a push-pull
+methodology to allow for efficient updates.
+
+"Real" functional reactive programming should be specified with denotational
+semantics ([wikipedia](https://en.wikipedia.org/wiki/Denotational_semantics)):
+you can view the semantics for `Kairos` [here](docs/semantics.md).
+
+## Usage
+
+First, stand up a new `FrpNetwork`. All reactive events and state is kept
+consistent within a single network.
+
+``` kotlin
+val coroutineScope: CoroutineScope = ...
+val frpNetwork = coroutineScope.newFrpNetwork()
+```
+
+You can use the `FrpNetwork` to stand-up a network of reactive events and state.
+Events are modeled with `TFlow` (short for "transactional flow"), and state
+`TState` (short for "transactional state").
+
+``` kotlin
+suspend fun activate(network: FrpNetwork) {
+    network.activateSpec {
+        val input = network.mutableTFlow<Unit>()
+        // Launch a long-running side-effect that emits to the network
+        // every second.
+        launchEffect {
+            while (true) {
+                input.emit(Unit)
+                delay(1.seconds)
+            }
+        }
+        // Accumulate state
+        val count: TState<Int> = input.fold { _, i -> i + 1 }
+        // Observe events to perform side-effects in reaction to them
+        input.observe {
+            println("Got event ${count.sample()} at time: ${System.currentTimeMillis()}")
+        }
+    }
+}
+```
+
+`FrpNetwork.activateSpec` will suspend indefinitely; cancelling the invocation
+will tear-down all effects and obervers running within the lambda.
+
+## Resources
+
+- [Cheatsheet for those coming from Kotlin Flow](docs/flow-to-kairos-cheatsheet.md)
diff --git a/packages/SystemUI/utils/kairos/docs/flow-to-kairos-cheatsheet.md b/packages/SystemUI/utils/kairos/docs/flow-to-kairos-cheatsheet.md
new file mode 100644
index 0000000..9f7fd02
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/docs/flow-to-kairos-cheatsheet.md
@@ -0,0 +1,330 @@
+# From Flows to Kairos
+
+## Key differences
+
+* Kairos evaluates all events (`TFlow` emissions + observers) in a transaction.
+
+* Kairos splits `Flow` APIs into two distinct types: `TFlow` and `TState`
+
+    * `TFlow` is roughly equivalent to `SharedFlow` w/ a replay cache that
+      exists for the duration of the current Kairos transaction and shared with
+      `SharingStarted.WhileSubscribed()`
+
+    * `TState` is roughly equivalent to `StateFlow` shared with
+      `SharingStarted.Eagerly`, but the current value can only be queried within
+      a Kairos transaction, and the value is only updated at the end of the
+      transaction
+
+* Kairos further divides `Flow` APIs based on how they internally use state:
+
+  * **FrpTransactionScope:** APIs that internally query some state need to be
+    performed within an Kairos transaction
+
+    * this scope is available from the other scopes, and from most lambdas
+      passed to other Kairos APIs
+
+  * **FrpStateScope:** APIs that internally accumulate state in reaction to
+    events need to be performed within an FRP State scope (akin to a
+    `CoroutineScope`)
+
+    * this scope is a side-effect-free subset of FrpBuildScope, and so can be
+      used wherever you have an FrpBuildScope
+
+  * **FrpBuildScope:** APIs that perform external side-effects (`Flow.collect`)
+    need to be performed within an FRP Build scope (akin to a `CoroutineScope`)
+
+    * this scope is available from `FrpNetwork.activateSpec { … }`
+
+  * All other APIs can be used anywhere
+
+## emptyFlow()
+
+Use `emptyTFlow`
+
+``` kotlin
+// this TFlow emits nothing
+val noEvents: TFlow<Int> = emptyTFlow
+```
+
+## map { … }
+
+Use `TFlow.map` / `TState.map`
+
+``` kotlin
+val anInt: TState<Int> = …
+val squared: TState<Int> = anInt.map { it * it }
+val messages: TFlow<String> = …
+val messageLengths: TFlow<Int> = messages.map { it.size }
+```
+
+## filter { … } / mapNotNull { … }
+
+### I have a TFlow
+
+Use `TFlow.filter` / `TFlow.mapNotNull`
+
+``` kotlin
+val messages: TFlow<String> = …
+val nonEmpty: TFlow<String> = messages.filter { it.isNotEmpty() }
+```
+
+### I have a TState
+
+Convert the `TState` to `TFlow` using `TState.stateChanges`, then use
+`TFlow.filter` / `TFlow.mapNotNull`
+
+If you need to convert back to `TState`, use `TFlow.hold(initialValue)` on the
+result.
+
+``` kotlin
+tState.stateChanges.filter { … }.hold(initialValue)
+```
+
+Note that `TFlow.hold` is only available within an `FrpStateScope` in order to
+track the lifetime of the state accumulation.
+
+## combine(...) { … }
+
+### I have TStates
+
+Use `combine(TStates)`
+
+``` kotlin
+val someInt: TState<Int> = …
+val someString: TState<String> = …
+val model: TState<MyModel> = combine(someInt, someString) { i, s -> MyModel(i, s) }
+```
+
+### I have TFlows
+
+Convert the TFlows to TStates using `TFlow.hold(initialValue)`, then use
+`combine(TStates)`
+
+If you want the behavior of Flow.combine where nothing is emitted until each
+TFlow has emitted at least once, you can use filter:
+
+``` kotlin
+// null used as an example, can use a different sentinel if needed
+combine(tFlowA.hold(null), tFlowB.hold(null)) { a, b ->
+        a?.let { b?.let { … } }
+    }
+    .filterNotNull()
+```
+
+Note that `TFlow.hold` is only available within an `FrpStateScope` in order to
+track the lifetime of the state accumulation.
+
+#### Explanation
+
+`Flow.combine` always tracks the last-emitted value of each `Flow` it's
+combining. This is a form of state-accumulation; internally, it collects from
+each `Flow`, tracks the latest-emitted value, and when anything changes, it
+re-runs the lambda to combine the latest values.
+
+An effect of this is that `Flow.combine` doesn't emit until each combined `Flow`
+has emitted at least once. This often bites developers. As a workaround,
+developers generally append `.onStart { emit(initialValue) }` to the `Flows`
+that don't immediately emit.
+
+Kairos avoids this gotcha by forcing usage of `TState` for `combine`, thus
+ensuring that there is always a current value to be combined for each input.
+
+## collect { … }
+
+Use `observe { … }`
+
+``` kotlin
+val job: Job = tFlow.observe { println("observed: $it") }
+```
+
+Note that `observe` is only available within an `FrpBuildScope` in order to
+track the lifetime of the observer. `FrpBuildScope` can only come from a
+top-level `FrpNetwork.transaction { … }`, or a sub-scope created by using a
+`-Latest` operator.
+
+## sample(flow) { … }
+
+### I want to sample a TState
+
+Use `TState.sample()` to get the current value of a `TState`. This can be
+invoked anywhere you have access to an `FrpTransactionScope`.
+
+``` kotlin
+// the lambda passed to map receives an FrpTransactionScope, so it can invoke
+// sample
+tFlow.map { tState.sample() }
+```
+
+#### Explanation
+
+To keep all state-reads consistent, the current value of a TState can only be
+queried within a Kairos transaction, modeled with `FrpTransactionScope`. Note
+that both `FrpStateScope` and `FrpBuildScope` extend `FrpTransactionScope`.
+
+### I want to sample a TFlow
+
+Convert to a `TState` by using `TFlow.hold(initialValue)`, then use `sample`.
+
+Note that `hold` is only available within an `FrpStateScope` in order to track
+the lifetime of the state accumulation.
+
+## stateIn(scope, sharingStarted, initialValue)
+
+Use `TFlow.hold(initialValue)`. There is no need to supply a sharingStarted
+argument; all states are accumulated eagerly.
+
+``` kotlin
+val ints: TFlow<Int> = …
+val lastSeenInt: TState<Int> = ints.hold(initialValue = 0)
+```
+
+Note that `hold` is only available within an `FrpStateScope` in order to track
+the lifetime of the state accumulation (akin to the scope parameter of
+`Flow.stateIn`). `FrpStateScope` can only come from a top-level
+`FrpNetwork.transaction { … }`, or a sub-scope created by using a `-Latest`
+operator. Also note that `FrpBuildScope` extends `FrpStateScope`.
+
+## distinctUntilChanged()
+
+Use `distinctUntilChanged` like normal. This is only available for `TFlow`;
+`TStates` are already `distinctUntilChanged`.
+
+## merge(...)
+
+### I have TFlows
+
+Use `merge(TFlows) { … }`. The lambda argument is used to disambiguate multiple
+simultaneous emissions within the same transaction.
+
+#### Explanation
+
+Under Kairos's rules, a `TFlow` may only emit up to once per transaction. This
+means that if we are merging two or more `TFlows` that are emitting at the same
+time (within the same transaction), the resulting merged `TFlow` must emit a
+single value. The lambda argument allows the developer to decide what to do in
+this case.
+
+### I have TStates
+
+If `combine` doesn't satisfy your needs, you can use `TState.stateChanges` to
+convert to a `TFlow`, and then `merge`.
+
+## conflatedCallbackFlow { … }
+
+Use `tFlow { … }`.
+
+As a shortcut, if you already have a `conflatedCallbackFlow { … }`, you can
+convert it to a TFlow via `Flow.toTFlow()`.
+
+Note that `tFlow` is only available within an `FrpBuildScope` in order to track
+the lifetime of the input registration.
+
+## first()
+
+### I have a TState
+
+Use `TState.sample`.
+
+### I have a TFlow
+
+Use `TFlow.nextOnly`, which works exactly like `Flow.first` but instead of
+suspending it returns a `TFlow` that emits once.
+
+The naming is intentionally different because `first` implies that it is the
+first-ever value emitted from the `Flow` (which makes sense for cold `Flows`),
+whereas `nextOnly` indicates that only the next value relative to the current
+transaction (the one `nextOnly` is being invoked in) will be emitted.
+
+Note that `nextOnly` is only available within an `FrpStateScope` in order to
+track the lifetime of the state accumulation.
+
+## flatMapLatest { … }
+
+If you want to use -Latest to cancel old side-effects, similar to what the Flow
+-Latest operators offer for coroutines, see `mapLatest`.
+
+### I have a TState…
+
+#### …and want to switch TStates
+
+Use `TState.flatMap`
+
+``` kotlin
+val flattened = tState.flatMap { a -> getTState(a) }
+```
+
+#### …and want to switch TFlows
+
+Use `TState<TFlow<T>>.switch()`
+
+``` kotlin
+val tFlow = tState.map { a -> getTFlow(a) }.switch()
+```
+
+### I have a TFlow…
+
+#### …and want to switch TFlows
+
+Use `hold` to convert to a `TState<TFlow<T>>`, then use `switch` to switch to
+the latest `TFlow`.
+
+``` kotlin
+val tFlow = tFlowOfFlows.hold(emptyTFlow).switch()
+```
+
+#### …and want to switch TStates
+
+Use `hold` to convert to a `TState<TState<T>>`, then use `flatMap` to switch to
+the latest `TState`.
+
+``` kotlin
+val tState = tFlowOfStates.hold(tStateOf(initialValue)).flatMap { it }
+```
+
+## mapLatest { … } / collectLatest { … }
+
+`FrpStateScope` and `FrpBuildScope` both provide `-Latest` operators that
+automatically cancel old work when new values are emitted.
+
+``` kotlin
+val currentModel: TState<SomeModel> = …
+val mapped: TState<...> = currentModel.mapLatestBuild { model ->
+    effect { "new model in the house: $model" }
+    model.someState.observe { "someState: $it" }
+    val someData: TState<SomeInfo> =
+        getBroadcasts(model.uri)
+            .map { extractInfo(it) }
+            .hold(initialInfo)
+    …
+}
+```
+
+## flowOf(...)
+
+### I want a TState
+
+Use `tStateOf(initialValue)`.
+
+### I want a TFlow
+
+Use `now.map { initialValue }`
+
+Note that `now` is only available within an `FrpTransactionScope`.
+
+#### Explanation
+
+`TFlows` are not cold, and so there isn't a notion of "emit this value once
+there is a collector" like there is for `Flow`. The closest analog would be
+`TState`, since the initial value is retained indefinitely until there is an
+observer. However, it is often useful to immediately emit a value within the
+current transaction, usually when using a `flatMap` or `switch`. In these cases,
+using `now` explicitly models that the emission will occur within the current
+transaction.
+
+``` kotlin
+fun <T> FrpTransactionScope.tFlowOf(value: T): TFlow<T> = now.map { value }
+```
+
+## MutableStateFlow / MutableSharedFlow
+
+Use `MutableTState(frpNetwork, initialValue)` and `MutableTFlow(frpNetwork)`.
diff --git a/packages/SystemUI/utils/kairos/docs/semantics.md b/packages/SystemUI/utils/kairos/docs/semantics.md
new file mode 100644
index 0000000..d43bb44
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/docs/semantics.md
@@ -0,0 +1,225 @@
+# FRP Semantics
+
+`Kairos`'s pure API is based off of the following denotational semantics
+([wikipedia](https://en.wikipedia.org/wiki/Denotational_semantics)).
+
+The semantics model `Kairos` types as time-varying values; by making `Time` a
+first-class value, we can define a referentially-transparent API that allows us
+to reason about the behavior of the pure `Kairos` combinators. This is
+implementation-agnostic; we can compare the behavior of any implementation with
+expected behavior denoted by these semantics to identify bugs.
+
+The semantics are written in pseudo-Kotlin; places where we are deviating from
+real Kotlin are noted with comments.
+
+``` kotlin
+
+sealed class Time : Comparable<Time> {
+  object BigBang : Time()
+  data class At(time: BigDecimal) : Time()
+  object Infinity : Time()
+
+  override final fun compareTo(other: Time): Int =
+    when (this) {
+      BigBang -> if (other === BigBang) 0 else -1
+      is At -> when (other) {
+        BigBang -> 1
+        is At -> time.compareTo(other.time)
+        Infinity -> -1
+      }
+      Infinity -> if (other === Infinity) 0 else 1
+    }
+}
+
+typealias Transactional<T> = (Time) -> T
+
+typealias TFlow<T> = SortedMap<Time, T>
+
+private fun <T> SortedMap<Time, T>.pairwise(): List<Pair<Pair<Time, T>, Pair<Time<T>>>> =
+  // NOTE: pretend evaluation is lazy, so that error() doesn't immediately throw
+  (toList() + Pair(Time.Infinity, error("no value"))).zipWithNext()
+
+class TState<T> internal constructor(
+  internal val current: Transactional<T>,
+  val stateChanges: TFlow<T>,
+)
+
+val emptyTFlow: TFlow<Nothing> = emptyMap()
+
+fun <A, B> TFlow<A>.map(f: FrpTransactionScope.(A) -> B): TFlow<B> =
+  mapValues { (t, a) -> FrpTransactionScope(t).f(a) }
+
+fun <A> TFlow<A>.filter(f: FrpTransactionScope.(A) -> Boolean): TFlow<A> =
+  filter { (t, a) -> FrpTransactionScope(t).f(a) }
+
+fun <A> merge(
+  first: TFlow<A>,
+  second: TFlow<A>,
+  onCoincidence: Time.(A, A) -> A,
+): TFlow<A> =
+  first.toMutableMap().also { result ->
+    second.forEach { (t, a) ->
+      result.merge(t, a) { f, s ->
+        FrpTranscationScope(t).onCoincidence(f, a)
+      }
+    }
+  }.toSortedMap()
+
+fun <A> TState<TFlow<A>>.switch(): TFlow<A> {
+  val truncated = listOf(Pair(Time.BigBang, current.invoke(Time.BigBang))) +
+    stateChanges.dropWhile { (time, _) -> time < time0 }
+  val events =
+    truncated
+      .pairwise()
+      .flatMap { ((t0, sa), (t2, _)) ->
+        sa.filter { (t1, a) -> t0 < t1 && t1 <= t2 }
+      }
+  return events.toSortedMap()
+}
+
+fun <A> TState<TFlow<A>>.switchPromptly(): TFlow<A> {
+  val truncated = listOf(Pair(Time.BigBang, current.invoke(Time.BigBang))) +
+    stateChanges.dropWhile { (time, _) -> time < time0 }
+  val events =
+    truncated
+      .pairwise()
+      .flatMap { ((t0, sa), (t2, _)) ->
+        sa.filter { (t1, a) -> t0 <= t1 && t1 <= t2 }
+      }
+  return events.toSortedMap()
+}
+
+typealias GroupedTFlow<K, V> = TFlow<Map<K, V>>
+
+fun <K, V> TFlow<Map<K, V>>.groupByKey(): GroupedTFlow<K, V> = this
+
+fun <K, V> GroupedTFlow<K, V>.eventsForKey(key: K): TFlow<V> =
+  map { m -> m[k] }.filter { it != null }.map { it!! }
+
+fun <A, B> TState<A>.map(f: (A) -> B): TState<B> =
+  TState(
+    current = { t -> f(current.invoke(t)) },
+    stateChanges = stateChanges.map { f(it) },
+  )
+
+fun <A, B, C> TState<A>.combineWith(
+  other: TState<B>,
+  f: (A, B) -> C,
+): TState<C> =
+  TState(
+    current = { t -> f(current.invoke(t), other.current.invoke(t)) },
+    stateChanges = run {
+      val aChanges =
+        stateChanges
+          .map { a ->
+            val b = other.current.sample()
+            Triple(a, b, f(a, b))
+          }
+      val bChanges =
+        other
+          .stateChanges
+          .map { b ->
+            val a = current.sample()
+            Triple(a, b, f(a, b))
+          }
+      merge(aChanges, bChanges) { (a, _, _), (_, b, _) ->
+          Triple(a, b, f(a, b))
+        }
+        .map { (_, _, zipped) -> zipped }
+    },
+  )
+
+fun <A> TState<TState<A>>.flatten(): TState<A> {
+  val changes =
+    stateChanges
+      .pairwise()
+      .flatMap { ((t0, oldInner), (t2, _)) ->
+        val inWindow =
+          oldInner
+            .stateChanges
+            .filter { (t1, b) -> t0 <= t1 && t1 < t2 }
+        if (inWindow.firstOrNull()?.time != t0) {
+          listOf(Pair(t0, oldInner.current.invoke(t0))) + inWindow
+        } else {
+          inWindow
+        }
+      }
+  return TState(
+    current = { t -> current.invoke(t).current.invoke(t) },
+    stateChanges = changes.toSortedMap(),
+  )
+}
+
+open class FrpTranscationScope internal constructor(
+  internal val currentTime: Time,
+) {
+  val now: TFlow<Unit> =
+    sortedMapOf(currentTime to Unit)
+
+  fun <A> Transactional<A>.sample(): A =
+    invoke(currentTime)
+
+  fun <A> TState<A>.sample(): A =
+    current.sample()
+}
+
+class FrpStateScope internal constructor(
+  time: Time,
+  internal val stopTime: Time,
+): FrpTransactionScope(time) {
+
+  fun <A, B> TFlow<A>.fold(
+    initialValue: B,
+    f: FrpTransactionScope.(B, A) -> B,
+  ): TState<B> {
+    val truncated =
+      dropWhile { (t, _) -> t < currentTime }
+        .takeWhile { (t, _) -> t <= stopTime }
+    val folded =
+      truncated
+        .scan(Pair(currentTime, initialValue)) { (_, b) (t, a) ->
+          Pair(t, FrpTransactionScope(t).f(a, b))
+        }
+    val lookup = { t1 ->
+      folded.lastOrNull { (t0, _) -> t0 < t1 }?.value ?: initialValue
+    }
+    return TState(lookup, folded.toSortedMap())
+  }
+
+  fun <A> TFlow<A>.hold(initialValue: A): TState<A> =
+    fold(initialValue) { _, a -> a }
+
+  fun <K, V> TFlow<Map<K, Maybe<V>>>.foldMapIncrementally(
+    initialValues: Map<K, V>
+  ): TState<Map<K, V>> =
+    fold(initialValues) { patch, map ->
+      val eithers = patch.map { (k, v) ->
+        if (v is Just) Left(k to v.value) else Right(k)
+      }
+      val adds = eithers.filterIsInstance<Left>().map { it.left }
+      val removes = eithers.filterIsInstance<Right>().map { it.right }
+      val removed: Map<K, V> = map - removes.toSet()
+      val updated: Map<K, V> = removed + adds
+      updated
+    }
+
+  fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally(
+    initialTFlows: Map<K, TFlow<V>>,
+  ): TFlow<Map<K, V>> =
+    foldMapIncrementally(initialTFlows).map { it.merge() }.switch()
+
+  fun <K, A, B> TFlow<Map<K, Maybe<A>>.mapLatestStatefulForKey(
+    transform: suspend FrpStateScope.(A) -> B,
+  ): TFlow<Map<K, Maybe<B>>> =
+    pairwise().map { ((t0, patch), (t1, _)) ->
+      patch.map { (k, ma) ->
+        ma.map { a ->
+          FrpStateScope(t0, t1).transform(a)
+        }
+      }
+    }
+  }
+
+}
+
+```
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/Combinators.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/Combinators.kt
new file mode 100644
index 0000000..8bf3a43
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/Combinators.kt
@@ -0,0 +1,250 @@
+/*
+ * 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.systemui.kairos
+
+import com.android.systemui.kairos.util.These
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.none
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.conflate
+
+/**
+ * Returns a [TFlow] that emits the value sampled from the [Transactional] produced by each emission
+ * of the original [TFlow], within the same transaction of the original emission.
+ */
+fun <A> TFlow<Transactional<A>>.sampleTransactionals(): TFlow<A> = map { it.sample() }
+
+/** @see FrpTransactionScope.sample */
+fun <A, B, C> TFlow<A>.sample(
+    state: TState<B>,
+    transform: suspend FrpTransactionScope.(A, B) -> C,
+): TFlow<C> = map { transform(it, state.sample()) }
+
+/** @see FrpTransactionScope.sample */
+fun <A, B, C> TFlow<A>.sample(
+    transactional: Transactional<B>,
+    transform: suspend FrpTransactionScope.(A, B) -> C,
+): TFlow<C> = map { transform(it, transactional.sample()) }
+
+/**
+ * Like [sample], but if [state] is changing at the time it is sampled ([stateChanges] is emitting),
+ * then the new value is passed to [transform].
+ *
+ * Note that [sample] is both more performant, and safer to use with recursive definitions. You will
+ * generally want to use it rather than this.
+ *
+ * @see sample
+ */
+fun <A, B, C> TFlow<A>.samplePromptly(
+    state: TState<B>,
+    transform: suspend FrpTransactionScope.(A, B) -> C,
+): TFlow<C> =
+    sample(state) { a, b -> These.thiz<Pair<A, B>, B>(a to b) }
+        .mergeWith(state.stateChanges.map { These.that(it) }) { thiz, that ->
+            These.both((thiz as These.This).thiz, (that as These.That).that)
+        }
+        .mapMaybe { these ->
+            when (these) {
+                // both present, transform the upstream value and the new value
+                is These.Both -> just(transform(these.thiz.first, these.that))
+                // no upstream present, so don't perform the sample
+                is These.That -> none()
+                // just the upstream, so transform the upstream and the old value
+                is These.This -> just(transform(these.thiz.first, these.thiz.second))
+            }
+        }
+
+/**
+ * Returns a [TState] containing a map with a snapshot of the current state of each [TState] in the
+ * original map.
+ */
+fun <K, A> Map<K, TState<A>>.combineValues(): TState<Map<K, A>> =
+    asIterable()
+        .map { (k, state) -> state.map { v -> k to v } }
+        .combine()
+        .map { entries -> entries.toMap() }
+
+/**
+ * Returns a cold [Flow] that, when collected, emits from this [TFlow]. [network] is needed to
+ * transactionally connect to / disconnect from the [TFlow] when collection starts/stops.
+ */
+fun <A> TFlow<A>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, emits from this [TState]. [network] is needed to
+ * transactionally connect to / disconnect from the [TState] when collection starts/stops.
+ */
+fun <A> TState<A>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, applies this [FrpSpec] in a new transaction in this
+ * [network], and then emits from the returned [TFlow].
+ *
+ * When collection is cancelled, so is the [FrpSpec]. This means all ongoing work is cleaned up.
+ */
+@JvmName("flowSpecToColdConflatedFlow")
+fun <A> FrpSpec<TFlow<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { applySpec().observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, applies this [FrpSpec] in a new transaction in this
+ * [network], and then emits from the returned [TState].
+ *
+ * When collection is cancelled, so is the [FrpSpec]. This means all ongoing work is cleaned up.
+ */
+@JvmName("stateSpecToColdConflatedFlow")
+fun <A> FrpSpec<TState<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { applySpec().observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, applies this [Transactional] in a new transaction in
+ * this [network], and then emits from the returned [TFlow].
+ */
+@JvmName("transactionalFlowToColdConflatedFlow")
+fun <A> Transactional<TFlow<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { sample().observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, applies this [Transactional] in a new transaction in
+ * this [network], and then emits from the returned [TState].
+ */
+@JvmName("transactionalStateToColdConflatedFlow")
+fun <A> Transactional<TState<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { sample().observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, applies this [FrpStateful] in a new transaction in
+ * this [network], and then emits from the returned [TFlow].
+ *
+ * When collection is cancelled, so is the [FrpStateful]. This means all ongoing work is cleaned up.
+ */
+@JvmName("statefulFlowToColdConflatedFlow")
+fun <A> FrpStateful<TFlow<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { applyStateful().observe { trySend(it) } } }.conflate()
+
+/**
+ * Returns a cold [Flow] that, when collected, applies this [Transactional] in a new transaction in
+ * this [network], and then emits from the returned [TState].
+ *
+ * When collection is cancelled, so is the [FrpStateful]. This means all ongoing work is cleaned up.
+ */
+@JvmName("statefulStateToColdConflatedFlow")
+fun <A> FrpStateful<TState<A>>.toColdConflatedFlow(network: FrpNetwork): Flow<A> =
+    channelFlow { network.activateSpec { applyStateful().observe { trySend(it) } } }.conflate()
+
+/** Return a [TFlow] that emits from the original [TFlow] only when [state] is `true`. */
+fun <A> TFlow<A>.filter(state: TState<Boolean>): TFlow<A> = filter { state.sample() }
+
+private fun Iterable<Boolean>.allTrue() = all { it }
+
+private fun Iterable<Boolean>.anyTrue() = any { it }
+
+/** Returns a [TState] that is `true` only when all of [states] are `true`. */
+fun allOf(vararg states: TState<Boolean>): TState<Boolean> = combine(*states) { it.allTrue() }
+
+/** Returns a [TState] that is `true` when any of [states] are `true`. */
+fun anyOf(vararg states: TState<Boolean>): TState<Boolean> = combine(*states) { it.anyTrue() }
+
+/** Returns a [TState] containing the inverse of the Boolean held by the original [TState]. */
+fun not(state: TState<Boolean>): TState<Boolean> = state.mapCheapUnsafe { !it }
+
+/**
+ * Represents a modal FRP sub-network.
+ *
+ * When [enabled][enableMode], all network modifications are applied immediately to the FRP network.
+ * When the returned [TFlow] emits a [FrpBuildMode], that mode is enabled and replaces this mode,
+ * undoing all modifications in the process (any registered [observers][FrpBuildScope.observe] are
+ * unregistered, and any pending [side-effects][FrpBuildScope.effect] are cancelled).
+ *
+ * Use [compiledFrpSpec] to compile and stand-up a mode graph.
+ *
+ * @see FrpStatefulMode
+ */
+fun interface FrpBuildMode<out A> {
+    /**
+     * Invoked when this mode is enabled. Returns a value and a [TFlow] that signals a switch to a
+     * new mode.
+     */
+    suspend fun FrpBuildScope.enableMode(): Pair<A, TFlow<FrpBuildMode<A>>>
+}
+
+/**
+ * Returns an [FrpSpec] that, when [applied][FrpBuildScope.applySpec], stands up a modal-transition
+ * graph starting with this [FrpBuildMode], automatically switching to new modes as they are
+ * produced.
+ *
+ * @see FrpBuildMode
+ */
+val <A> FrpBuildMode<A>.compiledFrpSpec: FrpSpec<TState<A>>
+    get() = frpSpec {
+        var modeChangeEvents by TFlowLoop<FrpBuildMode<A>>()
+        val activeMode: TState<Pair<A, TFlow<FrpBuildMode<A>>>> =
+            modeChangeEvents
+                .map { it.run { frpSpec { enableMode() } } }
+                .holdLatestSpec(frpSpec { enableMode() })
+        modeChangeEvents =
+            activeMode.map { statefully { it.second.nextOnly() } }.applyLatestStateful().switch()
+        activeMode.map { it.first }
+    }
+
+/**
+ * Represents a modal FRP sub-network.
+ *
+ * When [enabled][enableMode], all state accumulation is immediately started. When the returned
+ * [TFlow] emits a [FrpBuildMode], that mode is enabled and replaces this mode, stopping all state
+ * accumulation in the process.
+ *
+ * Use [compiledStateful] to compile and stand-up a mode graph.
+ *
+ * @see FrpBuildMode
+ */
+fun interface FrpStatefulMode<out A> {
+    /**
+     * Invoked when this mode is enabled. Returns a value and a [TFlow] that signals a switch to a
+     * new mode.
+     */
+    suspend fun FrpStateScope.enableMode(): Pair<A, TFlow<FrpStatefulMode<A>>>
+}
+
+/**
+ * Returns an [FrpStateful] that, when [applied][FrpStateScope.applyStateful], stands up a
+ * modal-transition graph starting with this [FrpStatefulMode], automatically switching to new modes
+ * as they are produced.
+ *
+ * @see FrpBuildMode
+ */
+val <A> FrpStatefulMode<A>.compiledStateful: FrpStateful<TState<A>>
+    get() = statefully {
+        var modeChangeEvents by TFlowLoop<FrpStatefulMode<A>>()
+        val activeMode: TState<Pair<A, TFlow<FrpStatefulMode<A>>>> =
+            modeChangeEvents
+                .map { it.run { statefully { enableMode() } } }
+                .holdLatestStateful(statefully { enableMode() })
+        modeChangeEvents =
+            activeMode.map { statefully { it.second.nextOnly() } }.applyLatestStateful().switch()
+        activeMode.map { it.first }
+    }
+
+/**
+ * Runs [spec] in this [FrpBuildScope], and then re-runs it whenever [rebuildSignal] emits. Returns
+ * a [TState] that holds the result of the currently-active [FrpSpec].
+ */
+fun <A> FrpBuildScope.rebuildOn(rebuildSignal: TFlow<*>, spec: FrpSpec<A>): TState<A> =
+    rebuildSignal.map { spec }.holdLatestSpec(spec)
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpBuildScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpBuildScope.kt
new file mode 100644
index 0000000..4de6deb
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpBuildScope.kt
@@ -0,0 +1,864 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.kairos
+
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.map
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.RestrictsSuspension
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.dropWhile
+import kotlinx.coroutines.launch
+
+/** A function that modifies the FrpNetwork. */
+typealias FrpSpec<A> = suspend FrpBuildScope.() -> A
+
+/**
+ * Constructs an [FrpSpec]. The passed [block] will be invoked with an [FrpBuildScope] that can be
+ * used to perform network-building operations, including adding new inputs and outputs to the
+ * network, as well as all operations available in [FrpTransactionScope].
+ */
+@ExperimentalFrpApi
+@Suppress("NOTHING_TO_INLINE")
+inline fun <A> frpSpec(noinline block: suspend FrpBuildScope.() -> A): FrpSpec<A> = block
+
+/** Applies the [FrpSpec] within this [FrpBuildScope]. */
+@ExperimentalFrpApi
+inline operator fun <A> FrpBuildScope.invoke(block: FrpBuildScope.() -> A) = run(block)
+
+/** Operations that add inputs and outputs to an FRP network. */
+@ExperimentalFrpApi
+@RestrictsSuspension
+interface FrpBuildScope : FrpStateScope {
+
+    /** TODO: Javadoc */
+    @ExperimentalFrpApi
+    fun <R> deferredBuildScope(block: suspend FrpBuildScope.() -> R): FrpDeferredValue<R>
+
+    /** TODO: Javadoc */
+    @ExperimentalFrpApi fun deferredBuildScopeAction(block: suspend FrpBuildScope.() -> Unit)
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow].
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * Unlike [mapLatestBuild], these modifications are not undone with each subsequent emission of
+     * the original [TFlow].
+     *
+     * **NOTE:** This API does not [observe] the original [TFlow], meaning that unless the returned
+     * (or a downstream) [TFlow] is observed separately, [transform] will not be invoked, and no
+     * internal side-effects will occur.
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapBuild(transform: suspend FrpBuildScope.(A) -> B): TFlow<B>
+
+    /**
+     * Invokes [block] whenever this [TFlow] emits a value, allowing side-effects to be safely
+     * performed in reaction to the emission.
+     *
+     * Specifically, [block] is deferred to the end of the transaction, and is only actually
+     * executed if this [FrpBuildScope] is still active by that time. It can be deactivated due to a
+     * -Latest combinator, for example.
+     *
+     * Shorthand for:
+     * ```kotlin
+     *   tFlow.observe { effect { ... } }
+     * ```
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.observe(
+        coroutineContext: CoroutineContext = EmptyCoroutineContext,
+        block: suspend FrpEffectScope.(A) -> Unit = {},
+    ): Job
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original
+     * [TFlow], and a [FrpDeferredValue] containing the result of applying [initialSpecs]
+     * immediately.
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same
+     * key are undone (any registered [observers][observe] are unregistered, and any pending
+     * [side-effects][effect] are cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpSpec] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey(
+        initialSpecs: FrpDeferredValue<Map<K, FrpSpec<B>>>,
+        numKeys: Int? = null,
+    ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>>
+
+    /**
+     * Creates an instance of a [TFlow] with elements that are from [builder].
+     *
+     * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the
+     * provided [MutableTFlow].
+     *
+     * By default, [builder] is only running while the returned [TFlow] is being
+     * [observed][observe]. If you want it to run at all times, simply add a no-op observer:
+     * ```kotlin
+     * tFlow { ... }.apply { observe() }
+     * ```
+     */
+    @ExperimentalFrpApi fun <T> tFlow(builder: suspend FrpProducerScope<T>.() -> Unit): TFlow<T>
+
+    /**
+     * Creates an instance of a [TFlow] with elements that are emitted from [builder].
+     *
+     * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the
+     * provided [MutableTFlow].
+     *
+     * By default, [builder] is only running while the returned [TFlow] is being
+     * [observed][observe]. If you want it to run at all times, simply add a no-op observer:
+     * ```kotlin
+     * tFlow { ... }.apply { observe() }
+     * ```
+     *
+     * In the event of backpressure, emissions are *coalesced* into batches. When a value is
+     * [emitted][FrpCoalescingProducerScope.emit] from [builder], it is merged into the batch via
+     * [coalesce]. Once the batch is consumed by the frp network in the next transaction, the batch
+     * is reset back to [getInitialValue].
+     */
+    @ExperimentalFrpApi
+    fun <In, Out> coalescingTFlow(
+        getInitialValue: () -> Out,
+        coalesce: (old: Out, new: In) -> Out,
+        builder: suspend FrpCoalescingProducerScope<In>.() -> Unit,
+    ): TFlow<Out>
+
+    /**
+     * Creates a new [FrpBuildScope] that is a child of this one.
+     *
+     * This new scope can be manually cancelled via the returned [Job], or will be cancelled
+     * automatically when its parent is cancelled. Cancellation will unregister all
+     * [observers][observe] and cancel all scheduled [effects][effect].
+     *
+     * The return value from [block] can be accessed via the returned [FrpDeferredValue].
+     */
+    @ExperimentalFrpApi fun <A> asyncScope(block: FrpSpec<A>): Pair<FrpDeferredValue<A>, Job>
+
+    // TODO: once we have context params, these can all become extensions:
+
+    /**
+     * Returns a [TFlow] containing the results of applying the given [transform] function to each
+     * value of the original [TFlow].
+     *
+     * Unlike [TFlow.map], [transform] can perform arbitrary asynchronous code. This code is run
+     * outside of the current FRP transaction; when [transform] returns, the returned value is
+     * emitted from the result [TFlow] in a new transaction.
+     *
+     * Shorthand for:
+     * ```kotlin
+     * tflow.mapLatestBuild { a -> asyncTFlow { transform(a) } }.flatten()
+     * ```
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapAsyncLatest(transform: suspend (A) -> B): TFlow<B> =
+        mapLatestBuild { a -> asyncTFlow { transform(a) } }.flatten()
+
+    /**
+     * Invokes [block] whenever this [TFlow] emits a value. [block] receives an [FrpBuildScope] that
+     * can be used to make further modifications to the FRP network, and/or perform side-effects via
+     * [effect].
+     *
+     * @see observe
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.observeBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job =
+        mapBuild(block).observe()
+
+    /**
+     * Returns a [StateFlow] whose [value][StateFlow.value] tracks the current
+     * [value of this TState][TState.sample], and will emit at the same rate as
+     * [TState.stateChanges].
+     *
+     * Note that the [value][StateFlow.value] is not available until the *end* of the current
+     * transaction. If you need the current value before this time, then use [TState.sample].
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<A>.toStateFlow(): StateFlow<A> {
+        val uninitialized = Any()
+        var initialValue: Any? = uninitialized
+        val innerStateFlow = MutableStateFlow<Any?>(uninitialized)
+        deferredBuildScope {
+            initialValue = sample()
+            stateChanges.observe {
+                innerStateFlow.value = it
+                initialValue = null
+            }
+        }
+
+        @Suppress("UNCHECKED_CAST")
+        fun getValue(innerValue: Any?): A =
+            when {
+                innerValue !== uninitialized -> innerValue as A
+                initialValue !== uninitialized -> initialValue as A
+                else ->
+                    error(
+                        "Attempted to access StateFlow.value before FRP transaction has completed."
+                    )
+            }
+
+        return object : StateFlow<A> {
+            override val replayCache: List<A>
+                get() = innerStateFlow.replayCache.map(::getValue)
+
+            override val value: A
+                get() = getValue(innerStateFlow.value)
+
+            override suspend fun collect(collector: FlowCollector<A>): Nothing {
+                innerStateFlow.collect { collector.emit(getValue(it)) }
+            }
+        }
+    }
+
+    /**
+     * Returns a [SharedFlow] configured with a replay cache of size [replay] that emits the current
+     * [value][TState.sample] of this [TState] followed by all [stateChanges].
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<A>.toSharedFlow(replay: Int = 0): SharedFlow<A> {
+        val result = MutableSharedFlow<A>(replay, extraBufferCapacity = 1)
+        deferredBuildScope {
+            result.tryEmit(sample())
+            stateChanges.observe { a -> result.tryEmit(a) }
+        }
+        return result
+    }
+
+    /**
+     * Returns a [SharedFlow] configured with a replay cache of size [replay] that emits values
+     * whenever this [TFlow] emits.
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.toSharedFlow(replay: Int = 0): SharedFlow<A> {
+        val result = MutableSharedFlow<A>(replay, extraBufferCapacity = 1)
+        observe { a -> result.tryEmit(a) }
+        return result
+    }
+
+    /**
+     * Returns a [TState] that holds onto the value returned by applying the most recently emitted
+     * [FrpSpec] from the original [TFlow], or the value returned by applying [initialSpec] if
+     * nothing has been emitted since it was constructed.
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<FrpSpec<A>>.holdLatestSpec(initialSpec: FrpSpec<A>): TState<A> {
+        val (changes: TFlow<A>, initApplied: FrpDeferredValue<A>) = applyLatestSpec(initialSpec)
+        return changes.holdDeferred(initApplied)
+    }
+
+    /**
+     * Returns a [TState] containing the value returned by applying the [FrpSpec] held by the
+     * original [TState].
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<FrpSpec<A>>.applyLatestSpec(): TState<A> {
+        val (appliedChanges: TFlow<A>, init: FrpDeferredValue<A>) =
+            stateChanges.applyLatestSpec(frpSpec { sample().applySpec() })
+        return appliedChanges.holdDeferred(init)
+    }
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original
+     * [TFlow].
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<FrpSpec<A>>.applyLatestSpec(): TFlow<A> = applyLatestSpec(frpSpec {}).first
+
+    /**
+     * Returns a [TFlow] that switches to a new [TFlow] produced by [transform] every time the
+     * original [TFlow] emits a value.
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * When the original [TFlow] emits a new value, those changes are undone (any registered
+     * [observers][observe] are unregistered, and any pending [effects][effect] are cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.flatMapLatestBuild(
+        transform: suspend FrpBuildScope.(A) -> TFlow<B>
+    ): TFlow<B> = mapCheap { frpSpec { transform(it) } }.applyLatestSpec().flatten()
+
+    /**
+     * Returns a [TState] by applying [transform] to the value held by the original [TState].
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * When the value held by the original [TState] changes, those changes are undone (any
+     * registered [observers][observe] are unregistered, and any pending [effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TState<A>.flatMapLatestBuild(
+        transform: suspend FrpBuildScope.(A) -> TState<B>
+    ): TState<B> = mapLatestBuild { transform(it) }.flatten()
+
+    /**
+     * Returns a [TState] that transforms the value held inside this [TState] by applying it to the
+     * [transform].
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * When the value held by the original [TState] changes, those changes are undone (any
+     * registered [observers][observe] are unregistered, and any pending [effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TState<A>.mapLatestBuild(transform: suspend FrpBuildScope.(A) -> B): TState<B> =
+        mapCheapUnsafe { frpSpec { transform(it) } }.applyLatestSpec()
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original
+     * [TFlow], and a [FrpDeferredValue] containing the result of applying [initialSpec]
+     * immediately.
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A : Any?, B> TFlow<FrpSpec<B>>.applyLatestSpec(
+        initialSpec: FrpSpec<A>
+    ): Pair<TFlow<B>, FrpDeferredValue<A>> {
+        val (flow, result) =
+            mapCheap { spec -> mapOf(Unit to just(spec)) }
+                .applyLatestSpecForKey(initialSpecs = mapOf(Unit to initialSpec), numKeys = 1)
+        val outFlow: TFlow<B> =
+            flow.mapMaybe {
+                checkNotNull(it[Unit]) { "applyLatest: expected result, but none present in: $it" }
+            }
+        val outInit: FrpDeferredValue<A> = deferredBuildScope {
+            val initResult: Map<Unit, A> = result.get()
+            check(Unit in initResult) {
+                "applyLatest: expected initial result, but none present in: $initResult"
+            }
+            @Suppress("UNCHECKED_CAST")
+            initResult.getOrDefault(Unit) { null } as A
+        }
+        return Pair(outFlow, outInit)
+    }
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow].
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * With each invocation of [transform], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapLatestBuild(transform: suspend FrpBuildScope.(A) -> B): TFlow<B> =
+        mapCheap { frpSpec { transform(it) } }.applyLatestSpec()
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to
+     * [initialValue] immediately.
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * With each invocation of [transform], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapLatestBuild(
+        initialValue: A,
+        transform: suspend FrpBuildScope.(A) -> B,
+    ): Pair<TFlow<B>, FrpDeferredValue<B>> =
+        mapLatestBuildDeferred(deferredOf(initialValue), transform)
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to
+     * [initialValue] immediately.
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * With each invocation of [transform], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapLatestBuildDeferred(
+        initialValue: FrpDeferredValue<A>,
+        transform: suspend FrpBuildScope.(A) -> B,
+    ): Pair<TFlow<B>, FrpDeferredValue<B>> =
+        mapCheap { frpSpec { transform(it) } }
+            .applyLatestSpec(initialSpec = frpSpec { transform(initialValue.get()) })
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original
+     * [TFlow], and a [FrpDeferredValue] containing the result of applying [initialSpecs]
+     * immediately.
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same
+     * key are undone (any registered [observers][observe] are unregistered, and any pending
+     * [side-effects][effect] are cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpSpec] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey(
+        initialSpecs: Map<K, FrpSpec<B>>,
+        numKeys: Int? = null,
+    ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> =
+        applyLatestSpecForKey(deferredOf(initialSpecs), numKeys)
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpSpec] emitted from the original
+     * [TFlow].
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same
+     * key are undone (any registered [observers][observe] are unregistered, and any pending
+     * [side-effects][effect] are cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpSpec] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey(
+        numKeys: Int? = null
+    ): TFlow<Map<K, Maybe<A>>> =
+        applyLatestSpecForKey<K, A, Nothing>(deferredOf(emptyMap()), numKeys).first
+
+    /**
+     * Returns a [TState] containing the latest results of applying each [FrpSpec] emitted from the
+     * original [TFlow].
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same
+     * key are undone (any registered [observers][observe] are unregistered, and any pending
+     * [side-effects][effect] are cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpSpec] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A> TFlow<Map<K, Maybe<FrpSpec<A>>>>.holdLatestSpecForKey(
+        initialSpecs: FrpDeferredValue<Map<K, FrpSpec<A>>>,
+        numKeys: Int? = null,
+    ): TState<Map<K, A>> {
+        val (changes, initialValues) = applyLatestSpecForKey(initialSpecs, numKeys)
+        return changes.foldMapIncrementally(initialValues)
+    }
+
+    /**
+     * Returns a [TState] containing the latest results of applying each [FrpSpec] emitted from the
+     * original [TFlow].
+     *
+     * When each [FrpSpec] is applied, changes from the previously-active [FrpSpec] with the same
+     * key are undone (any registered [observers][observe] are unregistered, and any pending
+     * [side-effects][effect] are cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpSpec] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A> TFlow<Map<K, Maybe<FrpSpec<A>>>>.holdLatestSpecForKey(
+        initialSpecs: Map<K, FrpSpec<A>> = emptyMap(),
+        numKeys: Int? = null,
+    ): TState<Map<K, A>> = holdLatestSpecForKey(deferredOf(initialSpecs), numKeys)
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to
+     * [initialValues] immediately.
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * With each invocation of [transform], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpBuildScope] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestBuildForKey(
+        initialValues: FrpDeferredValue<Map<K, A>>,
+        numKeys: Int? = null,
+        transform: suspend FrpBuildScope.(A) -> B,
+    ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> =
+        map { patch -> patch.mapValues { (_, v) -> v.map { frpSpec { transform(it) } } } }
+            .applyLatestSpecForKey(
+                deferredBuildScope {
+                    initialValues.get().mapValues { (_, v) -> frpSpec { transform(v) } }
+                },
+                numKeys = numKeys,
+            )
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to
+     * [initialValues] immediately.
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * With each invocation of [transform], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpBuildScope] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestBuildForKey(
+        initialValues: Map<K, A>,
+        numKeys: Int? = null,
+        transform: suspend FrpBuildScope.(A) -> B,
+    ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> =
+        mapLatestBuildForKey(deferredOf(initialValues), numKeys, transform)
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow].
+     *
+     * [transform] can perform modifications to the FRP network via its [FrpBuildScope] receiver.
+     * With each invocation of [transform], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpBuildScope] will be undone with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestBuildForKey(
+        numKeys: Int? = null,
+        transform: suspend FrpBuildScope.(A) -> B,
+    ): TFlow<Map<K, Maybe<B>>> = mapLatestBuildForKey(emptyMap(), numKeys, transform).first
+
+    /** Returns a [Deferred] containing the next value to be emitted from this [TFlow]. */
+    @ExperimentalFrpApi
+    fun <R> TFlow<R>.nextDeferred(): Deferred<R> {
+        lateinit var next: CompletableDeferred<R>
+        val job = nextOnly().observe { next.complete(it) }
+        next = CompletableDeferred<R>(parent = job)
+        return next
+    }
+
+    /** Returns a [TState] that reflects the [StateFlow.value] of this [StateFlow]. */
+    @ExperimentalFrpApi
+    fun <A> StateFlow<A>.toTState(): TState<A> {
+        val initial = value
+        return tFlow { dropWhile { it == initial }.collect { emit(it) } }.hold(initial)
+    }
+
+    /** Returns a [TFlow] that emits whenever this [Flow] emits. */
+    @ExperimentalFrpApi fun <A> Flow<A>.toTFlow(): TFlow<A> = tFlow { collect { emit(it) } }
+
+    /**
+     * Shorthand for:
+     * ```kotlin
+     * flow.toTFlow().hold(initialValue)
+     * ```
+     */
+    @ExperimentalFrpApi
+    fun <A> Flow<A>.toTState(initialValue: A): TState<A> = toTFlow().hold(initialValue)
+
+    /**
+     * Invokes [block] whenever this [TFlow] emits a value. [block] receives an [FrpBuildScope] that
+     * can be used to make further modifications to the FRP network, and/or perform side-effects via
+     * [effect].
+     *
+     * With each invocation of [block], changes from the previous invocation are undone (any
+     * registered [observers][observe] are unregistered, and any pending [side-effects][effect] are
+     * cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.observeLatestBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job =
+        mapLatestBuild { block(it) }.observe()
+
+    /**
+     * Invokes [block] whenever this [TFlow] emits a value, allowing side-effects to be safely
+     * performed in reaction to the emission.
+     *
+     * With each invocation of [block], running effects from the previous invocation are cancelled.
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.observeLatest(block: suspend FrpEffectScope.(A) -> Unit = {}): Job {
+        var innerJob: Job? = null
+        return observeBuild {
+            innerJob?.cancel()
+            innerJob = effect { block(it) }
+        }
+    }
+
+    /**
+     * Invokes [block] with the value held by this [TState], allowing side-effects to be safely
+     * performed in reaction to the state changing.
+     *
+     * With each invocation of [block], running effects from the previous invocation are cancelled.
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<A>.observeLatest(block: suspend FrpEffectScope.(A) -> Unit = {}): Job =
+        launchScope {
+            var innerJob = effect { block(sample()) }
+            stateChanges.observeBuild {
+                innerJob.cancel()
+                innerJob = effect { block(it) }
+            }
+        }
+
+    /**
+     * Applies [block] to the value held by this [TState]. [block] receives an [FrpBuildScope] that
+     * can be used to make further modifications to the FRP network, and/or perform side-effects via
+     * [effect].
+     *
+     * [block] can perform modifications to the FRP network via its [FrpBuildScope] receiver. With
+     * each invocation of [block], changes from the previous invocation are undone (any registered
+     * [observers][observe] are unregistered, and any pending [side-effects][effect] are cancelled).
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<A>.observeLatestBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job =
+        launchScope {
+            var innerJob: Job = launchScope { block(sample()) }
+            stateChanges.observeBuild {
+                innerJob.cancel()
+                innerJob = launchScope { block(it) }
+            }
+        }
+
+    /** Applies the [FrpSpec] within this [FrpBuildScope]. */
+    @ExperimentalFrpApi suspend fun <A> FrpSpec<A>.applySpec(): A = this()
+
+    /**
+     * Applies the [FrpSpec] within this [FrpBuildScope], returning the result as an
+     * [FrpDeferredValue].
+     */
+    @ExperimentalFrpApi
+    fun <A> FrpSpec<A>.applySpecDeferred(): FrpDeferredValue<A> = deferredBuildScope { applySpec() }
+
+    /**
+     * Invokes [block] on the value held in this [TState]. [block] receives an [FrpBuildScope] that
+     * can be used to make further modifications to the FRP network, and/or perform side-effects via
+     * [effect].
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<A>.observeBuild(block: suspend FrpBuildScope.(A) -> Unit = {}): Job =
+        launchScope {
+            block(sample())
+            stateChanges.observeBuild(block)
+        }
+
+    /**
+     * Invokes [block] with the current value of this [TState], re-invoking whenever it changes,
+     * allowing side-effects to be safely performed in reaction value changing.
+     *
+     * Specifically, [block] is deferred to the end of the transaction, and is only actually
+     * executed if this [FrpBuildScope] is still active by that time. It can be deactivated due to a
+     * -Latest combinator, for example.
+     *
+     * If the [TState] is changing within the *current* transaction (i.e. [stateChanges] is
+     * presently emitting) then [block] will be invoked for the first time with the new value;
+     * otherwise, it will be invoked with the [current][sample] value.
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<A>.observe(block: suspend FrpEffectScope.(A) -> Unit = {}): Job =
+        now.map { sample() }.mergeWith(stateChanges) { _, new -> new }.observe { block(it) }
+}
+
+/**
+ * Returns a [TFlow] that emits the result of [block] once it completes. [block] is evaluated
+ * outside of the current FRP transaction; when it completes, the returned [TFlow] emits in a new
+ * transaction.
+ *
+ * Shorthand for:
+ * ```
+ * tFlow { emitter: MutableTFlow<A> ->
+ *     val a = block()
+ *     emitter.emit(a)
+ * }
+ * ```
+ */
+@ExperimentalFrpApi
+fun <A> FrpBuildScope.asyncTFlow(block: suspend () -> A): TFlow<A> =
+    tFlow {
+            // TODO: if block completes synchronously, it would be nice to emit within this
+            //  transaction
+            emit(block())
+        }
+        .apply { observe() }
+
+/**
+ * Performs a side-effect in a safe manner w/r/t the current FRP transaction.
+ *
+ * Specifically, [block] is deferred to the end of the current transaction, and is only actually
+ * executed if this [FrpBuildScope] is still active by that time. It can be deactivated due to a
+ * -Latest combinator, for example.
+ *
+ * Shorthand for:
+ * ```kotlin
+ *   now.observe { block() }
+ * ```
+ */
+@ExperimentalFrpApi
+fun FrpBuildScope.effect(block: suspend FrpEffectScope.() -> Unit): Job = now.observe { block() }
+
+/**
+ * Launches [block] in a new coroutine, returning a [Job] bound to the coroutine.
+ *
+ * This coroutine is not actually started until the *end* of the current FRP transaction. This is
+ * done because the current [FrpBuildScope] might be deactivated within this transaction, perhaps
+ * due to a -Latest combinator. If this happens, then the coroutine will never actually be started.
+ *
+ * Shorthand for:
+ * ```kotlin
+ *   effect { frpCoroutineScope.launch { block() } }
+ * ```
+ */
+@ExperimentalFrpApi
+fun FrpBuildScope.launchEffect(block: suspend CoroutineScope.() -> Unit): Job = asyncEffect(block)
+
+/**
+ * Launches [block] in a new coroutine, returning the result as a [Deferred].
+ *
+ * This coroutine is not actually started until the *end* of the current FRP transaction. This is
+ * done because the current [FrpBuildScope] might be deactivated within this transaction, perhaps
+ * due to a -Latest combinator. If this happens, then the coroutine will never actually be started.
+ *
+ * Shorthand for:
+ * ```kotlin
+ *   CompletableDeferred<R>.apply {
+ *       effect { frpCoroutineScope.launch { complete(coroutineScope { block() }) } }
+ *     }
+ *     .await()
+ * ```
+ */
+@ExperimentalFrpApi
+fun <R> FrpBuildScope.asyncEffect(block: suspend CoroutineScope.() -> R): Deferred<R> {
+    val result = CompletableDeferred<R>()
+    val job = now.observe { frpCoroutineScope.launch { result.complete(coroutineScope(block)) } }
+    val handle = job.invokeOnCompletion { result.cancel() }
+    result.invokeOnCompletion {
+        handle.dispose()
+        job.cancel()
+    }
+    return result
+}
+
+/** Like [FrpBuildScope.asyncScope], but ignores the result of [block]. */
+@ExperimentalFrpApi fun FrpBuildScope.launchScope(block: FrpSpec<*>): Job = asyncScope(block).second
+
+/**
+ * Creates an instance of a [TFlow] with elements that are emitted from [builder].
+ *
+ * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the provided
+ * [MutableTFlow].
+ *
+ * By default, [builder] is only running while the returned [TFlow] is being
+ * [observed][FrpBuildScope.observe]. If you want it to run at all times, simply add a no-op
+ * observer:
+ * ```kotlin
+ * tFlow { ... }.apply { observe() }
+ * ```
+ *
+ * In the event of backpressure, emissions are *coalesced* into batches. When a value is
+ * [emitted][FrpCoalescingProducerScope.emit] from [builder], it is merged into the batch via
+ * [coalesce]. Once the batch is consumed by the FRP network in the next transaction, the batch is
+ * reset back to [initialValue].
+ */
+@ExperimentalFrpApi
+fun <In, Out> FrpBuildScope.coalescingTFlow(
+    initialValue: Out,
+    coalesce: (old: Out, new: In) -> Out,
+    builder: suspend FrpCoalescingProducerScope<In>.() -> Unit,
+): TFlow<Out> = coalescingTFlow(getInitialValue = { initialValue }, coalesce, builder)
+
+/**
+ * Creates an instance of a [TFlow] with elements that are emitted from [builder].
+ *
+ * [builder] is run in its own coroutine, allowing for ongoing work that can emit to the provided
+ * [MutableTFlow].
+ *
+ * By default, [builder] is only running while the returned [TFlow] is being
+ * [observed][FrpBuildScope.observe]. If you want it to run at all times, simply add a no-op
+ * observer:
+ * ```kotlin
+ * tFlow { ... }.apply { observe() }
+ * ```
+ *
+ * In the event of backpressure, emissions are *conflated*; any older emissions are dropped and only
+ * the most recent emission will be used when the FRP network is ready.
+ */
+@ExperimentalFrpApi
+fun <T> FrpBuildScope.conflatedTFlow(
+    builder: suspend FrpCoalescingProducerScope<T>.() -> Unit
+): TFlow<T> =
+    coalescingTFlow<T, Any?>(initialValue = Any(), coalesce = { _, new -> new }, builder = builder)
+        .mapCheap {
+            @Suppress("UNCHECKED_CAST")
+            it as T
+        }
+
+/** Scope for emitting to a [FrpBuildScope.coalescingTFlow]. */
+interface FrpCoalescingProducerScope<in T> {
+    /**
+     * Inserts [value] into the current batch, enqueueing it for emission from this [TFlow] if not
+     * already pending.
+     *
+     * Backpressure occurs when [emit] is called while the FRP network is currently in a
+     * transaction; if called multiple times, then emissions will be coalesced into a single batch
+     * that is then processed when the network is ready.
+     */
+    fun emit(value: T)
+}
+
+/** Scope for emitting to a [FrpBuildScope.tFlow]. */
+interface FrpProducerScope<in T> {
+    /**
+     * Emits a [value] to this [TFlow], suspending the caller until the FRP transaction containing
+     * the emission has completed.
+     */
+    suspend fun emit(value: T)
+}
+
+/**
+ * Suspends forever. Upon cancellation, runs [block]. Useful for unregistering callbacks inside of
+ * [FrpBuildScope.tFlow] and [FrpBuildScope.coalescingTFlow].
+ */
+suspend fun awaitClose(block: () -> Unit): Nothing =
+    try {
+        awaitCancellation()
+    } finally {
+        block()
+    }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpEffectScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpEffectScope.kt
new file mode 100644
index 0000000..be2eb43
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpEffectScope.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.systemui.kairos
+
+import kotlin.coroutines.RestrictsSuspension
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * Scope for external side-effects triggered by the Frp network. This still occurs within the
+ * context of a transaction, so general suspending calls are disallowed to prevent blocking the
+ * transaction. You can use [frpCoroutineScope] to [launch] new coroutines to perform long-running
+ * asynchronous work. This scope is alive for the duration of the containing [FrpBuildScope] that
+ * this side-effect scope is running in.
+ */
+@RestrictsSuspension
+@ExperimentalFrpApi
+interface FrpEffectScope : FrpTransactionScope {
+    /**
+     * A [CoroutineScope] whose lifecycle lives for as long as this [FrpEffectScope] is alive. This
+     * is generally until the [Job] returned by [FrpBuildScope.effect] is cancelled.
+     */
+    @ExperimentalFrpApi val frpCoroutineScope: CoroutineScope
+
+    /**
+     * A [FrpNetwork] instance that can be used to transactionally query / modify the FRP network.
+     *
+     * The lambda passed to [FrpNetwork.transact] on this instance will receive an [FrpBuildScope]
+     * that is lifetime-bound to this [FrpEffectScope]. Once this [FrpEffectScope] is no longer
+     * alive, any modifications to the FRP network performed via this [FrpNetwork] instance will be
+     * undone (any registered [observers][FrpBuildScope.observe] are unregistered, and any pending
+     * [side-effects][FrpBuildScope.effect] are cancelled).
+     */
+    @ExperimentalFrpApi val frpNetwork: FrpNetwork
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpNetwork.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpNetwork.kt
new file mode 100644
index 0000000..b688eaf
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpNetwork.kt
@@ -0,0 +1,195 @@
+/*
+ * 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.systemui.kairos
+
+import com.android.systemui.kairos.internal.BuildScopeImpl
+import com.android.systemui.kairos.internal.Network
+import com.android.systemui.kairos.internal.StateScopeImpl
+import com.android.systemui.kairos.internal.util.awaitCancellationAndThen
+import com.android.systemui.kairos.internal.util.childScope
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.coroutineContext
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+
+/**
+ * Marks declarations that are still **experimental** and shouldn't be used in general production
+ * code.
+ */
+@RequiresOptIn(
+    message = "This API is experimental and should not be used in general production code."
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalFrpApi
+
+/**
+ * External interface to an FRP network. Can be used to make transactional queries and modifications
+ * to the network.
+ */
+@ExperimentalFrpApi
+interface FrpNetwork {
+    /**
+     * Runs [block] inside of a transaction, suspending until the transaction is complete.
+     *
+     * The [FrpBuildScope] receiver exposes methods that can be used to query or modify the network.
+     * If the network is cancelled while the caller of [transact] is suspended, then the call will
+     * be cancelled.
+     */
+    @ExperimentalFrpApi suspend fun <R> transact(block: suspend FrpTransactionScope.() -> R): R
+
+    /**
+     * Activates [spec] in a transaction, suspending indefinitely. While suspended, all observers
+     * and long-running effects are kept alive. When cancelled, observers are unregistered and
+     * effects are cancelled.
+     */
+    @ExperimentalFrpApi suspend fun activateSpec(spec: FrpSpec<*>)
+
+    /** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */
+    @ExperimentalFrpApi
+    fun <In, Out> coalescingMutableTFlow(
+        coalesce: (old: Out, new: In) -> Out,
+        getInitialValue: () -> Out,
+    ): CoalescingMutableTFlow<In, Out>
+
+    /** Returns a [MutableTFlow] that can emit values into this [FrpNetwork]. */
+    @ExperimentalFrpApi fun <T> mutableTFlow(): MutableTFlow<T>
+
+    /** Returns a [MutableTState]. with initial state [initialValue]. */
+    @ExperimentalFrpApi
+    fun <T> mutableTStateDeferred(initialValue: FrpDeferredValue<T>): MutableTState<T>
+}
+
+/** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */
+@ExperimentalFrpApi
+fun <In, Out> FrpNetwork.coalescingMutableTFlow(
+    coalesce: (old: Out, new: In) -> Out,
+    initialValue: Out,
+): CoalescingMutableTFlow<In, Out> =
+    coalescingMutableTFlow(coalesce, getInitialValue = { initialValue })
+
+/** Returns a [MutableTState]. with initial state [initialValue]. */
+@ExperimentalFrpApi
+fun <T> FrpNetwork.mutableTState(initialValue: T): MutableTState<T> =
+    mutableTStateDeferred(deferredOf(initialValue))
+
+/** Returns a [MutableTState]. with initial state [initialValue]. */
+@ExperimentalFrpApi
+fun <T> MutableTState(network: FrpNetwork, initialValue: T): MutableTState<T> =
+    network.mutableTState(initialValue)
+
+/** Returns a [MutableTFlow] that can emit values into this [FrpNetwork]. */
+@ExperimentalFrpApi
+fun <T> MutableTFlow(network: FrpNetwork): MutableTFlow<T> = network.mutableTFlow()
+
+/** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */
+@ExperimentalFrpApi
+fun <In, Out> CoalescingMutableTFlow(
+    network: FrpNetwork,
+    coalesce: (old: Out, new: In) -> Out,
+    initialValue: Out,
+): CoalescingMutableTFlow<In, Out> = network.coalescingMutableTFlow(coalesce) { initialValue }
+
+/** Returns a [CoalescingMutableTFlow] that can emit values into this [FrpNetwork]. */
+@ExperimentalFrpApi
+fun <In, Out> CoalescingMutableTFlow(
+    network: FrpNetwork,
+    coalesce: (old: Out, new: In) -> Out,
+    getInitialValue: () -> Out,
+): CoalescingMutableTFlow<In, Out> = network.coalescingMutableTFlow(coalesce, getInitialValue)
+
+/**
+ * Activates [spec] in a transaction and invokes [block] with the result, suspending indefinitely.
+ * While suspended, all observers and long-running effects are kept alive. When cancelled, observers
+ * are unregistered and effects are cancelled.
+ */
+@ExperimentalFrpApi
+suspend fun <R> FrpNetwork.activateSpec(spec: FrpSpec<R>, block: suspend (R) -> Unit) {
+    activateSpec {
+        val result = spec.applySpec()
+        launchEffect { block(result) }
+    }
+}
+
+internal class LocalFrpNetwork(
+    private val network: Network,
+    private val scope: CoroutineScope,
+    private val endSignal: TFlow<Any>,
+) : FrpNetwork {
+    override suspend fun <R> transact(block: suspend FrpTransactionScope.() -> R): R {
+        val result = CompletableDeferred<R>(coroutineContext[Job])
+        @Suppress("DeferredResultUnused")
+        network.transaction {
+            val buildScope =
+                BuildScopeImpl(
+                    stateScope = StateScopeImpl(evalScope = this, endSignal = endSignal),
+                    coroutineScope = scope,
+                )
+            buildScope.runInBuildScope { effect { result.complete(block()) } }
+        }
+        return result.await()
+    }
+
+    override suspend fun activateSpec(spec: FrpSpec<*>) {
+        val job =
+            network
+                .transaction {
+                    val buildScope =
+                        BuildScopeImpl(
+                            stateScope = StateScopeImpl(evalScope = this, endSignal = endSignal),
+                            coroutineScope = scope,
+                        )
+                    buildScope.runInBuildScope { launchScope(spec) }
+                }
+                .await()
+        awaitCancellationAndThen { job.cancel() }
+    }
+
+    override fun <In, Out> coalescingMutableTFlow(
+        coalesce: (old: Out, new: In) -> Out,
+        getInitialValue: () -> Out,
+    ): CoalescingMutableTFlow<In, Out> = CoalescingMutableTFlow(coalesce, network, getInitialValue)
+
+    override fun <T> mutableTFlow(): MutableTFlow<T> = MutableTFlow(network)
+
+    override fun <T> mutableTStateDeferred(initialValue: FrpDeferredValue<T>): MutableTState<T> =
+        MutableTState(network, initialValue.unwrapped)
+}
+
+/**
+ * Combination of an [FrpNetwork] and a [Job] that, when cancelled, will cancel the entire FRP
+ * network.
+ */
+@ExperimentalFrpApi
+class RootFrpNetwork
+internal constructor(private val network: Network, private val scope: CoroutineScope, job: Job) :
+    Job by job, FrpNetwork by LocalFrpNetwork(network, scope, emptyTFlow)
+
+/** Constructs a new [RootFrpNetwork] in the given [CoroutineScope]. */
+@ExperimentalFrpApi
+fun CoroutineScope.newFrpNetwork(
+    context: CoroutineContext = EmptyCoroutineContext
+): RootFrpNetwork {
+    val scope = childScope(context)
+    val network = Network(scope)
+    scope.launch(CoroutineName("newFrpNetwork scheduler")) { network.runInputScheduler() }
+    return RootFrpNetwork(network, scope, scope.coroutineContext.job)
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpScope.kt
new file mode 100644
index 0000000..ad6b2c8
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpScope.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.systemui.kairos
+
+import kotlin.coroutines.RestrictsSuspension
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/** Denotes [FrpScope] interfaces as [DSL markers][DslMarker]. */
+@DslMarker annotation class FrpScopeMarker
+
+/**
+ * Base scope for all FRP scopes. Used to prevent implicitly capturing other scopes from in lambdas.
+ */
+@FrpScopeMarker
+@RestrictsSuspension
+@ExperimentalFrpApi
+interface FrpScope {
+    /**
+     * Returns the value held by the [FrpDeferredValue], suspending until available if necessary.
+     */
+    @ExperimentalFrpApi
+    @OptIn(ExperimentalCoroutinesApi::class)
+    suspend fun <A> FrpDeferredValue<A>.get(): A = suspendCancellableCoroutine { k ->
+        unwrapped.invokeOnCompletion { ex ->
+            ex?.let { k.resumeWithException(ex) } ?: k.resume(unwrapped.getCompleted())
+        }
+    }
+}
+
+/**
+ * A value that may not be immediately (synchronously) available, but is guaranteed to be available
+ * before this transaction is completed.
+ *
+ * @see FrpScope.get
+ */
+@ExperimentalFrpApi
+class FrpDeferredValue<out A> internal constructor(internal val unwrapped: Deferred<A>)
+
+/**
+ * Returns the value held by this [FrpDeferredValue], or throws [IllegalStateException] if it is not
+ * yet available.
+ *
+ * This API is not meant for general usage within the FRP network. It is made available mainly for
+ * debugging and logging. You should always prefer [get][FrpScope.get] if possible.
+ *
+ * @see FrpScope.get
+ */
+@ExperimentalFrpApi
+@OptIn(ExperimentalCoroutinesApi::class)
+fun <A> FrpDeferredValue<A>.getUnsafe(): A = unwrapped.getCompleted()
+
+/** Returns an already-available [FrpDeferredValue] containing [value]. */
+@ExperimentalFrpApi
+fun <A> deferredOf(value: A): FrpDeferredValue<A> = FrpDeferredValue(CompletableDeferred(value))
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpStateScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpStateScope.kt
new file mode 100644
index 0000000..c7ea680
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpStateScope.kt
@@ -0,0 +1,780 @@
+/*
+ * 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.systemui.kairos
+
+import com.android.systemui.kairos.combine as combinePure
+import com.android.systemui.kairos.map as mapPure
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Left
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.Right
+import com.android.systemui.kairos.util.WithPrev
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.map
+import com.android.systemui.kairos.util.none
+import com.android.systemui.kairos.util.partitionEithers
+import com.android.systemui.kairos.util.zipWith
+import kotlin.coroutines.RestrictsSuspension
+
+typealias FrpStateful<R> = suspend FrpStateScope.() -> R
+
+/**
+ * Returns a [FrpStateful] that, when [applied][FrpStateScope.applyStateful], invokes [block] with
+ * the applier's [FrpStateScope].
+ */
+// TODO: caching story? should each Scope have a cache of applied FrpStateful instances?
+@ExperimentalFrpApi
+@Suppress("NOTHING_TO_INLINE")
+inline fun <A> statefully(noinline block: suspend FrpStateScope.() -> A): FrpStateful<A> = block
+
+/**
+ * Operations that accumulate state within the FRP network.
+ *
+ * State accumulation is an ongoing process that has a lifetime. Use `-Latest` combinators, such as
+ * [mapLatestStateful], to create smaller, nested lifecycles so that accumulation isn't running
+ * longer than needed.
+ */
+@ExperimentalFrpApi
+@RestrictsSuspension
+interface FrpStateScope : FrpTransactionScope {
+
+    /** TODO */
+    @ExperimentalFrpApi
+    // TODO: wish this could just be `deferred` but alas
+    fun <A> deferredStateScope(block: suspend FrpStateScope.() -> A): FrpDeferredValue<A>
+
+    /**
+     * Returns a [TState] that holds onto the most recently emitted value from this [TFlow], or
+     * [initialValue] if nothing has been emitted since it was constructed.
+     *
+     * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s
+     * have been processed; this keeps the value of the [TState] consistent during the entire FRP
+     * transaction.
+     */
+    @ExperimentalFrpApi fun <A> TFlow<A>.holdDeferred(initialValue: FrpDeferredValue<A>): TState<A>
+
+    /**
+     * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s
+     * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally].
+     *
+     * Conceptually this is equivalent to:
+     * ```kotlin
+     *   fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally(
+     *     initialTFlows: Map<K, TFlow<V>>,
+     *   ): TFlow<Map<K, V>> =
+     *     foldMapIncrementally(initialTFlows).map { it.merge() }.switch()
+     * ```
+     *
+     * While the behavior is equivalent to the conceptual definition above, the implementation is
+     * significantly more efficient.
+     *
+     * @see merge
+     */
+    @ExperimentalFrpApi
+    fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally(
+        initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>>
+    ): TFlow<Map<K, V>>
+
+    /**
+     * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s
+     * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally].
+     *
+     * Conceptually this is equivalent to:
+     * ```kotlin
+     *   fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPrompt(
+     *     initialTFlows: Map<K, TFlow<V>>,
+     *   ): TFlow<Map<K, V>> =
+     *     foldMapIncrementally(initialTFlows).map { it.merge() }.switchPromptly()
+     * ```
+     *
+     * While the behavior is equivalent to the conceptual definition above, the implementation is
+     * significantly more efficient.
+     *
+     * @see merge
+     */
+    @ExperimentalFrpApi
+    fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptly(
+        initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>>
+    ): TFlow<Map<K, V>>
+
+    // TODO: everything below this comment can be made into extensions once we have context params
+
+    /**
+     * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s
+     * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally].
+     *
+     * Conceptually this is equivalent to:
+     * ```kotlin
+     *   fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally(
+     *     initialTFlows: Map<K, TFlow<V>>,
+     *   ): TFlow<Map<K, V>> =
+     *     foldMapIncrementally(initialTFlows).map { it.merge() }.switch()
+     * ```
+     *
+     * While the behavior is equivalent to the conceptual definition above, the implementation is
+     * significantly more efficient.
+     *
+     * @see merge
+     */
+    @ExperimentalFrpApi
+    fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally(
+        initialTFlows: Map<K, TFlow<V>> = emptyMap()
+    ): TFlow<Map<K, V>> = mergeIncrementally(deferredOf(initialTFlows))
+
+    /**
+     * Returns a [TFlow] that emits from a merged, incrementally-accumulated collection of [TFlow]s
+     * emitted from this, following the same "patch" rules as outlined in [foldMapIncrementally].
+     *
+     * Conceptually this is equivalent to:
+     * ```kotlin
+     *   fun <K, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPrompt(
+     *     initialTFlows: Map<K, TFlow<V>>,
+     *   ): TFlow<Map<K, V>> =
+     *     foldMapIncrementally(initialTFlows).map { it.merge() }.switchPromptly()
+     * ```
+     *
+     * While the behavior is equivalent to the conceptual definition above, the implementation is
+     * significantly more efficient.
+     *
+     * @see merge
+     */
+    @ExperimentalFrpApi
+    fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptly(
+        initialTFlows: Map<K, TFlow<V>> = emptyMap()
+    ): TFlow<Map<K, V>> = mergeIncrementallyPromptly(deferredOf(initialTFlows))
+
+    /** Applies the [FrpStateful] within this [FrpStateScope]. */
+    @ExperimentalFrpApi suspend fun <A> FrpStateful<A>.applyStateful(): A = this()
+
+    /**
+     * Applies the [FrpStateful] within this [FrpStateScope], returning the result as an
+     * [FrpDeferredValue].
+     */
+    @ExperimentalFrpApi
+    fun <A> FrpStateful<A>.applyStatefulDeferred(): FrpDeferredValue<A> = deferredStateScope {
+        applyStateful()
+    }
+
+    /**
+     * Returns a [TState] that holds onto the most recently emitted value from this [TFlow], or
+     * [initialValue] if nothing has been emitted since it was constructed.
+     *
+     * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s
+     * have been processed; this keeps the value of the [TState] consistent during the entire FRP
+     * transaction.
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.hold(initialValue: A): TState<A> = holdDeferred(deferredOf(initialValue))
+
+    /**
+     * Returns a [TFlow] the emits the result of applying [FrpStatefuls][FrpStateful] emitted from
+     * the original [TFlow].
+     *
+     * Unlike [applyLatestStateful], state accumulation is not stopped with each subsequent emission
+     * of the original [TFlow].
+     */
+    @ExperimentalFrpApi fun <A> TFlow<FrpStateful<A>>.applyStatefuls(): TFlow<A>
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow].
+     *
+     * [transform] can perform state accumulation via its [FrpStateScope] receiver. Unlike
+     * [mapLatestStateful], accumulation is not stopped with each subsequent emission of the
+     * original [TFlow].
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapStateful(transform: suspend FrpStateScope.(A) -> B): TFlow<B> =
+        mapPure { statefully { transform(it) } }.applyStatefuls()
+
+    /**
+     * Returns a [TState] the holds the result of applying the [FrpStateful] held by the original
+     * [TState].
+     *
+     * Unlike [applyLatestStateful], state accumulation is not stopped with each state change.
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<FrpStateful<A>>.applyStatefuls(): TState<A> =
+        stateChanges
+            .applyStatefuls()
+            .holdDeferred(initialValue = deferredStateScope { sampleDeferred().get()() })
+
+    /** Returns a [TFlow] that switches to the [TFlow] emitted by the original [TFlow]. */
+    @ExperimentalFrpApi fun <A> TFlow<TFlow<A>>.flatten() = hold(emptyTFlow).switch()
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow].
+     *
+     * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each
+     * invocation of [transform], state accumulation from previous invocation is stopped.
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapLatestStateful(transform: suspend FrpStateScope.(A) -> B): TFlow<B> =
+        mapPure { statefully { transform(it) } }.applyLatestStateful()
+
+    /**
+     * Returns a [TFlow] that switches to a new [TFlow] produced by [transform] every time the
+     * original [TFlow] emits a value.
+     *
+     * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each
+     * invocation of [transform], state accumulation from previous invocation is stopped.
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.flatMapLatestStateful(
+        transform: suspend FrpStateScope.(A) -> TFlow<B>
+    ): TFlow<B> = mapLatestStateful(transform).flatten()
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the
+     * original [TFlow].
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] is stopped.
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<FrpStateful<A>>.applyLatestStateful(): TFlow<A> = applyLatestStateful {}.first
+
+    /**
+     * Returns a [TState] containing the value returned by applying the [FrpStateful] held by the
+     * original [TState].
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] is stopped.
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<FrpStateful<A>>.applyLatestStateful(): TState<A> {
+        val (changes, init) = stateChanges.applyLatestStateful { sample()() }
+        return changes.holdDeferred(init)
+    }
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init]
+     * immediately.
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] is stopped.
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<FrpStateful<B>>.applyLatestStateful(
+        init: FrpStateful<A>
+    ): Pair<TFlow<B>, FrpDeferredValue<A>> {
+        val (flow, result) =
+            mapCheap { spec -> mapOf(Unit to just(spec)) }
+                .applyLatestStatefulForKey(init = mapOf(Unit to init), numKeys = 1)
+        val outFlow: TFlow<B> =
+            flow.mapMaybe {
+                checkNotNull(it[Unit]) { "applyLatest: expected result, but none present in: $it" }
+            }
+        val outInit: FrpDeferredValue<A> = deferredTransactionScope {
+            val initResult: Map<Unit, A> = result.get()
+            check(Unit in initResult) {
+                "applyLatest: expected initial result, but none present in: $initResult"
+            }
+            @Suppress("UNCHECKED_CAST")
+            initResult.getOrDefault(Unit) { null } as A
+        }
+        return Pair(outFlow, outInit)
+    }
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init]
+     * immediately.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateful] will be stopped with no replacement.
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] with the same key is stopped.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey(
+        init: FrpDeferredValue<Map<K, FrpStateful<B>>>,
+        numKeys: Int? = null,
+    ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>>
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init]
+     * immediately.
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] with the same key is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateful] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey(
+        init: Map<K, FrpStateful<B>>,
+        numKeys: Int? = null,
+    ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> =
+        applyLatestStatefulForKey(deferredOf(init), numKeys)
+
+    /**
+     * Returns a [TState] containing the latest results of applying each [FrpStateful] emitted from
+     * the original [TFlow].
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] with the same key is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateful] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A> TFlow<Map<K, Maybe<FrpStateful<A>>>>.holdLatestStatefulForKey(
+        init: FrpDeferredValue<Map<K, FrpStateful<A>>>,
+        numKeys: Int? = null,
+    ): TState<Map<K, A>> {
+        val (changes, initialValues) = applyLatestStatefulForKey(init, numKeys)
+        return changes.foldMapIncrementally(initialValues)
+    }
+
+    /**
+     * Returns a [TState] containing the latest results of applying each [FrpStateful] emitted from
+     * the original [TFlow].
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] with the same key is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateful] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A> TFlow<Map<K, Maybe<FrpStateful<A>>>>.holdLatestStatefulForKey(
+        init: Map<K, FrpStateful<A>> = emptyMap(),
+        numKeys: Int? = null,
+    ): TState<Map<K, A>> = holdLatestStatefulForKey(deferredOf(init), numKeys)
+
+    /**
+     * Returns a [TFlow] containing the results of applying each [FrpStateful] emitted from the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [init]
+     * immediately.
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] with the same key is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateful] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey(
+        numKeys: Int? = null
+    ): TFlow<Map<K, Maybe<A>>> =
+        applyLatestStatefulForKey(init = emptyMap<K, FrpStateful<*>>(), numKeys = numKeys).first
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to
+     * [initialValues] immediately.
+     *
+     * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each
+     * invocation of [transform], state accumulation from previous invocation is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateScope] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestStatefulForKey(
+        initialValues: FrpDeferredValue<Map<K, A>>,
+        numKeys: Int? = null,
+        transform: suspend FrpStateScope.(A) -> B,
+    ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> =
+        mapPure { patch -> patch.mapValues { (_, v) -> v.map { statefully { transform(it) } } } }
+            .applyLatestStatefulForKey(
+                deferredStateScope {
+                    initialValues.get().mapValues { (_, v) -> statefully { transform(v) } }
+                },
+                numKeys = numKeys,
+            )
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow], and a [FrpDeferredValue] containing the result of applying [transform] to
+     * [initialValues] immediately.
+     *
+     * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each
+     * invocation of [transform], state accumulation from previous invocation is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateScope] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestStatefulForKey(
+        initialValues: Map<K, A>,
+        numKeys: Int? = null,
+        transform: suspend FrpStateScope.(A) -> B,
+    ): Pair<TFlow<Map<K, Maybe<B>>>, FrpDeferredValue<Map<K, B>>> =
+        mapLatestStatefulForKey(deferredOf(initialValues), numKeys, transform)
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow].
+     *
+     * [transform] can perform state accumulation via its [FrpStateScope] receiver. With each
+     * invocation of [transform], state accumulation from previous invocation is stopped.
+     *
+     * If the [Maybe] contained within the value for an associated key is [none], then the
+     * previously-active [FrpStateScope] will be stopped with no replacement.
+     */
+    @ExperimentalFrpApi
+    fun <K, A, B> TFlow<Map<K, Maybe<A>>>.mapLatestStatefulForKey(
+        numKeys: Int? = null,
+        transform: suspend FrpStateScope.(A) -> B,
+    ): TFlow<Map<K, Maybe<B>>> = mapLatestStatefulForKey(emptyMap(), numKeys, transform).first
+
+    /**
+     * Returns a [TFlow] that will only emit the next event of the original [TFlow], and then will
+     * act as [emptyTFlow].
+     *
+     * If the original [TFlow] is emitting an event at this exact time, then it will be the only
+     * even emitted from the result [TFlow].
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.nextOnly(): TFlow<A> =
+        if (this === emptyTFlow) {
+            this
+        } else {
+            TFlowLoop<A>().also {
+                it.loopback = it.mapCheap { emptyTFlow }.hold(this@nextOnly).switch()
+            }
+        }
+
+    /** Returns a [TFlow] that skips the next emission of the original [TFlow]. */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.skipNext(): TFlow<A> =
+        if (this === emptyTFlow) {
+            this
+        } else {
+            nextOnly().mapCheap { this@skipNext }.hold(emptyTFlow).switch()
+        }
+
+    /**
+     * Returns a [TFlow] that emits values from the original [TFlow] up until [stop] emits a value.
+     *
+     * If the original [TFlow] emits at the same time as [stop], then the returned [TFlow] will emit
+     * that value.
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.takeUntil(stop: TFlow<*>): TFlow<A> =
+        if (stop === emptyTFlow) {
+            this
+        } else {
+            stop.mapCheap { emptyTFlow }.nextOnly().hold(this).switch()
+        }
+
+    /**
+     * Invokes [stateful] in a new [FrpStateScope] that is a child of this one.
+     *
+     * This new scope is stopped when [stop] first emits a value, or when the parent scope is
+     * stopped. Stopping will end all state accumulation; any [TStates][TState] returned from this
+     * scope will no longer update.
+     */
+    @ExperimentalFrpApi
+    fun <A> childStateScope(stop: TFlow<*>, stateful: FrpStateful<A>): FrpDeferredValue<A> {
+        val (_, init: FrpDeferredValue<Map<Unit, A>>) =
+            stop
+                .nextOnly()
+                .mapPure { mapOf(Unit to none<FrpStateful<A>>()) }
+                .applyLatestStatefulForKey(init = mapOf(Unit to stateful), numKeys = 1)
+        return deferredStateScope { init.get().getValue(Unit) }
+    }
+
+    /**
+     * Returns a [TFlow] that emits values from the original [TFlow] up to and including a value is
+     * emitted that satisfies [predicate].
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.takeUntil(predicate: suspend FrpTransactionScope.(A) -> Boolean): TFlow<A> =
+        takeUntil(filter(predicate))
+
+    /**
+     * Returns a [TState] that is incrementally updated when this [TFlow] emits a value, by applying
+     * [transform] to both the emitted value and the currently tracked state.
+     *
+     * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s
+     * have been processed; this keeps the value of the [TState] consistent during the entire FRP
+     * transaction.
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.fold(
+        initialValue: B,
+        transform: suspend FrpTransactionScope.(A, B) -> B,
+    ): TState<B> {
+        lateinit var state: TState<B>
+        return mapPure { a -> transform(a, state.sample()) }.hold(initialValue).also { state = it }
+    }
+
+    /**
+     * Returns a [TState] that is incrementally updated when this [TFlow] emits a value, by applying
+     * [transform] to both the emitted value and the currently tracked state.
+     *
+     * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s
+     * have been processed; this keeps the value of the [TState] consistent during the entire FRP
+     * transaction.
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.foldDeferred(
+        initialValue: FrpDeferredValue<B>,
+        transform: suspend FrpTransactionScope.(A, B) -> B,
+    ): TState<B> {
+        lateinit var state: TState<B>
+        return mapPure { a -> transform(a, state.sample()) }
+            .holdDeferred(initialValue)
+            .also { state = it }
+    }
+
+    /**
+     * Returns a [TState] that holds onto the result of applying the most recently emitted
+     * [FrpStateful] this [TFlow], or [init] if nothing has been emitted since it was constructed.
+     *
+     * When each [FrpStateful] is applied, state accumulation from the previously-active
+     * [FrpStateful] is stopped.
+     *
+     * Note that the value contained within the [TState] is not updated until *after* all [TFlow]s
+     * have been processed; this keeps the value of the [TState] consistent during the entire FRP
+     * transaction.
+     *
+     * Shorthand for:
+     * ```kotlin
+     * val (changes, initApplied) = applyLatestStateful(init)
+     * return changes.toTStateDeferred(initApplied)
+     * ```
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<FrpStateful<A>>.holdLatestStateful(init: FrpStateful<A>): TState<A> {
+        val (changes, initApplied) = applyLatestStateful(init)
+        return changes.holdDeferred(initApplied)
+    }
+
+    /**
+     * Returns a [TFlow] that emits the two most recent emissions from the original [TFlow].
+     * [initialValue] is used as the previous value for the first emission.
+     *
+     * Shorthand for `sample(hold(init)) { new, old -> Pair(old, new) }`
+     */
+    @ExperimentalFrpApi
+    fun <S, T : S> TFlow<T>.pairwise(initialValue: S): TFlow<WithPrev<S, T>> {
+        val previous = hold(initialValue)
+        return mapCheap { new -> WithPrev(previousValue = previous.sample(), newValue = new) }
+    }
+
+    /**
+     * Returns a [TFlow] that emits the two most recent emissions from the original [TFlow]. Note
+     * that the returned [TFlow] will not emit until the original [TFlow] has emitted twice.
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.pairwise(): TFlow<WithPrev<A, A>> =
+        mapCheap { just(it) }
+            .pairwise(none)
+            .mapMaybe { (prev, next) -> prev.zipWith(next, ::WithPrev) }
+
+    /**
+     * Returns a [TState] that holds both the current and previous values of the original [TState].
+     * [initialPreviousValue] is used as the first previous value.
+     *
+     * Shorthand for `sample(hold(init)) { new, old -> Pair(old, new) }`
+     */
+    @ExperimentalFrpApi
+    fun <S, T : S> TState<T>.pairwise(initialPreviousValue: S): TState<WithPrev<S, T>> =
+        stateChanges
+            .pairwise(initialPreviousValue)
+            .holdDeferred(deferredTransactionScope { WithPrev(initialPreviousValue, sample()) })
+
+    /**
+     * Returns a [TState] holding a [Map] that is updated incrementally whenever this emits a value.
+     *
+     * The value emitted is used as a "patch" for the tracked [Map]; for each key [K] in the emitted
+     * map, an associated value of [Just] will insert or replace the value in the tracked [Map], and
+     * an associated value of [none] will remove the key from the tracked [Map].
+     */
+    @ExperimentalFrpApi
+    fun <K, V> TFlow<Map<K, Maybe<V>>>.foldMapIncrementally(
+        initialValues: FrpDeferredValue<Map<K, V>>
+    ): TState<Map<K, V>> =
+        foldDeferred(initialValues) { patch, map ->
+            val (adds: List<Pair<K, V>>, removes: List<K>) =
+                patch
+                    .asSequence()
+                    .map { (k, v) -> if (v is Just) Left(k to v.value) else Right(k) }
+                    .partitionEithers()
+            val removed: Map<K, V> = map - removes.toSet()
+            val updated: Map<K, V> = removed + adds
+            updated
+        }
+
+    /**
+     * Returns a [TState] holding a [Map] that is updated incrementally whenever this emits a value.
+     *
+     * The value emitted is used as a "patch" for the tracked [Map]; for each key [K] in the emitted
+     * map, an associated value of [Just] will insert or replace the value in the tracked [Map], and
+     * an associated value of [none] will remove the key from the tracked [Map].
+     */
+    @ExperimentalFrpApi
+    fun <K, V> TFlow<Map<K, Maybe<V>>>.foldMapIncrementally(
+        initialValues: Map<K, V> = emptyMap()
+    ): TState<Map<K, V>> = foldMapIncrementally(deferredOf(initialValues))
+
+    /**
+     * Returns a [TFlow] that wraps each emission of the original [TFlow] into an [IndexedValue],
+     * containing the emitted value and its index (starting from zero).
+     *
+     * Shorthand for:
+     * ```
+     *   val index = fold(0) { _, oldIdx -> oldIdx + 1 }
+     *   sample(index) { a, idx -> IndexedValue(idx, a) }
+     * ```
+     */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.withIndex(): TFlow<IndexedValue<A>> {
+        val index = fold(0) { _, old -> old + 1 }
+        return sample(index) { a, idx -> IndexedValue(idx, a) }
+    }
+
+    /**
+     * Returns a [TFlow] containing the results of applying [transform] to each value of the
+     * original [TFlow] and its index (starting from zero).
+     *
+     * Shorthand for:
+     * ```
+     *   withIndex().map { (idx, a) -> transform(idx, a) }
+     * ```
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TFlow<A>.mapIndexed(transform: suspend FrpTransactionScope.(Int, A) -> B): TFlow<B> {
+        val index = fold(0) { _, i -> i + 1 }
+        return sample(index) { a, idx -> transform(idx, a) }
+    }
+
+    /** Returns a [TFlow] where all subsequent repetitions of the same value are filtered out. */
+    @ExperimentalFrpApi
+    fun <A> TFlow<A>.distinctUntilChanged(): TFlow<A> {
+        val state: TState<Any?> = hold(Any())
+        return filter { it != state.sample() }
+    }
+
+    /**
+     * Returns a new [TFlow] that emits at the same rate as the original [TFlow], but combines the
+     * emitted value with the most recent emission from [other] using [transform].
+     *
+     * Note that the returned [TFlow] will not emit anything until [other] has emitted at least one
+     * value.
+     */
+    @ExperimentalFrpApi
+    fun <A, B, C> TFlow<A>.sample(
+        other: TFlow<B>,
+        transform: suspend FrpTransactionScope.(A, B) -> C,
+    ): TFlow<C> {
+        val state = other.mapCheap { just(it) }.hold(none)
+        return sample(state) { a, b -> b.map { transform(a, it) } }.filterJust()
+    }
+
+    /**
+     * Returns a [TState] that samples the [Transactional] held by the given [TState] within the
+     * same transaction that the state changes.
+     */
+    @ExperimentalFrpApi
+    fun <A> TState<Transactional<A>>.sampleTransactionals(): TState<A> =
+        stateChanges
+            .sampleTransactionals()
+            .holdDeferred(deferredTransactionScope { sample().sample() })
+
+    /**
+     * Returns a [TState] that transforms the value held inside this [TState] by applying it to the
+     * given function [transform].
+     */
+    @ExperimentalFrpApi
+    fun <A, B> TState<A>.map(transform: suspend FrpTransactionScope.(A) -> B): TState<B> =
+        mapPure { transactionally { transform(it) } }.sampleTransactionals()
+
+    /**
+     * Returns a [TState] whose value is generated with [transform] by combining the current values
+     * of each given [TState].
+     *
+     * @see TState.combineWith
+     */
+    @ExperimentalFrpApi
+    fun <A, B, Z> combine(
+        stateA: TState<A>,
+        stateB: TState<B>,
+        transform: suspend FrpTransactionScope.(A, B) -> Z,
+    ): TState<Z> =
+        com.android.systemui.kairos
+            .combine(stateA, stateB) { a, b -> transactionally { transform(a, b) } }
+            .sampleTransactionals()
+
+    /**
+     * Returns a [TState] whose value is generated with [transform] by combining the current values
+     * of each given [TState].
+     *
+     * @see TState.combineWith
+     */
+    @ExperimentalFrpApi
+    fun <A, B, C, D, Z> combine(
+        stateA: TState<A>,
+        stateB: TState<B>,
+        stateC: TState<C>,
+        stateD: TState<D>,
+        transform: suspend FrpTransactionScope.(A, B, C, D) -> Z,
+    ): TState<Z> =
+        com.android.systemui.kairos
+            .combine(stateA, stateB, stateC, stateD) { a, b, c, d ->
+                transactionally { transform(a, b, c, d) }
+            }
+            .sampleTransactionals()
+
+    /** Returns a [TState] by applying [transform] to the value held by the original [TState]. */
+    @ExperimentalFrpApi
+    fun <A, B> TState<A>.flatMap(
+        transform: suspend FrpTransactionScope.(A) -> TState<B>
+    ): TState<B> = mapPure { transactionally { transform(it) } }.sampleTransactionals().flatten()
+
+    /**
+     * Returns a [TState] whose value is generated with [transform] by combining the current values
+     * of each given [TState].
+     *
+     * @see TState.combineWith
+     */
+    @ExperimentalFrpApi
+    fun <A, Z> combine(
+        vararg states: TState<A>,
+        transform: suspend FrpTransactionScope.(List<A>) -> Z,
+    ): TState<Z> = combinePure(*states).map(transform)
+
+    /**
+     * Returns a [TState] whose value is generated with [transform] by combining the current values
+     * of each given [TState].
+     *
+     * @see TState.combineWith
+     */
+    @ExperimentalFrpApi
+    fun <A, Z> Iterable<TState<A>>.combine(
+        transform: suspend FrpTransactionScope.(List<A>) -> Z
+    ): TState<Z> = combinePure().map(transform)
+
+    /**
+     * Returns a [TState] by combining the values held inside the given [TState]s by applying them
+     * to the given function [transform].
+     */
+    @ExperimentalFrpApi
+    fun <A, B, C> TState<A>.combineWith(
+        other: TState<B>,
+        transform: suspend FrpTransactionScope.(A, B) -> C,
+    ): TState<C> = combine(this, other, transform)
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpTransactionScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpTransactionScope.kt
new file mode 100644
index 0000000..a7ae1d9
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/FrpTransactionScope.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.systemui.kairos
+
+import kotlin.coroutines.RestrictsSuspension
+
+/**
+ * FRP operations that are available while a transaction is active.
+ *
+ * These operations do not accumulate state, which makes [FrpTransactionScope] weaker than
+ * [FrpStateScope], but allows them to be used in more places.
+ */
+@ExperimentalFrpApi
+@RestrictsSuspension
+interface FrpTransactionScope : FrpScope {
+
+    /**
+     * Returns the current value of this [Transactional] as a [FrpDeferredValue].
+     *
+     * @see sample
+     */
+    @ExperimentalFrpApi fun <A> Transactional<A>.sampleDeferred(): FrpDeferredValue<A>
+
+    /**
+     * Returns the current value of this [TState] as a [FrpDeferredValue].
+     *
+     * @see sample
+     */
+    @ExperimentalFrpApi fun <A> TState<A>.sampleDeferred(): FrpDeferredValue<A>
+
+    /** TODO */
+    @ExperimentalFrpApi
+    fun <A> deferredTransactionScope(
+        block: suspend FrpTransactionScope.() -> A
+    ): FrpDeferredValue<A>
+
+    /** A [TFlow] that emits once, within this transaction, and then never again. */
+    @ExperimentalFrpApi val now: TFlow<Unit>
+
+    /**
+     * Returns the current value held by this [TState]. Guaranteed to be consistent within the same
+     * transaction.
+     */
+    @ExperimentalFrpApi suspend fun <A> TState<A>.sample(): A = sampleDeferred().get()
+
+    /**
+     * Returns the current value held by this [Transactional]. Guaranteed to be consistent within
+     * the same transaction.
+     */
+    @ExperimentalFrpApi suspend fun <A> Transactional<A>.sample(): A = sampleDeferred().get()
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/TFlow.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/TFlow.kt
new file mode 100644
index 0000000..7ba1aca
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/TFlow.kt
@@ -0,0 +1,560 @@
+/*
+ * 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.systemui.kairos
+
+import com.android.systemui.kairos.internal.DemuxImpl
+import com.android.systemui.kairos.internal.Init
+import com.android.systemui.kairos.internal.InitScope
+import com.android.systemui.kairos.internal.InputNode
+import com.android.systemui.kairos.internal.Network
+import com.android.systemui.kairos.internal.NoScope
+import com.android.systemui.kairos.internal.TFlowImpl
+import com.android.systemui.kairos.internal.activated
+import com.android.systemui.kairos.internal.cached
+import com.android.systemui.kairos.internal.constInit
+import com.android.systemui.kairos.internal.filterNode
+import com.android.systemui.kairos.internal.init
+import com.android.systemui.kairos.internal.map
+import com.android.systemui.kairos.internal.mapImpl
+import com.android.systemui.kairos.internal.mapMaybeNode
+import com.android.systemui.kairos.internal.mergeNodes
+import com.android.systemui.kairos.internal.mergeNodesLeft
+import com.android.systemui.kairos.internal.neverImpl
+import com.android.systemui.kairos.internal.switchDeferredImplSingle
+import com.android.systemui.kairos.internal.switchPromptImpl
+import com.android.systemui.kairos.internal.util.hashString
+import com.android.systemui.kairos.util.Either
+import com.android.systemui.kairos.util.Left
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.Right
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.map
+import com.android.systemui.kairos.util.toMaybe
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.reflect.KProperty
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+
+/** A series of values of type [A] available at discrete points in time. */
+@ExperimentalFrpApi
+sealed class TFlow<out A> {
+    companion object {
+        /** A [TFlow] with no values. */
+        val empty: TFlow<Nothing> = EmptyFlow
+    }
+}
+
+/** A [TFlow] with no values. */
+@ExperimentalFrpApi val emptyTFlow: TFlow<Nothing> = TFlow.empty
+
+/**
+ * A forward-reference to a [TFlow]. Useful for recursive definitions.
+ *
+ * This reference can be used like a standard [TFlow], but will hold up evaluation of the FRP
+ * network until the [loopback] reference is set.
+ */
+@ExperimentalFrpApi
+class TFlowLoop<A> : TFlow<A>() {
+    private val deferred = CompletableDeferred<TFlow<A>>()
+
+    internal val init: Init<TFlowImpl<A>> =
+        init(name = null) { deferred.await().init.connect(evalScope = this) }
+
+    /** The [TFlow] this reference is referring to. */
+    @ExperimentalFrpApi
+    var loopback: TFlow<A>? = null
+        set(value) {
+            value?.let {
+                check(deferred.complete(value)) { "TFlowLoop.loopback has already been set." }
+                field = value
+            }
+        }
+
+    operator fun getValue(thisRef: Any?, property: KProperty<*>): TFlow<A> = this
+
+    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: TFlow<A>) {
+        loopback = value
+    }
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+/** TODO */
+@ExperimentalFrpApi fun <A> Lazy<TFlow<A>>.defer(): TFlow<A> = deferInline { value }
+
+/** TODO */
+@ExperimentalFrpApi
+fun <A> FrpDeferredValue<TFlow<A>>.defer(): TFlow<A> = deferInline { unwrapped.await() }
+
+/** TODO */
+@ExperimentalFrpApi
+fun <A> deferTFlow(block: suspend FrpScope.() -> TFlow<A>): TFlow<A> = deferInline {
+    NoScope.runInFrpScope(block)
+}
+
+/** Returns a [TFlow] that emits the new value of this [TState] when it changes. */
+@ExperimentalFrpApi
+val <A> TState<A>.stateChanges: TFlow<A>
+    get() = TFlowInit(init(name = null) { init.connect(evalScope = this).changes })
+
+/**
+ * Returns a [TFlow] that contains only the [just] results of applying [transform] to each value of
+ * the original [TFlow].
+ *
+ * @see mapNotNull
+ */
+@ExperimentalFrpApi
+fun <A, B> TFlow<A>.mapMaybe(transform: suspend FrpTransactionScope.(A) -> Maybe<B>): TFlow<B> {
+    val pulse =
+        mapMaybeNode({ init.connect(evalScope = this) }) { runInTransactionScope { transform(it) } }
+    return TFlowInit(constInit(name = null, pulse))
+}
+
+/**
+ * Returns a [TFlow] that contains only the non-null results of applying [transform] to each value
+ * of the original [TFlow].
+ *
+ * @see mapMaybe
+ */
+@ExperimentalFrpApi
+fun <A, B> TFlow<A>.mapNotNull(transform: suspend FrpTransactionScope.(A) -> B?): TFlow<B> =
+    mapMaybe {
+        transform(it).toMaybe()
+    }
+
+/** Returns a [TFlow] containing only values of the original [TFlow] that are not null. */
+@ExperimentalFrpApi fun <A> TFlow<A?>.filterNotNull(): TFlow<A> = mapNotNull { it }
+
+/** Shorthand for `mapNotNull { it as? A }`. */
+@ExperimentalFrpApi
+inline fun <reified A> TFlow<*>.filterIsInstance(): TFlow<A> = mapNotNull { it as? A }
+
+/** Shorthand for `mapMaybe { it }`. */
+@ExperimentalFrpApi fun <A> TFlow<Maybe<A>>.filterJust(): TFlow<A> = mapMaybe { it }
+
+/**
+ * Returns a [TFlow] containing the results of applying [transform] to each value of the original
+ * [TFlow].
+ */
+@ExperimentalFrpApi
+fun <A, B> TFlow<A>.map(transform: suspend FrpTransactionScope.(A) -> B): TFlow<B> {
+    val mapped: TFlowImpl<B> =
+        mapImpl({ init.connect(evalScope = this) }) { a -> runInTransactionScope { transform(a) } }
+    return TFlowInit(constInit(name = null, mapped.cached()))
+}
+
+/**
+ * Like [map], but the emission is not cached during the transaction. Use only if [transform] is
+ * fast and pure.
+ *
+ * @see map
+ */
+@ExperimentalFrpApi
+fun <A, B> TFlow<A>.mapCheap(transform: suspend FrpTransactionScope.(A) -> B): TFlow<B> =
+    TFlowInit(
+        constInit(
+            name = null,
+            mapImpl({ init.connect(evalScope = this) }) { a ->
+                runInTransactionScope { transform(a) }
+            },
+        )
+    )
+
+/**
+ * Returns a [TFlow] that invokes [action] before each value of the original [TFlow] is emitted.
+ * Useful for logging and debugging.
+ *
+ * ```
+ *   pulse.onEach { foo(it) } == pulse.map { foo(it); it }
+ * ```
+ *
+ * Note that the side effects performed in [onEach] are only performed while the resulting [TFlow]
+ * is connected to an output of the FRP network. If your goal is to reliably perform side effects in
+ * response to a [TFlow], use the output combinators available in [FrpBuildScope], such as
+ * [FrpBuildScope.toSharedFlow] or [FrpBuildScope.observe].
+ */
+@ExperimentalFrpApi
+fun <A> TFlow<A>.onEach(action: suspend FrpTransactionScope.(A) -> Unit): TFlow<A> = map {
+    action(it)
+    it
+}
+
+/**
+ * Returns a [TFlow] containing only values of the original [TFlow] that satisfy the given
+ * [predicate].
+ */
+@ExperimentalFrpApi
+fun <A> TFlow<A>.filter(predicate: suspend FrpTransactionScope.(A) -> Boolean): TFlow<A> {
+    val pulse =
+        filterNode({ init.connect(evalScope = this) }) { runInTransactionScope { predicate(it) } }
+    return TFlowInit(constInit(name = null, pulse.cached()))
+}
+
+/**
+ * Splits a [TFlow] of pairs into a pair of [TFlows][TFlow], where each returned [TFlow] emits half
+ * of the original.
+ *
+ * Shorthand for:
+ * ```kotlin
+ * val lefts = map { it.first }
+ * val rights = map { it.second }
+ * return Pair(lefts, rights)
+ * ```
+ */
+@ExperimentalFrpApi
+fun <A, B> TFlow<Pair<A, B>>.unzip(): Pair<TFlow<A>, TFlow<B>> {
+    val lefts = map { it.first }
+    val rights = map { it.second }
+    return lefts to rights
+}
+
+/**
+ * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from both.
+ *
+ * Because [TFlow]s can only emit one value per transaction, the provided [transformCoincidence]
+ * function is used to combine coincident emissions to produce the result value to be emitted by the
+ * merged [TFlow].
+ */
+@ExperimentalFrpApi
+fun <A> TFlow<A>.mergeWith(
+    other: TFlow<A>,
+    transformCoincidence: suspend FrpTransactionScope.(A, A) -> A = { a, _ -> a },
+): TFlow<A> {
+    val node =
+        mergeNodes(
+            getPulse = { init.connect(evalScope = this) },
+            getOther = { other.init.connect(evalScope = this) },
+        ) { a, b ->
+            runInTransactionScope { transformCoincidence(a, b) }
+        }
+    return TFlowInit(constInit(name = null, node))
+}
+
+/**
+ * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. All coincident
+ * emissions are collected into the emitted [List], preserving the input ordering.
+ *
+ * @see mergeWith
+ * @see mergeLeft
+ */
+@ExperimentalFrpApi
+fun <A> merge(vararg flows: TFlow<A>): TFlow<List<A>> = flows.asIterable().merge()
+
+/**
+ * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. In the case of
+ * coincident emissions, the emission from the left-most [TFlow] is emitted.
+ *
+ * @see merge
+ */
+@ExperimentalFrpApi
+fun <A> mergeLeft(vararg flows: TFlow<A>): TFlow<A> = flows.asIterable().mergeLeft()
+
+/**
+ * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all.
+ *
+ * Because [TFlow]s can only emit one value per transaction, the provided [transformCoincidence]
+ * function is used to combine coincident emissions to produce the result value to be emitted by the
+ * merged [TFlow].
+ */
+// TODO: can be optimized to avoid creating the intermediate list
+fun <A> merge(vararg flows: TFlow<A>, transformCoincidence: (A, A) -> A): TFlow<A> =
+    merge(*flows).map { l -> l.reduce(transformCoincidence) }
+
+/**
+ * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. All coincident
+ * emissions are collected into the emitted [List], preserving the input ordering.
+ *
+ * @see mergeWith
+ * @see mergeLeft
+ */
+@ExperimentalFrpApi
+fun <A> Iterable<TFlow<A>>.merge(): TFlow<List<A>> =
+    TFlowInit(constInit(name = null, mergeNodes { map { it.init.connect(evalScope = this) } }))
+
+/**
+ * Merges the given [TFlows][TFlow] into a single [TFlow] that emits events from all. In the case of
+ * coincident emissions, the emission from the left-most [TFlow] is emitted.
+ *
+ * @see merge
+ */
+@ExperimentalFrpApi
+fun <A> Iterable<TFlow<A>>.mergeLeft(): TFlow<A> =
+    TFlowInit(constInit(name = null, mergeNodesLeft { map { it.init.connect(evalScope = this) } }))
+
+/**
+ * Creates a new [TFlow] that emits events from all given [TFlow]s. All simultaneous emissions are
+ * collected into the emitted [List], preserving the input ordering.
+ *
+ * @see mergeWith
+ */
+@ExperimentalFrpApi fun <A> Sequence<TFlow<A>>.merge(): TFlow<List<A>> = asIterable().merge()
+
+/**
+ * Creates a new [TFlow] that emits events from all given [TFlow]s. All simultaneous emissions are
+ * collected into the emitted [Map], and are given the same key of the associated [TFlow] in the
+ * input [Map].
+ *
+ * @see mergeWith
+ */
+@ExperimentalFrpApi
+fun <K, A> Map<K, TFlow<A>>.merge(): TFlow<Map<K, A>> =
+    asSequence().map { (k, flowA) -> flowA.map { a -> k to a } }.toList().merge().map { it.toMap() }
+
+/**
+ * Returns a [GroupedTFlow] that can be used to efficiently split a single [TFlow] into multiple
+ * downstream [TFlow]s.
+ *
+ * The input [TFlow] emits [Map] instances that specify which downstream [TFlow] the associated
+ * value will be emitted from. These downstream [TFlow]s can be obtained via
+ * [GroupedTFlow.eventsForKey].
+ *
+ * An example:
+ * ```
+ *   val sFoo: TFlow<Map<String, Foo>> = ...
+ *   val fooById: GroupedTFlow<String, Foo> = sFoo.groupByKey()
+ *   val fooBar: TFlow<Foo> = fooById["bar"]
+ * ```
+ *
+ * This is semantically equivalent to `val fooBar = sFoo.mapNotNull { map -> map["bar"] }` but is
+ * significantly more efficient; specifically, using [mapNotNull] in this way incurs a `O(n)`
+ * performance hit, where `n` is the number of different [mapNotNull] operations used to filter on a
+ * specific key's presence in the emitted [Map]. [groupByKey] internally uses a [HashMap] to lookup
+ * the appropriate downstream [TFlow], and so operates in `O(1)`.
+ *
+ * Note that the result [GroupedTFlow] should be cached and re-used to gain the performance benefit.
+ *
+ * @see selector
+ */
+@ExperimentalFrpApi
+fun <K, A> TFlow<Map<K, A>>.groupByKey(numKeys: Int? = null): GroupedTFlow<K, A> =
+    GroupedTFlow(DemuxImpl({ init.connect(this) }, numKeys))
+
+/**
+ * Shorthand for `map { mapOf(extractKey(it) to it) }.groupByKey()`
+ *
+ * @see groupByKey
+ */
+@ExperimentalFrpApi
+fun <K, A> TFlow<A>.groupBy(
+    numKeys: Int? = null,
+    extractKey: suspend FrpTransactionScope.(A) -> K,
+): GroupedTFlow<K, A> = map { mapOf(extractKey(it) to it) }.groupByKey(numKeys)
+
+/**
+ * Returns two new [TFlow]s that contain elements from this [TFlow] that satisfy or don't satisfy
+ * [predicate].
+ *
+ * Using this is equivalent to `upstream.filter(predicate) to upstream.filter { !predicate(it) }`
+ * but is more efficient; specifically, [partition] will only invoke [predicate] once per element.
+ */
+@ExperimentalFrpApi
+fun <A> TFlow<A>.partition(
+    predicate: suspend FrpTransactionScope.(A) -> Boolean
+): Pair<TFlow<A>, TFlow<A>> {
+    val grouped: GroupedTFlow<Boolean, A> = groupBy(numKeys = 2, extractKey = predicate)
+    return Pair(grouped.eventsForKey(true), grouped.eventsForKey(false))
+}
+
+/**
+ * Returns two new [TFlow]s that contain elements from this [TFlow]; [Pair.first] will contain
+ * [Left] values, and [Pair.second] will contain [Right] values.
+ *
+ * Using this is equivalent to using [filterIsInstance] in conjunction with [map] twice, once for
+ * [Left]s and once for [Right]s, but is slightly more efficient; specifically, the
+ * [filterIsInstance] check is only performed once per element.
+ */
+@ExperimentalFrpApi
+fun <A, B> TFlow<Either<A, B>>.partitionEither(): Pair<TFlow<A>, TFlow<B>> {
+    val (left, right) = partition { it is Left }
+    return Pair(left.mapCheap { (it as Left).value }, right.mapCheap { (it as Right).value })
+}
+
+/**
+ * A mapping from keys of type [K] to [TFlow]s emitting values of type [A].
+ *
+ * @see groupByKey
+ */
+@ExperimentalFrpApi
+class GroupedTFlow<in K, out A> internal constructor(internal val impl: DemuxImpl<K, A>) {
+    /**
+     * Returns a [TFlow] that emits values of type [A] that correspond to the given [key].
+     *
+     * @see groupByKey
+     */
+    @ExperimentalFrpApi
+    fun eventsForKey(key: K): TFlow<A> = TFlowInit(constInit(name = null, impl.eventsForKey(key)))
+
+    /**
+     * Returns a [TFlow] that emits values of type [A] that correspond to the given [key].
+     *
+     * @see groupByKey
+     */
+    @ExperimentalFrpApi operator fun get(key: K): TFlow<A> = eventsForKey(key)
+}
+
+/**
+ * Returns a [TFlow] that switches to the [TFlow] contained within this [TState] whenever it
+ * changes.
+ *
+ * This switch does take effect until the *next* transaction after [TState] changes. For a switch
+ * that takes effect immediately, see [switchPromptly].
+ */
+@ExperimentalFrpApi
+fun <A> TState<TFlow<A>>.switch(): TFlow<A> {
+    return TFlowInit(
+        constInit(
+            name = null,
+            switchDeferredImplSingle(
+                getStorage = {
+                    init.connect(this).getCurrentWithEpoch(this).first.init.connect(this)
+                },
+                getPatches = {
+                    mapImpl({ init.connect(this).changes }) { newFlow ->
+                        newFlow.init.connect(this)
+                    }
+                },
+            ),
+        )
+    )
+}
+
+/**
+ * Returns a [TFlow] that switches to the [TFlow] contained within this [TState] whenever it
+ * changes.
+ *
+ * This switch takes effect immediately within the same transaction that [TState] changes. In
+ * general, you should prefer [switch] over this method. It is both safer and more performant.
+ */
+// TODO: parameter to handle coincidental emission from both old and new
+@ExperimentalFrpApi
+fun <A> TState<TFlow<A>>.switchPromptly(): TFlow<A> {
+    val switchNode =
+        switchPromptImpl(
+            getStorage = {
+                mapOf(Unit to init.connect(this).getCurrentWithEpoch(this).first.init.connect(this))
+            },
+            getPatches = {
+                val patches = init.connect(this).changes
+                mapImpl({ patches }) { newFlow -> mapOf(Unit to just(newFlow.init.connect(this))) }
+            },
+        )
+    return TFlowInit(constInit(name = null, mapImpl({ switchNode }) { it.getValue(Unit) }))
+}
+
+/**
+ * A mutable [TFlow] that provides the ability to [emit] values to the flow, handling backpressure
+ * by coalescing all emissions into batches.
+ *
+ * @see FrpNetwork.coalescingMutableTFlow
+ */
+@ExperimentalFrpApi
+class CoalescingMutableTFlow<In, Out>
+internal constructor(
+    internal val coalesce: (old: Out, new: In) -> Out,
+    internal val network: Network,
+    private val getInitialValue: () -> Out,
+    internal val impl: InputNode<Out> = InputNode(),
+) : TFlow<Out>() {
+    internal val name: String? = null
+    internal val storage = AtomicReference(false to getInitialValue())
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+
+    /**
+     * Inserts [value] into the current batch, enqueueing it for emission from this [TFlow] if not
+     * already pending.
+     *
+     * Backpressure occurs when [emit] is called while the FRP network is currently in a
+     * transaction; if called multiple times, then emissions will be coalesced into a single batch
+     * that is then processed when the network is ready.
+     */
+    @ExperimentalFrpApi
+    fun emit(value: In) {
+        val (scheduled, _) = storage.getAndUpdate { (_, old) -> true to coalesce(old, value) }
+        if (!scheduled) {
+            @Suppress("DeferredResultUnused")
+            network.transaction {
+                impl.visit(this, storage.getAndSet(false to getInitialValue()).second)
+            }
+        }
+    }
+}
+
+/**
+ * A mutable [TFlow] that provides the ability to [emit] values to the flow, handling backpressure
+ * by suspending the emitter.
+ *
+ * @see FrpNetwork.coalescingMutableTFlow
+ */
+@ExperimentalFrpApi
+class MutableTFlow<T>
+internal constructor(internal val network: Network, internal val impl: InputNode<T> = InputNode()) :
+    TFlow<T>() {
+    internal val name: String? = null
+
+    private val storage = AtomicReference<Job?>(null)
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+
+    /**
+     * Emits a [value] to this [TFlow], suspending the caller until the FRP transaction containing
+     * the emission has completed.
+     */
+    @ExperimentalFrpApi
+    suspend fun emit(value: T) {
+        coroutineScope {
+            val newEmit =
+                async(start = CoroutineStart.LAZY) {
+                    network.transaction { impl.visit(this, value) }.await()
+                }
+            val jobOrNull = storage.getAndSet(newEmit)
+            if (jobOrNull?.isActive != true) {
+                newEmit.await()
+            } else {
+                jobOrNull.join()
+            }
+        }
+    }
+
+    //    internal suspend fun emitInCurrentTransaction(value: T, evalScope: EvalScope) {
+    //        if (storage.getAndSet(just(value)) is None) {
+    //            impl.visit(evalScope)
+    //        }
+    //    }
+}
+
+private data object EmptyFlow : TFlow<Nothing>()
+
+internal class TFlowInit<out A>(val init: Init<TFlowImpl<A>>) : TFlow<A>() {
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+internal val <A> TFlow<A>.init: Init<TFlowImpl<A>>
+    get() =
+        when (this) {
+            is EmptyFlow -> constInit("EmptyFlow", neverImpl)
+            is TFlowInit -> init
+            is TFlowLoop -> init
+            is CoalescingMutableTFlow<*, A> -> constInit(name, impl.activated())
+            is MutableTFlow -> constInit(name, impl.activated())
+        }
+
+private inline fun <A> deferInline(crossinline block: suspend InitScope.() -> TFlow<A>): TFlow<A> =
+    TFlowInit(init(name = null) { block().init.connect(evalScope = this) })
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/TState.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/TState.kt
new file mode 100644
index 0000000..a4c6956
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/TState.kt
@@ -0,0 +1,492 @@
+/*
+ * 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.systemui.kairos
+
+import com.android.systemui.kairos.internal.DerivedMapCheap
+import com.android.systemui.kairos.internal.Init
+import com.android.systemui.kairos.internal.InitScope
+import com.android.systemui.kairos.internal.Network
+import com.android.systemui.kairos.internal.NoScope
+import com.android.systemui.kairos.internal.Schedulable
+import com.android.systemui.kairos.internal.TFlowImpl
+import com.android.systemui.kairos.internal.TStateImpl
+import com.android.systemui.kairos.internal.TStateSource
+import com.android.systemui.kairos.internal.activated
+import com.android.systemui.kairos.internal.cached
+import com.android.systemui.kairos.internal.constInit
+import com.android.systemui.kairos.internal.constS
+import com.android.systemui.kairos.internal.filterNode
+import com.android.systemui.kairos.internal.flatMap
+import com.android.systemui.kairos.internal.init
+import com.android.systemui.kairos.internal.map
+import com.android.systemui.kairos.internal.mapCheap
+import com.android.systemui.kairos.internal.mapImpl
+import com.android.systemui.kairos.internal.util.hashString
+import com.android.systemui.kairos.internal.zipStates
+import kotlin.reflect.KProperty
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+
+/**
+ * A time-varying value with discrete changes. Essentially, a combination of a [Transactional] that
+ * holds a value, and a [TFlow] that emits when the value changes.
+ */
+@ExperimentalFrpApi sealed class TState<out A>
+
+/** A [TState] that never changes. */
+@ExperimentalFrpApi
+fun <A> tStateOf(value: A): TState<A> {
+    val operatorName = "tStateOf"
+    val name = "$operatorName($value)"
+    return TStateInit(constInit(name, constS(name, operatorName, value)))
+}
+
+/** TODO */
+@ExperimentalFrpApi fun <A> Lazy<TState<A>>.defer(): TState<A> = deferInline { value }
+
+/** TODO */
+@ExperimentalFrpApi
+fun <A> FrpDeferredValue<TState<A>>.defer(): TState<A> = deferInline { unwrapped.await() }
+
+/** TODO */
+@ExperimentalFrpApi
+fun <A> deferTState(block: suspend FrpScope.() -> TState<A>): TState<A> = deferInline {
+    NoScope.runInFrpScope(block)
+}
+
+/**
+ * Returns a [TState] containing the results of applying [transform] to the value held by the
+ * original [TState].
+ */
+@ExperimentalFrpApi
+fun <A, B> TState<A>.map(transform: suspend FrpScope.(A) -> B): TState<B> {
+    val operatorName = "map"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            init.connect(evalScope = this).map(name, operatorName) {
+                NoScope.runInFrpScope { transform(it) }
+            }
+        }
+    )
+}
+
+/**
+ * Returns a [TState] that transforms the value held inside this [TState] by applying it to the
+ * [transform].
+ *
+ * Note that unlike [map], the result is not cached. This means that not only should [transform] be
+ * fast and pure, it should be *monomorphic* (1-to-1). Failure to do this means that [stateChanges]
+ * for the returned [TState] will operate unexpectedly, emitting at rates that do not reflect an
+ * observable change to the returned [TState].
+ */
+@ExperimentalFrpApi
+fun <A, B> TState<A>.mapCheapUnsafe(transform: suspend FrpScope.(A) -> B): TState<B> {
+    val operatorName = "map"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            init.connect(evalScope = this).mapCheap(name, operatorName) {
+                NoScope.runInFrpScope { transform(it) }
+            }
+        }
+    )
+}
+
+/**
+ * Returns a [TState] by combining the values held inside the given [TState]s by applying them to
+ * the given function [transform].
+ */
+@ExperimentalFrpApi
+fun <A, B, C> TState<A>.combineWith(
+    other: TState<B>,
+    transform: suspend FrpScope.(A, B) -> C,
+): TState<C> = combine(this, other, transform)
+
+/**
+ * Splits a [TState] of pairs into a pair of [TFlows][TState], where each returned [TState] holds
+ * hald of the original.
+ *
+ * Shorthand for:
+ * ```kotlin
+ * val lefts = map { it.first }
+ * val rights = map { it.second }
+ * return Pair(lefts, rights)
+ * ```
+ */
+@ExperimentalFrpApi
+fun <A, B> TState<Pair<A, B>>.unzip(): Pair<TState<A>, TState<B>> {
+    val left = map { it.first }
+    val right = map { it.second }
+    return left to right
+}
+
+/**
+ * Returns a [TState] by combining the values held inside the given [TStates][TState] into a [List].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A> Iterable<TState<A>>.combine(): TState<List<A>> {
+    val operatorName = "combine"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            zipStates(name, operatorName, states = map { it.init.connect(evalScope = this) })
+        }
+    )
+}
+
+/**
+ * Returns a [TState] by combining the values held inside the given [TStates][TState] into a [Map].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <K : Any, A> Map<K, TState<A>>.combine(): TState<Map<K, A>> {
+    val operatorName = "combine"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            zipStates(
+                name,
+                operatorName,
+                states = mapValues { it.value.init.connect(evalScope = this) },
+            )
+        }
+    )
+}
+
+/**
+ * Returns a [TState] whose value is generated with [transform] by combining the current values of
+ * each given [TState].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A, B> Iterable<TState<A>>.combine(transform: suspend FrpScope.(List<A>) -> B): TState<B> =
+    combine().map(transform)
+
+/**
+ * Returns a [TState] by combining the values held inside the given [TState]s into a [List].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A> combine(vararg states: TState<A>): TState<List<A>> = states.asIterable().combine()
+
+/**
+ * Returns a [TState] whose value is generated with [transform] by combining the current values of
+ * each given [TState].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A, B> combine(
+    vararg states: TState<A>,
+    transform: suspend FrpScope.(List<A>) -> B,
+): TState<B> = states.asIterable().combine(transform)
+
+/**
+ * Returns a [TState] whose value is generated with [transform] by combining the current values of
+ * each given [TState].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A, B, Z> combine(
+    stateA: TState<A>,
+    stateB: TState<B>,
+    transform: suspend FrpScope.(A, B) -> Z,
+): TState<Z> {
+    val operatorName = "combine"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            coroutineScope {
+                val dl1: Deferred<TStateImpl<A>> = async {
+                    stateA.init.connect(evalScope = this@init)
+                }
+                val dl2: Deferred<TStateImpl<B>> = async {
+                    stateB.init.connect(evalScope = this@init)
+                }
+                zipStates(name, operatorName, dl1.await(), dl2.await()) { a, b ->
+                    NoScope.runInFrpScope { transform(a, b) }
+                }
+            }
+        }
+    )
+}
+
+/**
+ * Returns a [TState] whose value is generated with [transform] by combining the current values of
+ * each given [TState].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A, B, C, Z> combine(
+    stateA: TState<A>,
+    stateB: TState<B>,
+    stateC: TState<C>,
+    transform: suspend FrpScope.(A, B, C) -> Z,
+): TState<Z> {
+    val operatorName = "combine"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            coroutineScope {
+                val dl1: Deferred<TStateImpl<A>> = async {
+                    stateA.init.connect(evalScope = this@init)
+                }
+                val dl2: Deferred<TStateImpl<B>> = async {
+                    stateB.init.connect(evalScope = this@init)
+                }
+                val dl3: Deferred<TStateImpl<C>> = async {
+                    stateC.init.connect(evalScope = this@init)
+                }
+                zipStates(name, operatorName, dl1.await(), dl2.await(), dl3.await()) { a, b, c ->
+                    NoScope.runInFrpScope { transform(a, b, c) }
+                }
+            }
+        }
+    )
+}
+
+/**
+ * Returns a [TState] whose value is generated with [transform] by combining the current values of
+ * each given [TState].
+ *
+ * @see TState.combineWith
+ */
+@ExperimentalFrpApi
+fun <A, B, C, D, Z> combine(
+    stateA: TState<A>,
+    stateB: TState<B>,
+    stateC: TState<C>,
+    stateD: TState<D>,
+    transform: suspend FrpScope.(A, B, C, D) -> Z,
+): TState<Z> {
+    val operatorName = "combine"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            coroutineScope {
+                val dl1: Deferred<TStateImpl<A>> = async {
+                    stateA.init.connect(evalScope = this@init)
+                }
+                val dl2: Deferred<TStateImpl<B>> = async {
+                    stateB.init.connect(evalScope = this@init)
+                }
+                val dl3: Deferred<TStateImpl<C>> = async {
+                    stateC.init.connect(evalScope = this@init)
+                }
+                val dl4: Deferred<TStateImpl<D>> = async {
+                    stateD.init.connect(evalScope = this@init)
+                }
+                zipStates(name, operatorName, dl1.await(), dl2.await(), dl3.await(), dl4.await()) {
+                    a,
+                    b,
+                    c,
+                    d ->
+                    NoScope.runInFrpScope { transform(a, b, c, d) }
+                }
+            }
+        }
+    )
+}
+
+/** Returns a [TState] by applying [transform] to the value held by the original [TState]. */
+@ExperimentalFrpApi
+fun <A, B> TState<A>.flatMap(transform: suspend FrpScope.(A) -> TState<B>): TState<B> {
+    val operatorName = "flatMap"
+    val name = operatorName
+    return TStateInit(
+        init(name) {
+            init.connect(this).flatMap(name, operatorName) { a ->
+                NoScope.runInFrpScope { transform(a) }.init.connect(this)
+            }
+        }
+    )
+}
+
+/** Shorthand for `flatMap { it }` */
+@ExperimentalFrpApi fun <A> TState<TState<A>>.flatten() = flatMap { it }
+
+/**
+ * Returns a [TStateSelector] that can be used to efficiently check if the input [TState] is
+ * currently holding a specific value.
+ *
+ * An example:
+ * ```
+ *   val lInt: TState<Int> = ...
+ *   val intSelector: TStateSelector<Int> = lInt.selector()
+ *   // Tracks if lInt is holding 1
+ *   val isOne: TState<Boolean> = intSelector.whenSelected(1)
+ * ```
+ *
+ * This is semantically equivalent to `val isOne = lInt.map { i -> i == 1 }`, but is significantly
+ * more efficient; specifically, using [TState.map] in this way incurs a `O(n)` performance hit,
+ * where `n` is the number of different [TState.map] operations used to track a specific value.
+ * [selector] internally uses a [HashMap] to lookup the appropriate downstream [TState] to update,
+ * and so operates in `O(1)`.
+ *
+ * Note that the result [TStateSelector] should be cached and re-used to gain the performance
+ * benefit.
+ *
+ * @see groupByKey
+ */
+@ExperimentalFrpApi
+fun <A> TState<A>.selector(numDistinctValues: Int? = null): TStateSelector<A> =
+    TStateSelector(
+        this,
+        stateChanges
+            .map { new -> mapOf(new to true, sampleDeferred().get() to false) }
+            .groupByKey(numDistinctValues),
+    )
+
+/**
+ * Tracks the currently selected value of type [A] from an upstream [TState].
+ *
+ * @see selector
+ */
+@ExperimentalFrpApi
+class TStateSelector<A>
+internal constructor(
+    private val upstream: TState<A>,
+    private val groupedChanges: GroupedTFlow<A, Boolean>,
+) {
+    /**
+     * Returns a [TState] that tracks whether the upstream [TState] is currently holding the given
+     * [value].
+     *
+     * @see selector
+     */
+    @ExperimentalFrpApi
+    fun whenSelected(value: A): TState<Boolean> {
+        val operatorName = "TStateSelector#whenSelected"
+        val name = "$operatorName[$value]"
+        return TStateInit(
+            init(name) {
+                DerivedMapCheap(
+                    name,
+                    operatorName,
+                    upstream = upstream.init.connect(evalScope = this),
+                    changes = groupedChanges.impl.eventsForKey(value),
+                ) {
+                    it == value
+                }
+            }
+        )
+    }
+
+    @ExperimentalFrpApi operator fun get(value: A): TState<Boolean> = whenSelected(value)
+}
+
+/** TODO */
+@ExperimentalFrpApi
+class MutableTState<T>
+internal constructor(internal val network: Network, initialValue: Deferred<T>) : TState<T>() {
+
+    private val input: CoalescingMutableTFlow<Deferred<T>, Deferred<T>?> =
+        CoalescingMutableTFlow(
+            coalesce = { _, new -> new },
+            network = network,
+            getInitialValue = { null },
+        )
+
+    internal val tState = run {
+        val changes = input.impl
+        val name = null
+        val operatorName = "MutableTState"
+        lateinit var state: TStateSource<T>
+        val calm: TFlowImpl<T> =
+            filterNode({ mapImpl(upstream = { changes.activated() }) { it!!.await() } }) { new ->
+                    new != state.getCurrentWithEpoch(evalScope = this).first
+                }
+                .cached()
+        state = TStateSource(name, operatorName, initialValue, calm)
+        @Suppress("DeferredResultUnused")
+        network.transaction {
+            calm.activate(evalScope = this, downstream = Schedulable.S(state))?.let {
+                (connection, needsEval) ->
+                state.upstreamConnection = connection
+                if (needsEval) {
+                    schedule(state)
+                }
+            }
+        }
+        TStateInit(constInit(name, state))
+    }
+
+    /** TODO */
+    @ExperimentalFrpApi fun setValue(value: T) = input.emit(CompletableDeferred(value))
+
+    @ExperimentalFrpApi
+    fun setValueDeferred(value: FrpDeferredValue<T>) = input.emit(value.unwrapped)
+}
+
+/** A forward-reference to a [TState], allowing for recursive definitions. */
+@ExperimentalFrpApi
+class TStateLoop<A> : TState<A>() {
+
+    private val name: String? = null
+
+    private val deferred = CompletableDeferred<TState<A>>()
+
+    internal val init: Init<TStateImpl<A>> =
+        init(name) { deferred.await().init.connect(evalScope = this) }
+
+    /** The [TState] this [TStateLoop] will forward to. */
+    @ExperimentalFrpApi
+    var loopback: TState<A>? = null
+        set(value) {
+            value?.let {
+                check(deferred.complete(value)) { "TStateLoop.loopback has already been set." }
+                field = value
+            }
+        }
+
+    @ExperimentalFrpApi
+    operator fun getValue(thisRef: Any?, property: KProperty<*>): TState<A> = this
+
+    @ExperimentalFrpApi
+    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: TState<A>) {
+        loopback = value
+    }
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+internal class TStateInit<A> internal constructor(internal val init: Init<TStateImpl<A>>) :
+    TState<A>() {
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+internal val <A> TState<A>.init: Init<TStateImpl<A>>
+    get() =
+        when (this) {
+            is TStateInit -> init
+            is TStateLoop -> init
+            is MutableTState -> tState.init
+        }
+
+private inline fun <A> deferInline(
+    crossinline block: suspend InitScope.() -> TState<A>
+): TState<A> = TStateInit(init(name = null) { block().init.connect(evalScope = this) })
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/Transactional.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/Transactional.kt
new file mode 100644
index 0000000..6b1c8c8
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/Transactional.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.systemui.kairos
+
+import com.android.systemui.kairos.internal.InitScope
+import com.android.systemui.kairos.internal.NoScope
+import com.android.systemui.kairos.internal.TransactionalImpl
+import com.android.systemui.kairos.internal.init
+import com.android.systemui.kairos.internal.transactionalImpl
+import com.android.systemui.kairos.internal.util.hashString
+import kotlinx.coroutines.CompletableDeferred
+
+/**
+ * A time-varying value. A [Transactional] encapsulates the idea of some continuous state; each time
+ * it is "sampled", a new result may be produced.
+ *
+ * Because FRP operates over an "idealized" model of Time that can be passed around as a data type,
+ * [Transactional]s are guaranteed to produce the same result if queried multiple times at the same
+ * (conceptual) time, in order to preserve _referential transparency_.
+ */
+@ExperimentalFrpApi
+class Transactional<out A> internal constructor(internal val impl: TState<TransactionalImpl<A>>) {
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+/** A constant [Transactional] that produces [value] whenever it is sampled. */
+@ExperimentalFrpApi
+fun <A> transactionalOf(value: A): Transactional<A> =
+    Transactional(tStateOf(TransactionalImpl.Const(CompletableDeferred(value))))
+
+/** TODO */
+@ExperimentalFrpApi
+fun <A> FrpDeferredValue<Transactional<A>>.defer(): Transactional<A> = deferInline {
+    unwrapped.await()
+}
+
+/** TODO */
+@ExperimentalFrpApi fun <A> Lazy<Transactional<A>>.defer(): Transactional<A> = deferInline { value }
+
+/** TODO */
+@ExperimentalFrpApi
+fun <A> deferTransactional(block: suspend FrpScope.() -> Transactional<A>): Transactional<A> =
+    deferInline {
+        NoScope.runInFrpScope(block)
+    }
+
+private inline fun <A> deferInline(
+    crossinline block: suspend InitScope.() -> Transactional<A>
+): Transactional<A> =
+    Transactional(TStateInit(init(name = null) { block().impl.init.connect(evalScope = this) }))
+
+/**
+ * Returns a [Transactional]. The passed [block] will be evaluated on demand at most once per
+ * transaction; any subsequent sampling within the same transaction will receive a cached value.
+ */
+@ExperimentalFrpApi
+fun <A> transactionally(block: suspend FrpTransactionScope.() -> A): Transactional<A> =
+    Transactional(tStateOf(transactionalImpl { runInTransactionScope(block) }))
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/debug/Debug.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/debug/Debug.kt
new file mode 100644
index 0000000..4f302a1
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/debug/Debug.kt
@@ -0,0 +1,180 @@
+/*
+ * 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.systemui.kairos.debug
+
+import com.android.systemui.kairos.MutableTState
+import com.android.systemui.kairos.TState
+import com.android.systemui.kairos.TStateInit
+import com.android.systemui.kairos.TStateLoop
+import com.android.systemui.kairos.internal.DerivedFlatten
+import com.android.systemui.kairos.internal.DerivedMap
+import com.android.systemui.kairos.internal.DerivedMapCheap
+import com.android.systemui.kairos.internal.DerivedZipped
+import com.android.systemui.kairos.internal.Init
+import com.android.systemui.kairos.internal.TStateDerived
+import com.android.systemui.kairos.internal.TStateImpl
+import com.android.systemui.kairos.internal.TStateSource
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.None
+import com.android.systemui.kairos.util.none
+import com.android.systemui.kairos.util.orElseGet
+
+// object IdGen {
+//    private val counter = AtomicLong()
+//    fun getId() = counter.getAndIncrement()
+// }
+
+typealias StateGraph = Graph<ActivationInfo>
+
+sealed class StateInfo(
+    val name: String,
+    val value: Maybe<Any?>,
+    val operator: String,
+    val epoch: Long?,
+)
+
+class Source(name: String, value: Maybe<Any?>, operator: String, epoch: Long) :
+    StateInfo(name, value, operator, epoch)
+
+class Derived(
+    name: String,
+    val type: DerivedStateType,
+    value: Maybe<Any?>,
+    operator: String,
+    epoch: Long?,
+) : StateInfo(name, value, operator, epoch)
+
+sealed interface DerivedStateType
+
+data object Flatten : DerivedStateType
+
+data class Mapped(val cheap: Boolean) : DerivedStateType
+
+data object Combine : DerivedStateType
+
+sealed class InitInfo(val name: String)
+
+class Uninitialized(name: String) : InitInfo(name)
+
+class Initialized(val state: StateInfo) : InitInfo(state.name)
+
+sealed interface ActivationInfo
+
+class Inactive(val name: String) : ActivationInfo
+
+class Active(val nodeInfo: StateInfo) : ActivationInfo
+
+class Dead(val name: String) : ActivationInfo
+
+data class Edge(val upstream: Any, val downstream: Any, val tag: Any? = null)
+
+data class Graph<T>(val nodes: Map<Any, T>, val edges: List<Edge>)
+
+internal fun TState<*>.dump(infoMap: MutableMap<Any, InitInfo>, edges: MutableList<Edge>) {
+    val init: Init<TStateImpl<Any?>> =
+        when (this) {
+            is TStateInit -> init
+            is TStateLoop -> init
+            is MutableTState -> tState.init
+        }
+    when (val stateMaybe = init.getUnsafe()) {
+        None -> {
+            infoMap[this] = Uninitialized(init.name ?: init.toString())
+        }
+        is Just -> {
+            stateMaybe.value.dump(infoMap, edges)
+        }
+    }
+}
+
+internal fun TStateImpl<*>.dump(infoById: MutableMap<Any, InitInfo>, edges: MutableList<Edge>) {
+    val state = this
+    if (state in infoById) return
+    val stateInfo =
+        when (state) {
+            is TStateDerived -> {
+                val type =
+                    when (state) {
+                        is DerivedFlatten -> {
+                            state.upstream.dump(infoById, edges)
+                            edges.add(
+                                Edge(upstream = state.upstream, downstream = state, tag = "outer")
+                            )
+                            state.upstream
+                                .getUnsafe()
+                                .orElseGet { null }
+                                ?.let {
+                                    edges.add(
+                                        Edge(upstream = it, downstream = state, tag = "inner")
+                                    )
+                                    it.dump(infoById, edges)
+                                }
+                            Flatten
+                        }
+                        is DerivedMap<*, *> -> {
+                            state.upstream.dump(infoById, edges)
+                            edges.add(Edge(upstream = state.upstream, downstream = state))
+                            Mapped(cheap = false)
+                        }
+                        is DerivedZipped<*, *> -> {
+                            state.upstream.forEach { (key, upstream) ->
+                                edges.add(
+                                    Edge(upstream = upstream, downstream = state, tag = "key=$key")
+                                )
+                                upstream.dump(infoById, edges)
+                            }
+                            Combine
+                        }
+                    }
+                Derived(
+                    state.name ?: state.operatorName,
+                    type,
+                    state.getCachedUnsafe(),
+                    state.operatorName,
+                    state.invalidatedEpoch,
+                )
+            }
+            is TStateSource ->
+                Source(
+                    state.name ?: state.operatorName,
+                    state.getStorageUnsafe(),
+                    state.operatorName,
+                    state.writeEpoch,
+                )
+            is DerivedMapCheap<*, *> -> {
+                state.upstream.dump(infoById, edges)
+                edges.add(Edge(upstream = state.upstream, downstream = state))
+                val type = Mapped(cheap = true)
+                Derived(
+                    state.name ?: state.operatorName,
+                    type,
+                    state.getUnsafe(),
+                    state.operatorName,
+                    null,
+                )
+            }
+        }
+    infoById[state] = Initialized(stateInfo)
+}
+
+private fun <A> TStateImpl<A>.getUnsafe(): Maybe<A> =
+    when (this) {
+        is TStateDerived -> getCachedUnsafe()
+        is TStateSource -> getStorageUnsafe()
+        is DerivedMapCheap<*, *> -> none
+    }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/BuildScopeImpl.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/BuildScopeImpl.kt
new file mode 100644
index 0000000..90f1aea
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/BuildScopeImpl.kt
@@ -0,0 +1,363 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.CoalescingMutableTFlow
+import com.android.systemui.kairos.FrpBuildScope
+import com.android.systemui.kairos.FrpCoalescingProducerScope
+import com.android.systemui.kairos.FrpDeferredValue
+import com.android.systemui.kairos.FrpEffectScope
+import com.android.systemui.kairos.FrpNetwork
+import com.android.systemui.kairos.FrpProducerScope
+import com.android.systemui.kairos.FrpSpec
+import com.android.systemui.kairos.FrpStateScope
+import com.android.systemui.kairos.FrpTransactionScope
+import com.android.systemui.kairos.GroupedTFlow
+import com.android.systemui.kairos.LocalFrpNetwork
+import com.android.systemui.kairos.MutableTFlow
+import com.android.systemui.kairos.TFlow
+import com.android.systemui.kairos.TFlowInit
+import com.android.systemui.kairos.groupByKey
+import com.android.systemui.kairos.init
+import com.android.systemui.kairos.internal.util.childScope
+import com.android.systemui.kairos.internal.util.launchOnCancel
+import com.android.systemui.kairos.internal.util.mapValuesParallel
+import com.android.systemui.kairos.launchEffect
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.None
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.map
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.startCoroutine
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CompletableJob
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.completeWith
+import kotlinx.coroutines.job
+
+internal class BuildScopeImpl(val stateScope: StateScopeImpl, val coroutineScope: CoroutineScope) :
+    BuildScope, StateScope by stateScope {
+
+    private val job: Job
+        get() = coroutineScope.coroutineContext.job
+
+    override val frpScope: FrpBuildScope = FrpBuildScopeImpl()
+
+    override suspend fun <R> runInBuildScope(block: suspend FrpBuildScope.() -> R): R {
+        val complete = CompletableDeferred<R>(parent = coroutineContext.job)
+        block.startCoroutine(
+            frpScope,
+            object : Continuation<R> {
+                override val context: CoroutineContext
+                    get() = EmptyCoroutineContext
+
+                override fun resumeWith(result: Result<R>) {
+                    complete.completeWith(result)
+                }
+            },
+        )
+        return complete.await()
+    }
+
+    private fun <A, T : TFlow<A>, S> buildTFlow(
+        constructFlow: (InputNode<A>) -> Pair<T, S>,
+        builder: suspend S.() -> Unit,
+    ): TFlow<A> {
+        var job: Job? = null
+        val stopEmitter = newStopEmitter()
+        val handle = this.job.invokeOnCompletion { stopEmitter.emit(Unit) }
+        // Create a child scope that will be kept alive beyond the end of this transaction.
+        val childScope = coroutineScope.childScope()
+        lateinit var emitter: Pair<T, S>
+        val inputNode =
+            InputNode<A>(
+                activate = {
+                    check(job == null) { "already activated" }
+                    job =
+                        reenterBuildScope(this@BuildScopeImpl, childScope).runInBuildScope {
+                            launchEffect {
+                                builder(emitter.second)
+                                handle.dispose()
+                                stopEmitter.emit(Unit)
+                            }
+                        }
+                },
+                deactivate = {
+                    checkNotNull(job) { "already deactivated" }.cancel()
+                    job = null
+                },
+            )
+        emitter = constructFlow(inputNode)
+        return with(frpScope) { emitter.first.takeUntil(stopEmitter) }
+    }
+
+    private fun <T> tFlowInternal(builder: suspend FrpProducerScope<T>.() -> Unit): TFlow<T> =
+        buildTFlow(
+            constructFlow = { inputNode ->
+                val flow = MutableTFlow(network, inputNode)
+                flow to
+                    object : FrpProducerScope<T> {
+                        override suspend fun emit(value: T) {
+                            flow.emit(value)
+                        }
+                    }
+            },
+            builder = builder,
+        )
+
+    private fun <In, Out> coalescingTFlowInternal(
+        getInitialValue: () -> Out,
+        coalesce: (old: Out, new: In) -> Out,
+        builder: suspend FrpCoalescingProducerScope<In>.() -> Unit,
+    ): TFlow<Out> =
+        buildTFlow(
+            constructFlow = { inputNode ->
+                val flow = CoalescingMutableTFlow(coalesce, network, getInitialValue, inputNode)
+                flow to
+                    object : FrpCoalescingProducerScope<In> {
+                        override fun emit(value: In) {
+                            flow.emit(value)
+                        }
+                    }
+            },
+            builder = builder,
+        )
+
+    private fun <A> asyncScopeInternal(block: FrpSpec<A>): Pair<FrpDeferredValue<A>, Job> {
+        val childScope = mutableChildBuildScope()
+        return FrpDeferredValue(deferAsync { childScope.runInBuildScope(block) }) to childScope.job
+    }
+
+    private fun <R> deferredInternal(block: suspend FrpBuildScope.() -> R): FrpDeferredValue<R> =
+        FrpDeferredValue(deferAsync { runInBuildScope(block) })
+
+    private fun deferredActionInternal(block: suspend FrpBuildScope.() -> Unit) {
+        deferAction { runInBuildScope(block) }
+    }
+
+    private fun <A> TFlow<A>.observeEffectInternal(
+        context: CoroutineContext,
+        block: suspend FrpEffectScope.(A) -> Unit,
+    ): Job {
+        val subRef = AtomicReference<Maybe<Output<A>>>(null)
+        val childScope = coroutineScope.childScope()
+        // When our scope is cancelled, deactivate this observer.
+        childScope.launchOnCancel(CoroutineName("TFlow.observeEffect")) {
+            subRef.getAndSet(None)?.let { output ->
+                if (output is Just) {
+                    @Suppress("DeferredResultUnused")
+                    network.transaction { scheduleDeactivation(output.value) }
+                }
+            }
+        }
+        // Defer so that we don't suspend the caller
+        deferAction {
+            val outputNode =
+                Output<A>(
+                    context = context,
+                    onDeath = { subRef.getAndSet(None)?.let { childScope.cancel() } },
+                    onEmit = { output ->
+                        if (subRef.get() is Just) {
+                            // Not cancelled, safe to emit
+                            val coroutine: suspend FrpEffectScope.() -> Unit = { block(output) }
+                            val complete = CompletableDeferred<Unit>(parent = coroutineContext.job)
+                            coroutine.startCoroutine(
+                                object : FrpEffectScope, FrpTransactionScope by frpScope {
+                                    override val frpCoroutineScope: CoroutineScope = childScope
+                                    override val frpNetwork: FrpNetwork =
+                                        LocalFrpNetwork(network, childScope, endSignal)
+                                },
+                                completion =
+                                    object : Continuation<Unit> {
+                                        override val context: CoroutineContext
+                                            get() = EmptyCoroutineContext
+
+                                        override fun resumeWith(result: Result<Unit>) {
+                                            complete.completeWith(result)
+                                        }
+                                    },
+                            )
+                            complete.await()
+                        }
+                    },
+                )
+            with(frpScope) { this@observeEffectInternal.takeUntil(endSignal) }
+                .init
+                .connect(evalScope = stateScope.evalScope)
+                .activate(evalScope = stateScope.evalScope, outputNode.schedulable)
+                ?.let { (conn, needsEval) ->
+                    outputNode.upstream = conn
+                    if (!subRef.compareAndSet(null, just(outputNode))) {
+                        // Job's already been cancelled, schedule deactivation
+                        scheduleDeactivation(outputNode)
+                    } else if (needsEval) {
+                        outputNode.schedule(evalScope = stateScope.evalScope)
+                    }
+                } ?: childScope.cancel()
+        }
+        return childScope.coroutineContext.job
+    }
+
+    private fun <A, B> TFlow<A>.mapBuildInternal(
+        transform: suspend FrpBuildScope.(A) -> B
+    ): TFlow<B> {
+        val childScope = coroutineScope.childScope()
+        return TFlowInit(
+            constInit(
+                "mapBuild",
+                mapImpl({ init.connect(evalScope = this) }) { spec ->
+                        reenterBuildScope(outerScope = this@BuildScopeImpl, childScope)
+                            .runInBuildScope {
+                                val (result, _) = asyncScope { transform(spec) }
+                                result.get()
+                            }
+                    }
+                    .cached(),
+            )
+        )
+    }
+
+    private fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestForKeyInternal(
+        init: FrpDeferredValue<Map<K, FrpSpec<B>>>,
+        numKeys: Int?,
+    ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> {
+        val eventsByKey: GroupedTFlow<K, Maybe<FrpSpec<A>>> = groupByKey(numKeys)
+        val initOut: Deferred<Map<K, B>> = deferAsync {
+            init.unwrapped.await().mapValuesParallel { (k, spec) ->
+                val newEnd = with(frpScope) { eventsByKey[k].skipNext() }
+                val newScope = childBuildScope(newEnd)
+                newScope.runInBuildScope(spec)
+            }
+        }
+        val childScope = coroutineScope.childScope()
+        val changesNode: TFlowImpl<Map<K, Maybe<A>>> =
+            mapImpl(upstream = { this@applyLatestForKeyInternal.init.connect(evalScope = this) }) {
+                upstreamMap ->
+                reenterBuildScope(this@BuildScopeImpl, childScope).run {
+                    upstreamMap.mapValuesParallel { (k: K, ma: Maybe<FrpSpec<A>>) ->
+                        ma.map { spec ->
+                            val newEnd = with(frpScope) { eventsByKey[k].skipNext() }
+                            val newScope = childBuildScope(newEnd)
+                            newScope.runInBuildScope(spec)
+                        }
+                    }
+                }
+            }
+        val changes: TFlow<Map<K, Maybe<A>>> =
+            TFlowInit(constInit("applyLatestForKey", changesNode.cached()))
+        // Ensure effects are observed; otherwise init will stay alive longer than expected
+        changes.observeEffectInternal(EmptyCoroutineContext) {}
+        return changes to FrpDeferredValue(initOut)
+    }
+
+    private fun newStopEmitter(): CoalescingMutableTFlow<Unit, Unit> =
+        CoalescingMutableTFlow(
+            coalesce = { _, _: Unit -> },
+            network = network,
+            getInitialValue = {},
+        )
+
+    private suspend fun childBuildScope(newEnd: TFlow<Any>): BuildScopeImpl {
+        val newCoroutineScope: CoroutineScope = coroutineScope.childScope()
+        return BuildScopeImpl(
+                stateScope = stateScope.childStateScope(newEnd),
+                coroutineScope = newCoroutineScope,
+            )
+            .apply {
+                // Ensure that once this transaction is done, the new child scope enters the
+                // completing state (kept alive so long as there are child jobs).
+                scheduleOutput(
+                    OneShot {
+                        // TODO: don't like this cast
+                        (newCoroutineScope.coroutineContext.job as CompletableJob).complete()
+                    }
+                )
+                runInBuildScope { endSignal.nextOnly().observe { newCoroutineScope.cancel() } }
+            }
+    }
+
+    private fun mutableChildBuildScope(): BuildScopeImpl {
+        val stopEmitter = newStopEmitter()
+        val childScope = coroutineScope.childScope()
+        childScope.coroutineContext.job.invokeOnCompletion { stopEmitter.emit(Unit) }
+        // Ensure that once this transaction is done, the new child scope enters the completing
+        // state (kept alive so long as there are child jobs).
+        scheduleOutput(
+            OneShot {
+                // TODO: don't like this cast
+                (childScope.coroutineContext.job as CompletableJob).complete()
+            }
+        )
+        return BuildScopeImpl(
+            stateScope = StateScopeImpl(evalScope = stateScope.evalScope, endSignal = stopEmitter),
+            coroutineScope = childScope,
+        )
+    }
+
+    private inner class FrpBuildScopeImpl : FrpBuildScope, FrpStateScope by stateScope.frpScope {
+
+        override fun <T> tFlow(builder: suspend FrpProducerScope<T>.() -> Unit): TFlow<T> =
+            tFlowInternal(builder)
+
+        override fun <In, Out> coalescingTFlow(
+            getInitialValue: () -> Out,
+            coalesce: (old: Out, new: In) -> Out,
+            builder: suspend FrpCoalescingProducerScope<In>.() -> Unit,
+        ): TFlow<Out> = coalescingTFlowInternal(getInitialValue, coalesce, builder)
+
+        override fun <A> asyncScope(block: FrpSpec<A>): Pair<FrpDeferredValue<A>, Job> =
+            asyncScopeInternal(block)
+
+        override fun <R> deferredBuildScope(
+            block: suspend FrpBuildScope.() -> R
+        ): FrpDeferredValue<R> = deferredInternal(block)
+
+        override fun deferredBuildScopeAction(block: suspend FrpBuildScope.() -> Unit) =
+            deferredActionInternal(block)
+
+        override fun <A> TFlow<A>.observe(
+            coroutineContext: CoroutineContext,
+            block: suspend FrpEffectScope.(A) -> Unit,
+        ): Job = observeEffectInternal(coroutineContext, block)
+
+        override fun <A, B> TFlow<A>.mapBuild(transform: suspend FrpBuildScope.(A) -> B): TFlow<B> =
+            mapBuildInternal(transform)
+
+        override fun <K, A, B> TFlow<Map<K, Maybe<FrpSpec<A>>>>.applyLatestSpecForKey(
+            initialSpecs: FrpDeferredValue<Map<K, FrpSpec<B>>>,
+            numKeys: Int?,
+        ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> =
+            applyLatestForKeyInternal(initialSpecs, numKeys)
+    }
+}
+
+private fun EvalScope.reenterBuildScope(
+    outerScope: BuildScopeImpl,
+    coroutineScope: CoroutineScope,
+) =
+    BuildScopeImpl(
+        stateScope = StateScopeImpl(evalScope = this, endSignal = outerScope.endSignal),
+        coroutineScope,
+    )
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/DeferScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/DeferScope.kt
new file mode 100644
index 0000000..f65307c
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/DeferScope.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.asyncImmediate
+import com.android.systemui.kairos.internal.util.launchImmediate
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.isActive
+
+internal typealias DeferScope = CoroutineScope
+
+internal inline fun DeferScope.deferAction(
+    start: CoroutineStart = CoroutineStart.UNDISPATCHED,
+    crossinline block: suspend () -> Unit,
+): Job {
+    check(isActive) { "Cannot perform deferral, scope already closed." }
+    return launchImmediate(start, CoroutineName("deferAction")) { block() }
+}
+
+internal inline fun <R> DeferScope.deferAsync(
+    start: CoroutineStart = CoroutineStart.UNDISPATCHED,
+    crossinline block: suspend () -> R,
+): Deferred<R> {
+    check(isActive) { "Cannot perform deferral, scope already closed." }
+    return asyncImmediate(start, CoroutineName("deferAsync")) { block() }
+}
+
+internal suspend inline fun <A> deferScope(noinline block: suspend DeferScope.() -> A): A =
+    coroutineScope(block)
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Demux.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Demux.kt
new file mode 100644
index 0000000..e7b9952
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Demux.kt
@@ -0,0 +1,349 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.android.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.hashString
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.flatMap
+import com.android.systemui.kairos.util.getMaybe
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+internal class DemuxNode<K, A>(
+    private val branchNodeByKey: ConcurrentHashMap<K, DemuxBranchNode<K, A>>,
+    val lifecycle: DemuxLifecycle<K, A>,
+    private val spec: DemuxActivator<K, A>,
+) : SchedulableNode {
+
+    val schedulable = Schedulable.N(this)
+
+    inline val mutex
+        get() = lifecycle.mutex
+
+    lateinit var upstreamConnection: NodeConnection<Map<K, A>>
+
+    fun getAndMaybeAddDownstream(key: K): DemuxBranchNode<K, A> =
+        branchNodeByKey.getOrPut(key) { DemuxBranchNode(key, this) }
+
+    override suspend fun schedule(evalScope: EvalScope) {
+        val upstreamResult = upstreamConnection.getPushEvent(evalScope)
+        if (upstreamResult is Just) {
+            coroutineScope {
+                val outerScope = this
+                mutex.withLock {
+                    coroutineScope {
+                        for ((key, _) in upstreamResult.value) {
+                            launch {
+                                branchNodeByKey[key]?.let { branch ->
+                                    outerScope.launch { branch.schedule(evalScope) }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    override suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) {
+        coroutineScope {
+            mutex.withLock {
+                for ((_, branchNode) in branchNodeByKey) {
+                    branchNode.downstreamSet.adjustDirectUpstream(
+                        coroutineScope = this,
+                        scheduler,
+                        oldDepth,
+                        newDepth,
+                    )
+                }
+            }
+        }
+    }
+
+    override suspend fun moveIndirectUpstreamToDirect(
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectSet: Set<MuxDeferredNode<*, *>>,
+        newDirectDepth: Int,
+    ) {
+        coroutineScope {
+            mutex.withLock {
+                for ((_, branchNode) in branchNodeByKey) {
+                    branchNode.downstreamSet.moveIndirectUpstreamToDirect(
+                        coroutineScope = this,
+                        scheduler,
+                        oldIndirectDepth,
+                        oldIndirectSet,
+                        newDirectDepth,
+                    )
+                }
+            }
+        }
+    }
+
+    override suspend fun adjustIndirectUpstream(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    ) {
+        coroutineScope {
+            mutex.withLock {
+                for ((_, branchNode) in branchNodeByKey) {
+                    branchNode.downstreamSet.adjustIndirectUpstream(
+                        coroutineScope = this,
+                        scheduler,
+                        oldDepth,
+                        newDepth,
+                        removals,
+                        additions,
+                    )
+                }
+            }
+        }
+    }
+
+    override suspend fun moveDirectUpstreamToIndirect(
+        scheduler: Scheduler,
+        oldDirectDepth: Int,
+        newIndirectDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        coroutineScope {
+            mutex.withLock {
+                for ((_, branchNode) in branchNodeByKey) {
+                    branchNode.downstreamSet.moveDirectUpstreamToIndirect(
+                        coroutineScope = this,
+                        scheduler,
+                        oldDirectDepth,
+                        newIndirectDepth,
+                        newIndirectSet,
+                    )
+                }
+            }
+        }
+    }
+
+    override suspend fun removeIndirectUpstream(
+        scheduler: Scheduler,
+        depth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        coroutineScope {
+            mutex.withLock {
+                lifecycle.lifecycleState = DemuxLifecycleState.Dead
+                for ((_, branchNode) in branchNodeByKey) {
+                    branchNode.downstreamSet.removeIndirectUpstream(
+                        coroutineScope = this,
+                        scheduler,
+                        depth,
+                        indirectSet,
+                    )
+                }
+            }
+        }
+    }
+
+    override suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) {
+        coroutineScope {
+            mutex.withLock {
+                lifecycle.lifecycleState = DemuxLifecycleState.Dead
+                for ((_, branchNode) in branchNodeByKey) {
+                    branchNode.downstreamSet.removeDirectUpstream(
+                        coroutineScope = this,
+                        scheduler,
+                        depth,
+                    )
+                }
+            }
+        }
+    }
+
+    suspend fun removeDownstreamAndDeactivateIfNeeded(key: K) {
+        val deactivate =
+            mutex.withLock {
+                branchNodeByKey.remove(key)
+                branchNodeByKey.isEmpty()
+            }
+        if (deactivate) {
+            // No need for mutex here; no more concurrent changes to can occur during this phase
+            lifecycle.lifecycleState = DemuxLifecycleState.Inactive(spec)
+            upstreamConnection.removeDownstreamAndDeactivateIfNeeded(downstream = schedulable)
+        }
+    }
+}
+
+internal class DemuxBranchNode<K, A>(val key: K, private val demuxNode: DemuxNode<K, A>) :
+    PushNode<A> {
+
+    private val mutex = Mutex()
+
+    val downstreamSet = DownstreamSet()
+
+    override val depthTracker: DepthTracker
+        get() = demuxNode.upstreamConnection.depthTracker
+
+    override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean =
+        demuxNode.upstreamConnection.hasCurrentValue(transactionStore)
+
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> =
+        demuxNode.upstreamConnection.getPushEvent(evalScope).flatMap { it.getMaybe(key) }
+
+    override suspend fun addDownstream(downstream: Schedulable) {
+        mutex.withLock { downstreamSet.add(downstream) }
+    }
+
+    override suspend fun removeDownstream(downstream: Schedulable) {
+        mutex.withLock { downstreamSet.remove(downstream) }
+    }
+
+    override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) {
+        val canDeactivate =
+            mutex.withLock {
+                downstreamSet.remove(downstream)
+                downstreamSet.isEmpty()
+            }
+        if (canDeactivate) {
+            demuxNode.removeDownstreamAndDeactivateIfNeeded(key)
+        }
+    }
+
+    override suspend fun deactivateIfNeeded() {
+        if (mutex.withLock { downstreamSet.isEmpty() }) {
+            demuxNode.removeDownstreamAndDeactivateIfNeeded(key)
+        }
+    }
+
+    override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) {
+        if (mutex.withLock { downstreamSet.isEmpty() }) {
+            evalScope.scheduleDeactivation(this)
+        }
+    }
+
+    suspend fun schedule(evalScope: EvalScope) {
+        if (!coroutineScope { mutex.withLock { scheduleAll(downstreamSet, evalScope) } }) {
+            evalScope.scheduleDeactivation(this)
+        }
+    }
+}
+
+internal fun <K, A> DemuxImpl(
+    upstream: suspend EvalScope.() -> TFlowImpl<Map<K, A>>,
+    numKeys: Int?,
+): DemuxImpl<K, A> =
+    DemuxImpl(
+        DemuxLifecycle(
+            object : DemuxActivator<K, A> {
+                override suspend fun activate(
+                    evalScope: EvalScope,
+                    lifecycle: DemuxLifecycle<K, A>,
+                ): Pair<DemuxNode<K, A>, Boolean>? {
+                    val dmux = DemuxNode(ConcurrentHashMap(numKeys ?: 16), lifecycle, this)
+                    return upstream
+                        .invoke(evalScope)
+                        .activate(evalScope, downstream = dmux.schedulable)
+                        ?.let { (conn, needsEval) ->
+                            dmux.apply { upstreamConnection = conn } to needsEval
+                        }
+                }
+            }
+        )
+    )
+
+internal class DemuxImpl<in K, out A>(private val dmux: DemuxLifecycle<K, A>) {
+    fun eventsForKey(key: K): TFlowImpl<A> = TFlowCheap { downstream ->
+        dmux.activate(evalScope = this, key)?.let { (branchNode, needsEval) ->
+            branchNode.addDownstream(downstream)
+            val branchNeedsEval = needsEval && branchNode.getPushEvent(evalScope = this) is Just
+            ActivationResult(
+                connection = NodeConnection(branchNode, branchNode),
+                needsEval = branchNeedsEval,
+            )
+        }
+    }
+}
+
+internal class DemuxLifecycle<K, A>(@Volatile var lifecycleState: DemuxLifecycleState<K, A>) {
+    val mutex = Mutex()
+
+    override fun toString(): String = "TFlowDmuxState[$hashString][$lifecycleState][$mutex]"
+
+    suspend fun activate(evalScope: EvalScope, key: K): Pair<DemuxBranchNode<K, A>, Boolean>? =
+        coroutineScope {
+            mutex
+                .withLock {
+                    when (val state = lifecycleState) {
+                        is DemuxLifecycleState.Dead -> null
+                        is DemuxLifecycleState.Active ->
+                            state.node.getAndMaybeAddDownstream(key) to
+                                async {
+                                    state.node.upstreamConnection.hasCurrentValue(
+                                        evalScope.transactionStore
+                                    )
+                                }
+                        is DemuxLifecycleState.Inactive -> {
+                            state.spec
+                                .activate(evalScope, this@DemuxLifecycle)
+                                .also { result ->
+                                    lifecycleState =
+                                        if (result == null) {
+                                            DemuxLifecycleState.Dead
+                                        } else {
+                                            DemuxLifecycleState.Active(result.first)
+                                        }
+                                }
+                                ?.let { (node, needsEval) ->
+                                    node.getAndMaybeAddDownstream(key) to
+                                        CompletableDeferred(needsEval)
+                                }
+                        }
+                    }
+                }
+                ?.let { (branch, result) -> branch to result.await() }
+        }
+}
+
+internal sealed interface DemuxLifecycleState<out K, out A> {
+    class Inactive<K, A>(val spec: DemuxActivator<K, A>) : DemuxLifecycleState<K, A> {
+        override fun toString(): String = "Inactive"
+    }
+
+    class Active<K, A>(val node: DemuxNode<K, A>) : DemuxLifecycleState<K, A> {
+        override fun toString(): String = "Active(node=$node)"
+    }
+
+    data object Dead : DemuxLifecycleState<Nothing, Nothing>
+}
+
+internal interface DemuxActivator<K, A> {
+    suspend fun activate(
+        evalScope: EvalScope,
+        lifecycle: DemuxLifecycle<K, A>,
+    ): Pair<DemuxNode<K, A>, Boolean>?
+}
+
+internal inline fun <K, A> DemuxLifecycle(onSubscribe: DemuxActivator<K, A>) =
+    DemuxLifecycle(DemuxLifecycleState.Inactive(onSubscribe))
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/EvalScopeImpl.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/EvalScopeImpl.kt
new file mode 100644
index 0000000..815473f
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/EvalScopeImpl.kt
@@ -0,0 +1,119 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.FrpDeferredValue
+import com.android.systemui.kairos.FrpTransactionScope
+import com.android.systemui.kairos.TFlow
+import com.android.systemui.kairos.TFlowInit
+import com.android.systemui.kairos.TFlowLoop
+import com.android.systemui.kairos.TState
+import com.android.systemui.kairos.TStateInit
+import com.android.systemui.kairos.Transactional
+import com.android.systemui.kairos.emptyTFlow
+import com.android.systemui.kairos.init
+import com.android.systemui.kairos.mapCheap
+import com.android.systemui.kairos.switch
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.startCoroutine
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.completeWith
+import kotlinx.coroutines.job
+
+internal class EvalScopeImpl(networkScope: NetworkScope, deferScope: DeferScope) :
+    EvalScope, NetworkScope by networkScope, DeferScope by deferScope {
+
+    private suspend fun <A> Transactional<A>.sample(): A =
+        impl.sample().sample(this@EvalScopeImpl).await()
+
+    private suspend fun <A> TState<A>.sample(): A =
+        init.connect(evalScope = this@EvalScopeImpl).getCurrentWithEpoch(this@EvalScopeImpl).first
+
+    private val <A> Transactional<A>.deferredValue: FrpDeferredValue<A>
+        get() = FrpDeferredValue(deferAsync { sample() })
+
+    private val <A> TState<A>.deferredValue: FrpDeferredValue<A>
+        get() = FrpDeferredValue(deferAsync { sample() })
+
+    private val nowInternal: TFlow<Unit> by lazy {
+        var result by TFlowLoop<Unit>()
+        result =
+            TStateInit(
+                    constInit(
+                        "now",
+                        mkState(
+                            "now",
+                            "now",
+                            this,
+                            { result.mapCheap { emptyTFlow }.init.connect(evalScope = this) },
+                            CompletableDeferred(
+                                TFlowInit(
+                                    constInit(
+                                        "now",
+                                        TFlowCheap {
+                                            ActivationResult(
+                                                connection = NodeConnection(AlwaysNode, AlwaysNode),
+                                                needsEval = true,
+                                            )
+                                        },
+                                    )
+                                )
+                            ),
+                        ),
+                    )
+                )
+                .switch()
+        result
+    }
+
+    private fun <R> deferredInternal(
+        block: suspend FrpTransactionScope.() -> R
+    ): FrpDeferredValue<R> = FrpDeferredValue(deferAsync { runInTransactionScope(block) })
+
+    override suspend fun <R> runInTransactionScope(block: suspend FrpTransactionScope.() -> R): R {
+        val complete = CompletableDeferred<R>(parent = coroutineContext.job)
+        block.startCoroutine(
+            frpScope,
+            object : Continuation<R> {
+                override val context: CoroutineContext
+                    get() = EmptyCoroutineContext
+
+                override fun resumeWith(result: Result<R>) {
+                    complete.completeWith(result)
+                }
+            },
+        )
+        return complete.await()
+    }
+
+    override val frpScope: FrpTransactionScope = FrpTransactionScopeImpl()
+
+    inner class FrpTransactionScopeImpl : FrpTransactionScope {
+        override fun <A> Transactional<A>.sampleDeferred(): FrpDeferredValue<A> = deferredValue
+
+        override fun <A> TState<A>.sampleDeferred(): FrpDeferredValue<A> = deferredValue
+
+        override fun <R> deferredTransactionScope(
+            block: suspend FrpTransactionScope.() -> R
+        ): FrpDeferredValue<R> = deferredInternal(block)
+
+        override val now: TFlow<Unit>
+            get() = nowInternal
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/FilterNode.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/FilterNode.kt
new file mode 100644
index 0000000..bc06a36
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/FilterNode.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.none
+
+internal inline fun <A, B> mapMaybeNode(
+    crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>,
+    crossinline f: suspend EvalScope.(A) -> Maybe<B>,
+): TFlowImpl<B> {
+    return DemuxImpl(
+            {
+                mapImpl(getPulse) {
+                    val maybeResult = f(it)
+                    if (maybeResult is Just) {
+                        mapOf(Unit to maybeResult.value)
+                    } else {
+                        emptyMap()
+                    }
+                }
+            },
+            numKeys = 1,
+        )
+        .eventsForKey(Unit)
+}
+
+internal inline fun <A> filterNode(
+    crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>,
+    crossinline f: suspend EvalScope.(A) -> Boolean,
+): TFlowImpl<A> = mapMaybeNode(getPulse) { if (f(it)) just(it) else none }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Graph.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Graph.kt
new file mode 100644
index 0000000..3aec319
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Graph.kt
@@ -0,0 +1,530 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Bag
+import java.util.TreeMap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Tracks all upstream connections for Mux nodes.
+ *
+ * Connections come in two flavors:
+ * 1. **DIRECT** :: The upstream node may emit events that would cause the owner of this depth
+ *    tracker to also emit.
+ * 2. **INDIRECT** :: The upstream node will not emit events, but may start doing so in a future
+ *    transaction (at which point its depth will change to DIRECT).
+ *
+ * DIRECT connections are the standard, active connections that propagate events through the graph.
+ * They are used to calculate the evaluation depth of a node, so that it is only visited once it is
+ * certain that all DIRECT upstream connections have already been visited (or are not emitting in
+ * the current transaction).
+ *
+ * It is *invalid* for a node to be directly upstream of itself. Doing so is an error.
+ *
+ * INDIRECT connections identify nodes that are still "alive" (should not be garbage-collected) but
+ * are presently "dormant". This only occurs when a MuxDeferredNode has nothing switched-in, but is
+ * still connected to its "patches" upstream node, implying that something *may* be switched-in at a
+ * later time.
+ *
+ * It is *invalid* for a node to be indirectly upstream of itself. These connections are
+ * automatically filtered out.
+ *
+ * When there are no connections, either DIRECT or INDIRECT, a node *dies* and all incoming/outgoing
+ * connections are freed so that it can be garbage-collected.
+ *
+ * Note that there is an edge case where a MuxDeferredNode is connected to itself via its "patches"
+ * upstream node. In this case:
+ * 1. If the node has switched-in upstream nodes, then this is perfectly valid. Downstream nodes
+ *    will see a direct connection to this MuxDeferredNode.
+ * 2. Otherwise, the node would normally be considered "dormant" and downstream nodes would see an
+ *    indirect connection. However, because a node cannot be indirectly upstream of itself, then the
+ *    MuxDeferredNode sees no connection via its patches upstream node, and so is considered "dead".
+ *    Conceptually, this makes some sense: The only way for this recursive MuxDeferredNode to become
+ *    non-dormant is to switch some upstream nodes back in, but since the patches node is itself,
+ *    this will never happen.
+ *
+ * This behavior underpins the recursive definition of `nextOnly`.
+ */
+internal class DepthTracker {
+
+    @Volatile var snapshotIsDirect = true
+    @Volatile private var snapshotIsIndirectRoot = false
+
+    private inline val snapshotIsIndirect: Boolean
+        get() = !snapshotIsDirect
+
+    @Volatile var snapshotIndirectDepth: Int = 0
+    @Volatile var snapshotDirectDepth: Int = 0
+
+    private val _snapshotIndirectRoots = HashSet<MuxDeferredNode<*, *>>()
+    val snapshotIndirectRoots
+        get() = _snapshotIndirectRoots.toSet()
+
+    private val indirectAdditions = HashSet<MuxDeferredNode<*, *>>()
+    private val indirectRemovals = HashSet<MuxDeferredNode<*, *>>()
+    private val dirty_directUpstreamDepths = TreeMap<Int, Int>()
+    private val dirty_indirectUpstreamDepths = TreeMap<Int, Int>()
+    private val dirty_indirectUpstreamRoots = Bag<MuxDeferredNode<*, *>>()
+    @Volatile var dirty_directDepth = 0
+    @Volatile private var dirty_indirectDepth = 0
+    @Volatile private var dirty_depthIsDirect = true
+    @Volatile private var dirty_isIndirectRoot = false
+
+    suspend fun schedule(scheduler: Scheduler, node: MuxNode<*, *, *>) {
+        if (dirty_depthIsDirect) {
+            scheduler.schedule(dirty_directDepth, node)
+        } else {
+            scheduler.scheduleIndirect(dirty_indirectDepth, node)
+        }
+    }
+
+    // only used by MuxDeferred
+    // and only when there is a direct connection to the patch node
+    fun setIsIndirectRoot(isRoot: Boolean): Boolean {
+        if (isRoot != dirty_isIndirectRoot) {
+            dirty_isIndirectRoot = isRoot
+            return !dirty_depthIsDirect
+        }
+        return false
+    }
+
+    // adds an upstream connection, and recalcs depth
+    // returns true if depth has changed
+    fun addDirectUpstream(oldDepth: Int?, newDepth: Int): Boolean {
+        if (oldDepth != null) {
+            dirty_directUpstreamDepths.compute(oldDepth) { _, count ->
+                count?.minus(1)?.takeIf { it > 0 }
+            }
+        }
+        dirty_directUpstreamDepths.compute(newDepth) { _, current -> current?.plus(1) ?: 1 }
+        return recalcDepth()
+    }
+
+    private fun recalcDepth(): Boolean {
+        val newDepth =
+            dirty_directUpstreamDepths.lastEntry()?.let { (maxDepth, _) -> maxDepth + 1 } ?: 0
+
+        val isDirect = dirty_directUpstreamDepths.isNotEmpty()
+        val isDirectChanged = dirty_depthIsDirect != isDirect
+        dirty_depthIsDirect = isDirect
+
+        return (newDepth != dirty_directDepth).also { dirty_directDepth = newDepth } or
+            isDirectChanged
+    }
+
+    private fun recalcIndirDepth(): Boolean {
+        val newDepth =
+            dirty_indirectUpstreamDepths.lastEntry()?.let { (maxDepth, _) -> maxDepth + 1 } ?: 0
+        return (!dirty_depthIsDirect && !dirty_isIndirectRoot && newDepth != dirty_indirectDepth)
+            .also { dirty_indirectDepth = newDepth }
+    }
+
+    fun removeDirectUpstream(depth: Int): Boolean {
+        dirty_directUpstreamDepths.compute(depth) { _, count -> count?.minus(1)?.takeIf { it > 0 } }
+        return recalcDepth()
+    }
+
+    fun addIndirectUpstream(oldDepth: Int?, newDepth: Int): Boolean =
+        if (oldDepth == newDepth) {
+            false
+        } else {
+            if (oldDepth != null) {
+                dirty_indirectUpstreamDepths.compute(oldDepth) { _, current ->
+                    current?.minus(1)?.takeIf { it > 0 }
+                }
+            }
+            dirty_indirectUpstreamDepths.compute(newDepth) { _, current -> current?.plus(1) ?: 1 }
+            recalcIndirDepth()
+        }
+
+    fun removeIndirectUpstream(depth: Int): Boolean {
+        dirty_indirectUpstreamDepths.compute(depth) { _, current ->
+            current?.minus(1)?.takeIf { it > 0 }
+        }
+        return recalcIndirDepth()
+    }
+
+    fun updateIndirectRoots(
+        additions: Set<MuxDeferredNode<*, *>>? = null,
+        removals: Set<MuxDeferredNode<*, *>>? = null,
+        butNot: MuxDeferredNode<*, *>? = null,
+    ): Boolean {
+        val addsChanged =
+            additions
+                ?.let { dirty_indirectUpstreamRoots.addAll(additions, butNot) }
+                ?.let {
+                    indirectAdditions.addAll(indirectRemovals.applyRemovalDiff(it))
+                    true
+                } ?: false
+        val removalsChanged =
+            removals
+                ?.let { dirty_indirectUpstreamRoots.removeAll(removals) }
+                ?.let {
+                    indirectRemovals.addAll(indirectAdditions.applyRemovalDiff(it))
+                    true
+                } ?: false
+        return (!dirty_depthIsDirect && (addsChanged || removalsChanged))
+    }
+
+    private fun <T> HashSet<T>.applyRemovalDiff(changeSet: Set<T>): Set<T> {
+        val remainder = HashSet<T>()
+        for (element in changeSet) {
+            if (!add(element)) {
+                remainder.add(element)
+            }
+        }
+        return remainder
+    }
+
+    suspend fun propagateChanges(scheduler: Scheduler, muxNode: MuxNode<*, *, *>) {
+        if (isDirty()) {
+            schedule(scheduler, muxNode)
+        }
+    }
+
+    fun applyChanges(
+        coroutineScope: CoroutineScope,
+        scheduler: Scheduler,
+        downstreamSet: DownstreamSet,
+        muxNode: MuxNode<*, *, *>,
+    ) {
+        when {
+            dirty_depthIsDirect -> {
+                if (snapshotIsDirect) {
+                    downstreamSet.adjustDirectUpstream(
+                        coroutineScope,
+                        scheduler,
+                        oldDepth = snapshotDirectDepth,
+                        newDepth = dirty_directDepth,
+                    )
+                } else {
+                    downstreamSet.moveIndirectUpstreamToDirect(
+                        coroutineScope,
+                        scheduler,
+                        oldIndirectDepth = snapshotIndirectDepth,
+                        oldIndirectSet =
+                            buildSet {
+                                addAll(snapshotIndirectRoots)
+                                if (snapshotIsIndirectRoot) {
+                                    add(muxNode as MuxDeferredNode<*, *>)
+                                }
+                            },
+                        newDirectDepth = dirty_directDepth,
+                    )
+                }
+            }
+
+            dirty_hasIndirectUpstream() || dirty_isIndirectRoot -> {
+                if (snapshotIsDirect) {
+                    downstreamSet.moveDirectUpstreamToIndirect(
+                        coroutineScope,
+                        scheduler,
+                        oldDirectDepth = snapshotDirectDepth,
+                        newIndirectDepth = dirty_indirectDepth,
+                        newIndirectSet =
+                            buildSet {
+                                addAll(dirty_indirectUpstreamRoots)
+                                if (dirty_isIndirectRoot) {
+                                    add(muxNode as MuxDeferredNode<*, *>)
+                                }
+                            },
+                    )
+                } else {
+                    downstreamSet.adjustIndirectUpstream(
+                        coroutineScope,
+                        scheduler,
+                        oldDepth = snapshotIndirectDepth,
+                        newDepth = dirty_indirectDepth,
+                        removals =
+                            buildSet {
+                                addAll(indirectRemovals)
+                                if (snapshotIsIndirectRoot && !dirty_isIndirectRoot) {
+                                    add(muxNode as MuxDeferredNode<*, *>)
+                                }
+                            },
+                        additions =
+                            buildSet {
+                                addAll(indirectAdditions)
+                                if (!snapshotIsIndirectRoot && dirty_isIndirectRoot) {
+                                    add(muxNode as MuxDeferredNode<*, *>)
+                                }
+                            },
+                    )
+                }
+            }
+
+            else -> {
+                // die
+                muxNode.lifecycle.lifecycleState = MuxLifecycleState.Dead
+
+                if (snapshotIsDirect) {
+                    downstreamSet.removeDirectUpstream(
+                        coroutineScope,
+                        scheduler,
+                        depth = snapshotDirectDepth,
+                    )
+                } else {
+                    downstreamSet.removeIndirectUpstream(
+                        coroutineScope,
+                        scheduler,
+                        depth = snapshotIndirectDepth,
+                        indirectSet =
+                            buildSet {
+                                addAll(snapshotIndirectRoots)
+                                if (snapshotIsIndirectRoot) {
+                                    add(muxNode as MuxDeferredNode<*, *>)
+                                }
+                            },
+                    )
+                }
+                downstreamSet.clear()
+            }
+        }
+        reset()
+    }
+
+    fun dirty_hasDirectUpstream(): Boolean = dirty_directUpstreamDepths.isNotEmpty()
+
+    private fun dirty_hasIndirectUpstream(): Boolean = dirty_indirectUpstreamRoots.isNotEmpty()
+
+    override fun toString(): String =
+        "DepthTracker(" +
+            "sIsDirect=$snapshotIsDirect, " +
+            "sDirectDepth=$snapshotDirectDepth, " +
+            "sIndirectDepth=$snapshotIndirectDepth, " +
+            "sIndirectRoots=$snapshotIndirectRoots, " +
+            "dIsIndirectRoot=$dirty_isIndirectRoot, " +
+            "dDirectDepths=$dirty_directUpstreamDepths, " +
+            "dIndirectDepths=$dirty_indirectUpstreamDepths, " +
+            "dIndirectRoots=$dirty_indirectUpstreamRoots" +
+            ")"
+
+    fun reset() {
+        snapshotIsDirect = dirty_hasDirectUpstream()
+        snapshotDirectDepth = dirty_directDepth
+        snapshotIndirectDepth = dirty_indirectDepth
+        snapshotIsIndirectRoot = dirty_isIndirectRoot
+        if (indirectAdditions.isNotEmpty() || indirectRemovals.isNotEmpty()) {
+            _snapshotIndirectRoots.clear()
+            _snapshotIndirectRoots.addAll(dirty_indirectUpstreamRoots)
+        }
+        indirectAdditions.clear()
+        indirectRemovals.clear()
+        //        check(!isDirty()) { "should not be dirty after a reset" }
+    }
+
+    fun isDirty(): Boolean =
+        when {
+            snapshotIsDirect -> !dirty_depthIsDirect || snapshotDirectDepth != dirty_directDepth
+            snapshotIsIndirectRoot -> dirty_depthIsDirect || !dirty_isIndirectRoot
+            else ->
+                dirty_depthIsDirect ||
+                    dirty_isIndirectRoot ||
+                    snapshotIndirectDepth != dirty_indirectDepth ||
+                    indirectAdditions.isNotEmpty() ||
+                    indirectRemovals.isNotEmpty()
+        }
+
+    fun dirty_depthIncreased(): Boolean =
+        snapshotDirectDepth < dirty_directDepth || snapshotIsIndirect && dirty_hasDirectUpstream()
+}
+
+/**
+ * Tracks downstream nodes to be scheduled when the owner of this DownstreamSet produces a value in
+ * a transaction.
+ */
+internal class DownstreamSet {
+
+    val outputs = HashSet<Output<*>>()
+    val stateWriters = mutableListOf<TStateSource<*>>()
+    val muxMovers = HashSet<MuxDeferredNode<*, *>>()
+    val nodes = HashSet<SchedulableNode>()
+
+    fun add(schedulable: Schedulable) {
+        when (schedulable) {
+            is Schedulable.S -> stateWriters.add(schedulable.state)
+            is Schedulable.M -> muxMovers.add(schedulable.muxMover)
+            is Schedulable.N -> nodes.add(schedulable.node)
+            is Schedulable.O -> outputs.add(schedulable.output)
+        }
+    }
+
+    fun remove(schedulable: Schedulable) {
+        when (schedulable) {
+            is Schedulable.S -> error("WTF: latches are never removed")
+            is Schedulable.M -> muxMovers.remove(schedulable.muxMover)
+            is Schedulable.N -> nodes.remove(schedulable.node)
+            is Schedulable.O -> outputs.remove(schedulable.output)
+        }
+    }
+
+    fun adjustDirectUpstream(
+        coroutineScope: CoroutineScope,
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+    ) =
+        coroutineScope.run {
+            for (node in nodes) {
+                launch { node.adjustDirectUpstream(scheduler, oldDepth, newDepth) }
+            }
+        }
+
+    fun moveIndirectUpstreamToDirect(
+        coroutineScope: CoroutineScope,
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectSet: Set<MuxDeferredNode<*, *>>,
+        newDirectDepth: Int,
+    ) =
+        coroutineScope.run {
+            for (node in nodes) {
+                launch {
+                    node.moveIndirectUpstreamToDirect(
+                        scheduler,
+                        oldIndirectDepth,
+                        oldIndirectSet,
+                        newDirectDepth,
+                    )
+                }
+            }
+            for (mover in muxMovers) {
+                launch {
+                    mover.moveIndirectPatchNodeToDirect(scheduler, oldIndirectDepth, oldIndirectSet)
+                }
+            }
+        }
+
+    fun adjustIndirectUpstream(
+        coroutineScope: CoroutineScope,
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    ) =
+        coroutineScope.run {
+            for (node in nodes) {
+                launch {
+                    node.adjustIndirectUpstream(scheduler, oldDepth, newDepth, removals, additions)
+                }
+            }
+            for (mover in muxMovers) {
+                launch {
+                    mover.adjustIndirectPatchNode(
+                        scheduler,
+                        oldDepth,
+                        newDepth,
+                        removals,
+                        additions,
+                    )
+                }
+            }
+        }
+
+    fun moveDirectUpstreamToIndirect(
+        coroutineScope: CoroutineScope,
+        scheduler: Scheduler,
+        oldDirectDepth: Int,
+        newIndirectDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) =
+        coroutineScope.run {
+            for (node in nodes) {
+                launch {
+                    node.moveDirectUpstreamToIndirect(
+                        scheduler,
+                        oldDirectDepth,
+                        newIndirectDepth,
+                        newIndirectSet,
+                    )
+                }
+            }
+            for (mover in muxMovers) {
+                launch {
+                    mover.moveDirectPatchNodeToIndirect(scheduler, newIndirectDepth, newIndirectSet)
+                }
+            }
+        }
+
+    fun removeIndirectUpstream(
+        coroutineScope: CoroutineScope,
+        scheduler: Scheduler,
+        depth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    ) =
+        coroutineScope.run {
+            for (node in nodes) {
+                launch { node.removeIndirectUpstream(scheduler, depth, indirectSet) }
+            }
+            for (mover in muxMovers) {
+                launch { mover.removeIndirectPatchNode(scheduler, depth, indirectSet) }
+            }
+            for (output in outputs) {
+                launch { output.kill() }
+            }
+        }
+
+    fun removeDirectUpstream(coroutineScope: CoroutineScope, scheduler: Scheduler, depth: Int) =
+        coroutineScope.run {
+            for (node in nodes) {
+                launch { node.removeDirectUpstream(scheduler, depth) }
+            }
+            for (mover in muxMovers) {
+                launch { mover.removeDirectPatchNode(scheduler) }
+            }
+            for (output in outputs) {
+                launch { output.kill() }
+            }
+        }
+
+    fun clear() {
+        outputs.clear()
+        stateWriters.clear()
+        muxMovers.clear()
+        nodes.clear()
+    }
+}
+
+// TODO: remove this indirection
+internal sealed interface Schedulable {
+    data class S constructor(val state: TStateSource<*>) : Schedulable
+
+    data class M constructor(val muxMover: MuxDeferredNode<*, *>) : Schedulable
+
+    data class N constructor(val node: SchedulableNode) : Schedulable
+
+    data class O constructor(val output: Output<*>) : Schedulable
+}
+
+internal fun DownstreamSet.isEmpty() =
+    nodes.isEmpty() && outputs.isEmpty() && muxMovers.isEmpty() && stateWriters.isEmpty()
+
+@Suppress("NOTHING_TO_INLINE") internal inline fun DownstreamSet.isNotEmpty() = !isEmpty()
+
+internal fun CoroutineScope.scheduleAll(
+    downstreamSet: DownstreamSet,
+    evalScope: EvalScope,
+): Boolean {
+    downstreamSet.nodes.forEach { launch { it.schedule(evalScope) } }
+    downstreamSet.muxMovers.forEach { launch { it.scheduleMover(evalScope) } }
+    downstreamSet.outputs.forEach { launch { it.schedule(evalScope) } }
+    downstreamSet.stateWriters.forEach { evalScope.schedule(it) }
+    return downstreamSet.isNotEmpty()
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Init.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Init.kt
new file mode 100644
index 0000000..57db9a4
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Init.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.none
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+
+/** Performs actions once, when the reactive component is first connected to the network. */
+internal class Init<out A>(val name: String?, private val block: suspend InitScope.() -> A) {
+
+    /** Has the initialization logic been evaluated yet? */
+    private val initialized = AtomicBoolean()
+
+    /**
+     * Stores the result after initialization, as well as the id of the [Network] it's been
+     * initialized with.
+     */
+    private val cache = CompletableDeferred<Pair<Any, A>>()
+
+    suspend fun connect(evalScope: InitScope): A =
+        if (initialized.getAndSet(true)) {
+            // Read from cache
+            val (networkId, result) = cache.await()
+            check(networkId == evalScope.networkId) { "Network mismatch" }
+            result
+        } else {
+            // Write to cache
+            block(evalScope).also { cache.complete(evalScope.networkId to it) }
+        }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun getUnsafe(): Maybe<A> =
+        if (cache.isCompleted) {
+            just(cache.getCompleted().second)
+        } else {
+            none
+        }
+}
+
+internal fun <A> init(name: String?, block: suspend InitScope.() -> A) = Init(name, block)
+
+internal fun <A> constInit(name: String?, value: A) = init(name) { value }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Inputs.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Inputs.kt
new file mode 100644
index 0000000..8efaf79
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Inputs.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.just
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+internal class InputNode<A>(
+    private val activate: suspend EvalScope.() -> Unit = {},
+    private val deactivate: () -> Unit = {},
+) : PushNode<A>, Key<A> {
+
+    internal val downstreamSet = DownstreamSet()
+    private val mutex = Mutex()
+    private val activated = AtomicBoolean(false)
+
+    override val depthTracker: DepthTracker = DepthTracker()
+
+    override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean =
+        transactionStore.contains(this)
+
+    suspend fun visit(evalScope: EvalScope, value: A) {
+        evalScope.setResult(this, value)
+        coroutineScope {
+            if (!mutex.withLock { scheduleAll(downstreamSet, evalScope) }) {
+                evalScope.scheduleDeactivation(this@InputNode)
+            }
+        }
+    }
+
+    override suspend fun removeDownstream(downstream: Schedulable) {
+        mutex.withLock { downstreamSet.remove(downstream) }
+    }
+
+    override suspend fun deactivateIfNeeded() {
+        if (mutex.withLock { downstreamSet.isEmpty() && activated.getAndSet(false) }) {
+            deactivate()
+        }
+    }
+
+    override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) {
+        if (mutex.withLock { downstreamSet.isEmpty() }) {
+            evalScope.scheduleDeactivation(this)
+        }
+    }
+
+    override suspend fun addDownstream(downstream: Schedulable) {
+        mutex.withLock { downstreamSet.add(downstream) }
+    }
+
+    suspend fun addDownstreamAndActivateIfNeeded(downstream: Schedulable, evalScope: EvalScope) {
+        val needsActivation =
+            mutex.withLock {
+                val wasEmpty = downstreamSet.isEmpty()
+                downstreamSet.add(downstream)
+                wasEmpty && !activated.getAndSet(true)
+            }
+        if (needsActivation) {
+            activate(evalScope)
+        }
+    }
+
+    override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) {
+        val needsDeactivation =
+            mutex.withLock {
+                downstreamSet.remove(downstream)
+                downstreamSet.isEmpty() && activated.getAndSet(false)
+            }
+        if (needsDeactivation) {
+            deactivate()
+        }
+    }
+
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> =
+        evalScope.getCurrentValue(this)
+}
+
+internal fun <A> InputNode<A>.activated() = TFlowCheap { downstream ->
+    val input = this@activated
+    addDownstreamAndActivateIfNeeded(downstream, evalScope = this)
+    ActivationResult(connection = NodeConnection(input, input), needsEval = hasCurrentValue(input))
+}
+
+internal data object AlwaysNode : PushNode<Unit> {
+
+    override val depthTracker = DepthTracker()
+
+    override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean = true
+
+    override suspend fun removeDownstream(downstream: Schedulable) {}
+
+    override suspend fun deactivateIfNeeded() {}
+
+    override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) {}
+
+    override suspend fun addDownstream(downstream: Schedulable) {}
+
+    override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) {}
+
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<Unit> = just(Unit)
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/InternalScopes.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/InternalScopes.kt
new file mode 100644
index 0000000..af864e6
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/InternalScopes.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.FrpBuildScope
+import com.android.systemui.kairos.FrpStateScope
+import com.android.systemui.kairos.FrpTransactionScope
+import com.android.systemui.kairos.TFlow
+import com.android.systemui.kairos.internal.util.HeteroMap
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.util.Maybe
+
+internal interface InitScope {
+    val networkId: Any
+}
+
+internal interface EvalScope : NetworkScope, DeferScope {
+    val frpScope: FrpTransactionScope
+
+    suspend fun <R> runInTransactionScope(block: suspend FrpTransactionScope.() -> R): R
+}
+
+internal interface StateScope : EvalScope {
+    override val frpScope: FrpStateScope
+
+    suspend fun <R> runInStateScope(block: suspend FrpStateScope.() -> R): R
+
+    val endSignal: TFlow<Any>
+
+    fun childStateScope(newEnd: TFlow<Any>): StateScope
+}
+
+internal interface BuildScope : StateScope {
+    override val frpScope: FrpBuildScope
+
+    suspend fun <R> runInBuildScope(block: suspend FrpBuildScope.() -> R): R
+}
+
+internal interface NetworkScope : InitScope {
+
+    val epoch: Long
+    val network: Network
+
+    val compactor: Scheduler
+    val scheduler: Scheduler
+
+    val transactionStore: HeteroMap
+
+    fun scheduleOutput(output: Output<*>)
+
+    fun scheduleMuxMover(muxMover: MuxDeferredNode<*, *>)
+
+    fun schedule(state: TStateSource<*>)
+
+    suspend fun schedule(node: MuxNode<*, *, *>)
+
+    fun scheduleDeactivation(node: PushNode<*>)
+
+    fun scheduleDeactivation(output: Output<*>)
+}
+
+internal fun <A> NetworkScope.setResult(node: Key<A>, result: A) {
+    transactionStore[node] = result
+}
+
+internal fun <A> NetworkScope.getCurrentValue(key: Key<A>): Maybe<A> = transactionStore[key]
+
+internal fun NetworkScope.hasCurrentValue(key: Key<*>): Boolean = transactionStore.contains(key)
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Mux.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Mux.kt
new file mode 100644
index 0000000..f7ff15f
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Mux.kt
@@ -0,0 +1,326 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.android.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.ConcurrentNullableHashMap
+import com.android.systemui.kairos.internal.util.hashString
+import com.android.systemui.kairos.util.Just
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/** Base class for muxing nodes, which have a potentially dynamic collection of upstream nodes. */
+internal sealed class MuxNode<K : Any, V, Output>(val lifecycle: MuxLifecycle<Output>) :
+    PushNode<Output> {
+
+    inline val mutex
+        get() = lifecycle.mutex
+
+    // TODO: preserve insertion order?
+    val upstreamData = ConcurrentNullableHashMap<K, V>()
+    val switchedIn = ConcurrentHashMap<K, MuxBranchNode<K, V>>()
+    val downstreamSet: DownstreamSet = DownstreamSet()
+
+    // TODO: inline DepthTracker? would need to be added to PushNode signature
+    final override val depthTracker = DepthTracker()
+
+    final override suspend fun addDownstream(downstream: Schedulable) {
+        mutex.withLock { addDownstreamLocked(downstream) }
+    }
+
+    /**
+     * Adds a downstream schedulable to this mux node, such that when this mux node emits a value,
+     * it will be scheduled for evaluation within this same transaction.
+     *
+     * Must only be called when [mutex] is acquired.
+     */
+    fun addDownstreamLocked(downstream: Schedulable) {
+        downstreamSet.add(downstream)
+    }
+
+    final override suspend fun removeDownstream(downstream: Schedulable) {
+        // TODO: return boolean?
+        mutex.withLock { downstreamSet.remove(downstream) }
+    }
+
+    final override suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable) {
+        val deactivate =
+            mutex.withLock {
+                downstreamSet.remove(downstream)
+                downstreamSet.isEmpty()
+            }
+        if (deactivate) {
+            doDeactivate()
+        }
+    }
+
+    final override suspend fun deactivateIfNeeded() {
+        if (mutex.withLock { downstreamSet.isEmpty() }) {
+            doDeactivate()
+        }
+    }
+
+    /** visit this node from the scheduler (push eval) */
+    abstract suspend fun visit(evalScope: EvalScope)
+
+    /** perform deactivation logic, propagating to all upstream nodes. */
+    protected abstract suspend fun doDeactivate()
+
+    final override suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope) {
+        if (mutex.withLock { downstreamSet.isEmpty() }) {
+            evalScope.scheduleDeactivation(this)
+        }
+    }
+
+    suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) {
+        mutex.withLock {
+            if (depthTracker.addDirectUpstream(oldDepth, newDepth)) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun moveIndirectUpstreamToDirect(
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectRoots: Set<MuxDeferredNode<*, *>>,
+        newDepth: Int,
+    ) {
+        mutex.withLock {
+            if (
+                depthTracker.addDirectUpstream(oldDepth = null, newDepth) or
+                    depthTracker.removeIndirectUpstream(depth = oldIndirectDepth) or
+                    depthTracker.updateIndirectRoots(removals = oldIndirectRoots)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun adjustIndirectUpstream(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    ) {
+        mutex.withLock {
+            if (
+                depthTracker.addIndirectUpstream(oldDepth, newDepth) or
+                    depthTracker.updateIndirectRoots(
+                        additions,
+                        removals,
+                        butNot = this as? MuxDeferredNode<*, *>,
+                    )
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun moveDirectUpstreamToIndirect(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        mutex.withLock {
+            if (
+                depthTracker.addIndirectUpstream(oldDepth = null, newDepth) or
+                    depthTracker.removeDirectUpstream(oldDepth) or
+                    depthTracker.updateIndirectRoots(
+                        additions = newIndirectSet,
+                        butNot = this as? MuxDeferredNode<*, *>,
+                    )
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int, key: K) {
+        mutex.withLock {
+            switchedIn.remove(key)
+            if (depthTracker.removeDirectUpstream(depth)) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun removeIndirectUpstream(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+        key: K,
+    ) {
+        mutex.withLock {
+            switchedIn.remove(key)
+            if (
+                depthTracker.removeIndirectUpstream(oldDepth) or
+                    depthTracker.updateIndirectRoots(removals = indirectSet)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun visitCompact(scheduler: Scheduler) = coroutineScope {
+        if (depthTracker.isDirty()) {
+            depthTracker.applyChanges(coroutineScope = this, scheduler, downstreamSet, this@MuxNode)
+        }
+    }
+
+    abstract fun hasCurrentValueLocked(transactionStore: TransactionStore): Boolean
+}
+
+/** An input branch of a mux node, associated with a key. */
+internal class MuxBranchNode<K : Any, V>(private val muxNode: MuxNode<K, V, *>, val key: K) :
+    SchedulableNode {
+
+    val schedulable = Schedulable.N(this)
+
+    @Volatile lateinit var upstream: NodeConnection<V>
+
+    override suspend fun schedule(evalScope: EvalScope) {
+        val upstreamResult = upstream.getPushEvent(evalScope)
+        if (upstreamResult is Just) {
+            muxNode.upstreamData[key] = upstreamResult.value
+            evalScope.schedule(muxNode)
+        }
+    }
+
+    override suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) {
+        muxNode.adjustDirectUpstream(scheduler, oldDepth, newDepth)
+    }
+
+    override suspend fun moveIndirectUpstreamToDirect(
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectSet: Set<MuxDeferredNode<*, *>>,
+        newDirectDepth: Int,
+    ) {
+        muxNode.moveIndirectUpstreamToDirect(
+            scheduler,
+            oldIndirectDepth,
+            oldIndirectSet,
+            newDirectDepth,
+        )
+    }
+
+    override suspend fun adjustIndirectUpstream(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    ) {
+        muxNode.adjustIndirectUpstream(scheduler, oldDepth, newDepth, removals, additions)
+    }
+
+    override suspend fun moveDirectUpstreamToIndirect(
+        scheduler: Scheduler,
+        oldDirectDepth: Int,
+        newIndirectDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        muxNode.moveDirectUpstreamToIndirect(
+            scheduler,
+            oldDirectDepth,
+            newIndirectDepth,
+            newIndirectSet,
+        )
+    }
+
+    override suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) {
+        muxNode.removeDirectUpstream(scheduler, depth, key)
+    }
+
+    override suspend fun removeIndirectUpstream(
+        scheduler: Scheduler,
+        depth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        muxNode.removeIndirectUpstream(scheduler, depth, indirectSet, key)
+    }
+
+    override fun toString(): String = "MuxBranchNode(key=$key, mux=$muxNode)"
+}
+
+/** Tracks lifecycle of MuxNode in the network. Essentially a mutable ref for MuxLifecycleState. */
+internal class MuxLifecycle<A>(@Volatile var lifecycleState: MuxLifecycleState<A>) : TFlowImpl<A> {
+    val mutex = Mutex()
+
+    override fun toString(): String = "TFlowLifecycle[$hashString][$lifecycleState][$mutex]"
+
+    override suspend fun activate(
+        evalScope: EvalScope,
+        downstream: Schedulable,
+    ): ActivationResult<A>? =
+        mutex.withLock {
+            when (val state = lifecycleState) {
+                is MuxLifecycleState.Dead -> null
+                is MuxLifecycleState.Active -> {
+                    state.node.addDownstreamLocked(downstream)
+                    ActivationResult(
+                        connection = NodeConnection(state.node, state.node),
+                        needsEval = state.node.hasCurrentValueLocked(evalScope.transactionStore),
+                    )
+                }
+                is MuxLifecycleState.Inactive -> {
+                    state.spec
+                        .activate(evalScope, this@MuxLifecycle)
+                        .also { node ->
+                            lifecycleState =
+                                if (node == null) {
+                                    MuxLifecycleState.Dead
+                                } else {
+                                    MuxLifecycleState.Active(node)
+                                }
+                        }
+                        ?.let { node ->
+                            node.addDownstreamLocked(downstream)
+                            ActivationResult(
+                                connection = NodeConnection(node, node),
+                                needsEval = false,
+                            )
+                        }
+                }
+            }
+        }
+}
+
+internal sealed interface MuxLifecycleState<out A> {
+    class Inactive<A>(val spec: MuxActivator<A>) : MuxLifecycleState<A> {
+        override fun toString(): String = "Inactive"
+    }
+
+    class Active<A>(val node: MuxNode<*, *, A>) : MuxLifecycleState<A> {
+        override fun toString(): String = "Active(node=$node)"
+    }
+
+    data object Dead : MuxLifecycleState<Nothing>
+}
+
+internal interface MuxActivator<A> {
+    suspend fun activate(evalScope: EvalScope, lifecycle: MuxLifecycle<A>): MuxNode<*, *, A>?
+}
+
+internal inline fun <A> MuxLifecycle(onSubscribe: MuxActivator<A>): TFlowImpl<A> =
+    MuxLifecycle(MuxLifecycleState.Inactive(onSubscribe))
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/MuxDeferred.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/MuxDeferred.kt
new file mode 100644
index 0000000..08bee85
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/MuxDeferred.kt
@@ -0,0 +1,473 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.internal.util.associateByIndexTo
+import com.android.systemui.kairos.internal.util.hashString
+import com.android.systemui.kairos.internal.util.mapParallel
+import com.android.systemui.kairos.internal.util.mapValuesNotNullParallelTo
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Left
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.None
+import com.android.systemui.kairos.util.Right
+import com.android.systemui.kairos.util.These
+import com.android.systemui.kairos.util.flatMap
+import com.android.systemui.kairos.util.getMaybe
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.maybeThat
+import com.android.systemui.kairos.util.maybeThis
+import com.android.systemui.kairos.util.merge
+import com.android.systemui.kairos.util.orElseGet
+import com.android.systemui.kairos.util.partitionEithers
+import com.android.systemui.kairos.util.these
+import java.util.TreeMap
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.withLock
+
+internal class MuxDeferredNode<K : Any, V>(
+    lifecycle: MuxLifecycle<Map<K, V>>,
+    val spec: MuxActivator<Map<K, V>>,
+) : MuxNode<K, V, Map<K, V>>(lifecycle), Key<Map<K, V>> {
+
+    val schedulable = Schedulable.M(this)
+
+    @Volatile var patches: NodeConnection<Map<K, Maybe<TFlowImpl<V>>>>? = null
+    @Volatile var patchData: Map<K, Maybe<TFlowImpl<V>>>? = null
+
+    override fun hasCurrentValueLocked(transactionStore: TransactionStore): Boolean =
+        transactionStore.contains(this)
+
+    override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean =
+        mutex.withLock { hasCurrentValueLocked(transactionStore) }
+
+    override suspend fun visit(evalScope: EvalScope) {
+        val result = upstreamData.toMap()
+        upstreamData.clear()
+        val scheduleDownstream = result.isNotEmpty()
+        val compactDownstream = depthTracker.isDirty()
+        if (scheduleDownstream || compactDownstream) {
+            coroutineScope {
+                mutex.withLock {
+                    if (compactDownstream) {
+                        depthTracker.applyChanges(
+                            coroutineScope = this,
+                            evalScope.scheduler,
+                            downstreamSet,
+                            muxNode = this@MuxDeferredNode,
+                        )
+                    }
+                    if (scheduleDownstream) {
+                        evalScope.setResult(this@MuxDeferredNode, result)
+                        if (!scheduleAll(downstreamSet, evalScope)) {
+                            evalScope.scheduleDeactivation(this@MuxDeferredNode)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<Map<K, V>> =
+        evalScope.getCurrentValue(key = this)
+
+    private suspend fun compactIfNeeded(evalScope: EvalScope) {
+        depthTracker.propagateChanges(evalScope.compactor, this)
+    }
+
+    override suspend fun doDeactivate() {
+        // Update lifecycle
+        lifecycle.mutex.withLock {
+            if (lifecycle.lifecycleState !is MuxLifecycleState.Active) return@doDeactivate
+            lifecycle.lifecycleState = MuxLifecycleState.Inactive(spec)
+        }
+        // Process branch nodes
+        coroutineScope {
+            switchedIn.values.forEach { branchNode ->
+                branchNode.upstream.let {
+                    launch { it.removeDownstreamAndDeactivateIfNeeded(branchNode.schedulable) }
+                }
+            }
+        }
+        // Process patch node
+        patches?.removeDownstreamAndDeactivateIfNeeded(schedulable)
+    }
+
+    // MOVE phase
+    //  - concurrent moves may be occurring, but no more evals. all depth recalculations are
+    //    deferred to the end of this phase.
+    suspend fun performMove(evalScope: EvalScope) {
+        val patch = patchData ?: return
+        patchData = null
+
+        // TODO: this logic is very similar to what's in MuxPromptMoving, maybe turn into an inline
+        //  fun?
+
+        // We have a patch, process additions/updates and removals
+        val (adds, removes) =
+            patch
+                .asSequence()
+                .map { (k, newUpstream: Maybe<TFlowImpl<V>>) ->
+                    when (newUpstream) {
+                        is Just -> Left(k to newUpstream.value)
+                        None -> Right(k)
+                    }
+                }
+                .partitionEithers()
+
+        val severed = mutableListOf<NodeConnection<*>>()
+
+        coroutineScope {
+            // remove and sever
+            removes.forEach { k ->
+                switchedIn.remove(k)?.let { branchNode: MuxBranchNode<K, V> ->
+                    val conn = branchNode.upstream
+                    severed.add(conn)
+                    launch { conn.removeDownstream(downstream = branchNode.schedulable) }
+                    depthTracker.removeDirectUpstream(conn.depthTracker.snapshotDirectDepth)
+                }
+            }
+
+            // add or replace
+            adds
+                .mapParallel { (k, newUpstream: TFlowImpl<V>) ->
+                    val branchNode = MuxBranchNode(this@MuxDeferredNode, k)
+                    k to
+                        newUpstream.activate(evalScope, branchNode.schedulable)?.let { (conn, _) ->
+                            branchNode.apply { upstream = conn }
+                        }
+                }
+                .forEach { (k, newBranch: MuxBranchNode<K, V>?) ->
+                    // remove old and sever, if present
+                    switchedIn.remove(k)?.let { branchNode ->
+                        val conn = branchNode.upstream
+                        severed.add(conn)
+                        launch { conn.removeDownstream(downstream = branchNode.schedulable) }
+                        depthTracker.removeDirectUpstream(conn.depthTracker.snapshotDirectDepth)
+                    }
+
+                    // add new
+                    newBranch?.let {
+                        switchedIn[k] = newBranch
+                        val branchDepthTracker = newBranch.upstream.depthTracker
+                        if (branchDepthTracker.snapshotIsDirect) {
+                            depthTracker.addDirectUpstream(
+                                oldDepth = null,
+                                newDepth = branchDepthTracker.snapshotDirectDepth,
+                            )
+                        } else {
+                            depthTracker.addIndirectUpstream(
+                                oldDepth = null,
+                                newDepth = branchDepthTracker.snapshotIndirectDepth,
+                            )
+                            depthTracker.updateIndirectRoots(
+                                additions = branchDepthTracker.snapshotIndirectRoots,
+                                butNot = this@MuxDeferredNode,
+                            )
+                        }
+                    }
+                }
+        }
+
+        coroutineScope {
+            for (severedNode in severed) {
+                launch { severedNode.scheduleDeactivationIfNeeded(evalScope) }
+            }
+        }
+
+        compactIfNeeded(evalScope)
+    }
+
+    suspend fun removeDirectPatchNode(scheduler: Scheduler) {
+        mutex.withLock {
+            if (
+                depthTracker.removeIndirectUpstream(depth = 0) or
+                    depthTracker.setIsIndirectRoot(false)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+            patches = null
+        }
+    }
+
+    suspend fun removeIndirectPatchNode(
+        scheduler: Scheduler,
+        depth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        // indirectly connected patches forward the indirectSet
+        mutex.withLock {
+            if (
+                depthTracker.updateIndirectRoots(removals = indirectSet) or
+                    depthTracker.removeIndirectUpstream(depth)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+            patches = null
+        }
+    }
+
+    suspend fun moveIndirectPatchNodeToDirect(
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        // directly connected patches are stored as an indirect singleton set of the patchNode
+        mutex.withLock {
+            if (
+                depthTracker.updateIndirectRoots(removals = oldIndirectSet) or
+                    depthTracker.removeIndirectUpstream(oldIndirectDepth) or
+                    depthTracker.setIsIndirectRoot(true)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun moveDirectPatchNodeToIndirect(
+        scheduler: Scheduler,
+        newIndirectDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        // indirectly connected patches forward the indirectSet
+        mutex.withLock {
+            if (
+                depthTracker.setIsIndirectRoot(false) or
+                    depthTracker.updateIndirectRoots(additions = newIndirectSet, butNot = this) or
+                    depthTracker.addIndirectUpstream(oldDepth = null, newDepth = newIndirectDepth)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun adjustIndirectPatchNode(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    ) {
+        // indirectly connected patches forward the indirectSet
+        mutex.withLock {
+            if (
+                depthTracker.updateIndirectRoots(
+                    additions = additions,
+                    removals = removals,
+                    butNot = this,
+                ) or depthTracker.addIndirectUpstream(oldDepth = oldDepth, newDepth = newDepth)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun scheduleMover(evalScope: EvalScope) {
+        patchData =
+            checkNotNull(patches) { "mux mover scheduled with unset patches upstream node" }
+                .getPushEvent(evalScope)
+                .orElseGet { null }
+        evalScope.scheduleMuxMover(this)
+    }
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+internal inline fun <A> switchDeferredImplSingle(
+    crossinline getStorage: suspend EvalScope.() -> TFlowImpl<A>,
+    crossinline getPatches: suspend EvalScope.() -> TFlowImpl<TFlowImpl<A>>,
+): TFlowImpl<A> =
+    mapImpl({
+        switchDeferredImpl(
+            getStorage = { mapOf(Unit to getStorage()) },
+            getPatches = { mapImpl(getPatches) { newFlow -> mapOf(Unit to just(newFlow)) } },
+        )
+    }) { map ->
+        map.getValue(Unit)
+    }
+
+internal fun <K : Any, A> switchDeferredImpl(
+    getStorage: suspend EvalScope.() -> Map<K, TFlowImpl<A>>,
+    getPatches: suspend EvalScope.() -> TFlowImpl<Map<K, Maybe<TFlowImpl<A>>>>,
+): TFlowImpl<Map<K, A>> =
+    MuxLifecycle(
+        object : MuxActivator<Map<K, A>> {
+            override suspend fun activate(
+                evalScope: EvalScope,
+                lifecycle: MuxLifecycle<Map<K, A>>,
+            ): MuxNode<*, *, Map<K, A>>? {
+                val storage: Map<K, TFlowImpl<A>> = getStorage(evalScope)
+                // Initialize mux node and switched-in connections.
+                val muxNode =
+                    MuxDeferredNode(lifecycle, this).apply {
+                        storage.mapValuesNotNullParallelTo(switchedIn) { (key, flow) ->
+                            val branchNode = MuxBranchNode(this@apply, key)
+                            flow.activate(evalScope, branchNode.schedulable)?.let {
+                                (conn, needsEval) ->
+                                branchNode
+                                    .apply { upstream = conn }
+                                    .also {
+                                        if (needsEval) {
+                                            val result = conn.getPushEvent(evalScope)
+                                            if (result is Just) {
+                                                upstreamData[key] = result.value
+                                            }
+                                        }
+                                    }
+                            }
+                        }
+                    }
+                // Update depth based on all initial switched-in nodes.
+                muxNode.switchedIn.values.forEach { branch ->
+                    val conn = branch.upstream
+                    if (conn.depthTracker.snapshotIsDirect) {
+                        muxNode.depthTracker.addDirectUpstream(
+                            oldDepth = null,
+                            newDepth = conn.depthTracker.snapshotDirectDepth,
+                        )
+                    } else {
+                        muxNode.depthTracker.addIndirectUpstream(
+                            oldDepth = null,
+                            newDepth = conn.depthTracker.snapshotIndirectDepth,
+                        )
+                        muxNode.depthTracker.updateIndirectRoots(
+                            additions = conn.depthTracker.snapshotIndirectRoots,
+                            butNot = muxNode,
+                        )
+                    }
+                }
+                // We don't have our patches connection established yet, so for now pretend we have
+                // a direct connection to patches. We will update downstream nodes later if this
+                // turns out to be a lie.
+                muxNode.depthTracker.setIsIndirectRoot(true)
+                muxNode.depthTracker.reset()
+
+                // Setup patches connection; deferring allows for a recursive connection, where
+                // muxNode is downstream of itself via patches.
+                var isIndirect = true
+                evalScope.deferAction {
+                    val (patchesConn, needsEval) =
+                        getPatches(evalScope).activate(evalScope, downstream = muxNode.schedulable)
+                            ?: run {
+                                isIndirect = false
+                                // Turns out we can't connect to patches, so update our depth and
+                                // propagate
+                                muxNode.mutex.withLock {
+                                    if (muxNode.depthTracker.setIsIndirectRoot(false)) {
+                                        muxNode.depthTracker.schedule(evalScope.scheduler, muxNode)
+                                    }
+                                }
+                                return@deferAction
+                            }
+                    muxNode.patches = patchesConn
+
+                    if (!patchesConn.schedulerUpstream.depthTracker.snapshotIsDirect) {
+                        // Turns out patches is indirect, so we are not a root. Update depth and
+                        // propagate.
+                        muxNode.mutex.withLock {
+                            if (
+                                muxNode.depthTracker.setIsIndirectRoot(false) or
+                                    muxNode.depthTracker.addIndirectUpstream(
+                                        oldDepth = null,
+                                        newDepth = patchesConn.depthTracker.snapshotIndirectDepth,
+                                    ) or
+                                    muxNode.depthTracker.updateIndirectRoots(
+                                        additions = patchesConn.depthTracker.snapshotIndirectRoots
+                                    )
+                            ) {
+                                muxNode.depthTracker.schedule(evalScope.scheduler, muxNode)
+                            }
+                        }
+                    }
+                    // Schedule mover to process patch emission at the end of this transaction, if
+                    // needed.
+                    if (needsEval) {
+                        val result = patchesConn.getPushEvent(evalScope)
+                        if (result is Just) {
+                            muxNode.patchData = result.value
+                            evalScope.scheduleMuxMover(muxNode)
+                        }
+                    }
+                }
+
+                // Schedule for evaluation if any switched-in nodes have already emitted within
+                // this transaction.
+                if (muxNode.upstreamData.isNotEmpty()) {
+                    evalScope.schedule(muxNode)
+                }
+                return muxNode.takeUnless { muxNode.switchedIn.isEmpty() && !isIndirect }
+            }
+        }
+    )
+
+internal inline fun <A> mergeNodes(
+    crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>,
+    crossinline getOther: suspend EvalScope.() -> TFlowImpl<A>,
+    crossinline f: suspend EvalScope.(A, A) -> A,
+): TFlowImpl<A> {
+    val merged =
+        mapImpl({ mergeNodes(getPulse, getOther) }) { these ->
+            these.merge { thiz, that -> f(thiz, that) }
+        }
+    return merged.cached()
+}
+
+internal inline fun <A, B> mergeNodes(
+    crossinline getPulse: suspend EvalScope.() -> TFlowImpl<A>,
+    crossinline getOther: suspend EvalScope.() -> TFlowImpl<B>,
+): TFlowImpl<These<A, B>> {
+    val storage =
+        mapOf(
+            0 to mapImpl(getPulse) { These.thiz<A, B>(it) },
+            1 to mapImpl(getOther) { These.that(it) },
+        )
+    val switchNode = switchDeferredImpl(getStorage = { storage }, getPatches = { neverImpl })
+    val merged =
+        mapImpl({ switchNode }) { mergeResults ->
+            val first = mergeResults.getMaybe(0).flatMap { it.maybeThis() }
+            val second = mergeResults.getMaybe(1).flatMap { it.maybeThat() }
+            these(first, second).orElseGet { error("unexpected missing merge result") }
+        }
+    return merged.cached()
+}
+
+internal inline fun <A> mergeNodes(
+    crossinline getPulses: suspend EvalScope.() -> Iterable<TFlowImpl<A>>
+): TFlowImpl<List<A>> {
+    val switchNode =
+        switchDeferredImpl(
+            getStorage = { getPulses().associateByIndexTo(TreeMap()) },
+            getPatches = { neverImpl },
+        )
+    val merged = mapImpl({ switchNode }) { mergeResults -> mergeResults.values.toList() }
+    return merged.cached()
+}
+
+internal inline fun <A> mergeNodesLeft(
+    crossinline getPulses: suspend EvalScope.() -> Iterable<TFlowImpl<A>>
+): TFlowImpl<A> {
+    val switchNode =
+        switchDeferredImpl(
+            getStorage = { getPulses().associateByIndexTo(TreeMap()) },
+            getPatches = { neverImpl },
+        )
+    val merged =
+        mapImpl({ switchNode }) { mergeResults: Map<Int, A> -> mergeResults.values.first() }
+    return merged.cached()
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/MuxPrompt.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/MuxPrompt.kt
new file mode 100644
index 0000000..cdfafa9
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/MuxPrompt.kt
@@ -0,0 +1,472 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.internal.util.launchImmediate
+import com.android.systemui.kairos.internal.util.mapParallel
+import com.android.systemui.kairos.internal.util.mapValuesNotNullParallelTo
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Left
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.None
+import com.android.systemui.kairos.util.Right
+import com.android.systemui.kairos.util.filterJust
+import com.android.systemui.kairos.util.map
+import com.android.systemui.kairos.util.partitionEithers
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.withLock
+
+internal class MuxPromptMovingNode<K : Any, V>(
+    lifecycle: MuxLifecycle<Pair<Map<K, V>, Map<K, PullNode<V>>?>>,
+    private val spec: MuxActivator<Pair<Map<K, V>, Map<K, PullNode<V>>?>>,
+) :
+    MuxNode<K, V, Pair<Map<K, V>, Map<K, PullNode<V>>?>>(lifecycle),
+    Key<Pair<Map<K, V>, Map<K, PullNode<V>>?>> {
+
+    @Volatile var patchData: Map<K, Maybe<TFlowImpl<V>>>? = null
+    @Volatile var patches: MuxPromptPatchNode<K, V>? = null
+
+    @Volatile private var reEval: Pair<Map<K, V>, Map<K, PullNode<V>>?>? = null
+
+    override fun hasCurrentValueLocked(transactionStore: TransactionStore): Boolean =
+        transactionStore.contains(this)
+
+    override suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean =
+        mutex.withLock { hasCurrentValueLocked(transactionStore) }
+
+    override suspend fun visit(evalScope: EvalScope) {
+        val preSwitchResults: Map<K, V> = upstreamData.toMap()
+        upstreamData.clear()
+
+        val patch: Map<K, Maybe<TFlowImpl<V>>>? = patchData
+        patchData = null
+
+        val (reschedule, evalResult) =
+            reEval?.let { false to it }
+                ?: if (preSwitchResults.isNotEmpty() || patch?.isNotEmpty() == true) {
+                    doEval(preSwitchResults, patch, evalScope)
+                } else {
+                    false to null
+                }
+        reEval = null
+
+        if (reschedule || depthTracker.dirty_depthIncreased()) {
+            reEval = evalResult
+            // Can't schedule downstream yet, need to compact first
+            if (depthTracker.dirty_depthIncreased()) {
+                depthTracker.schedule(evalScope.compactor, node = this)
+            }
+            evalScope.schedule(this)
+        } else {
+            val compactDownstream = depthTracker.isDirty()
+            if (evalResult != null || compactDownstream) {
+                coroutineScope {
+                    mutex.withLock {
+                        if (compactDownstream) {
+                            adjustDownstreamDepths(evalScope, coroutineScope = this)
+                        }
+                        if (evalResult != null) {
+                            evalScope.setResult(this@MuxPromptMovingNode, evalResult)
+                            if (!scheduleAll(downstreamSet, evalScope)) {
+                                evalScope.scheduleDeactivation(this@MuxPromptMovingNode)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private suspend fun doEval(
+        preSwitchResults: Map<K, V>,
+        patch: Map<K, Maybe<TFlowImpl<V>>>?,
+        evalScope: EvalScope,
+    ): Pair<Boolean, Pair<Map<K, V>, Map<K, PullNode<V>>?>?> {
+        val newlySwitchedIn: Map<K, PullNode<V>>? =
+            patch?.let {
+                // We have a patch, process additions/updates and removals
+                val (adds, removes) =
+                    patch
+                        .asSequence()
+                        .map { (k, newUpstream: Maybe<TFlowImpl<V>>) ->
+                            when (newUpstream) {
+                                is Just -> Left(k to newUpstream.value)
+                                None -> Right(k)
+                            }
+                        }
+                        .partitionEithers()
+
+                val additionsAndUpdates = mutableMapOf<K, PullNode<V>>()
+                val severed = mutableListOf<NodeConnection<*>>()
+
+                coroutineScope {
+                    // remove and sever
+                    removes.forEach { k ->
+                        switchedIn.remove(k)?.let { branchNode: MuxBranchNode<K, V> ->
+                            val conn: NodeConnection<V> = branchNode.upstream
+                            severed.add(conn)
+                            launchImmediate {
+                                conn.removeDownstream(downstream = branchNode.schedulable)
+                            }
+                            depthTracker.removeDirectUpstream(conn.depthTracker.snapshotDirectDepth)
+                        }
+                    }
+
+                    // add or replace
+                    adds
+                        .mapParallel { (k, newUpstream: TFlowImpl<V>) ->
+                            val branchNode = MuxBranchNode(this@MuxPromptMovingNode, k)
+                            k to
+                                newUpstream.activate(evalScope, branchNode.schedulable)?.let {
+                                    (conn, _) ->
+                                    branchNode.apply { upstream = conn }
+                                }
+                        }
+                        .forEach { (k, newBranch: MuxBranchNode<K, V>?) ->
+                            // remove old and sever, if present
+                            switchedIn.remove(k)?.let { oldBranch: MuxBranchNode<K, V> ->
+                                val conn: NodeConnection<V> = oldBranch.upstream
+                                severed.add(conn)
+                                launchImmediate {
+                                    conn.removeDownstream(downstream = oldBranch.schedulable)
+                                }
+                                depthTracker.removeDirectUpstream(
+                                    conn.depthTracker.snapshotDirectDepth
+                                )
+                            }
+
+                            // add new
+                            newBranch?.let {
+                                switchedIn[k] = newBranch
+                                additionsAndUpdates[k] = newBranch.upstream.directUpstream
+                                val branchDepthTracker = newBranch.upstream.depthTracker
+                                if (branchDepthTracker.snapshotIsDirect) {
+                                    depthTracker.addDirectUpstream(
+                                        oldDepth = null,
+                                        newDepth = branchDepthTracker.snapshotDirectDepth,
+                                    )
+                                } else {
+                                    depthTracker.addIndirectUpstream(
+                                        oldDepth = null,
+                                        newDepth = branchDepthTracker.snapshotIndirectDepth,
+                                    )
+                                    depthTracker.updateIndirectRoots(
+                                        additions = branchDepthTracker.snapshotIndirectRoots,
+                                        butNot = null,
+                                    )
+                                }
+                            }
+                        }
+                }
+
+                coroutineScope {
+                    for (severedNode in severed) {
+                        launch { severedNode.scheduleDeactivationIfNeeded(evalScope) }
+                    }
+                }
+
+                additionsAndUpdates.takeIf { it.isNotEmpty() }
+            }
+
+        return if (preSwitchResults.isNotEmpty() || newlySwitchedIn != null) {
+            (newlySwitchedIn != null) to (preSwitchResults to newlySwitchedIn)
+        } else {
+            false to null
+        }
+    }
+
+    private suspend fun adjustDownstreamDepths(
+        evalScope: EvalScope,
+        coroutineScope: CoroutineScope,
+    ) {
+        if (depthTracker.dirty_depthIncreased()) {
+            // schedule downstream nodes on the compaction scheduler; this scheduler is drained at
+            // the end of this eval depth, so that all depth increases are applied before we advance
+            // the eval step
+            depthTracker.schedule(evalScope.compactor, node = this@MuxPromptMovingNode)
+        } else if (depthTracker.isDirty()) {
+            // schedule downstream nodes on the eval scheduler; this is more efficient and is only
+            // safe if the depth hasn't increased
+            depthTracker.applyChanges(
+                coroutineScope,
+                evalScope.scheduler,
+                downstreamSet,
+                muxNode = this@MuxPromptMovingNode,
+            )
+        }
+    }
+
+    override suspend fun getPushEvent(
+        evalScope: EvalScope
+    ): Maybe<Pair<Map<K, V>, Map<K, PullNode<V>>?>> = evalScope.getCurrentValue(key = this)
+
+    override suspend fun doDeactivate() {
+        // Update lifecycle
+        lifecycle.mutex.withLock {
+            if (lifecycle.lifecycleState !is MuxLifecycleState.Active) return@doDeactivate
+            lifecycle.lifecycleState = MuxLifecycleState.Inactive(spec)
+        }
+        // Process branch nodes
+        switchedIn.values.forEach { branchNode ->
+            branchNode.upstream.removeDownstreamAndDeactivateIfNeeded(
+                downstream = branchNode.schedulable
+            )
+        }
+        // Process patch node
+        patches?.let { patches ->
+            patches.upstream.removeDownstreamAndDeactivateIfNeeded(downstream = patches.schedulable)
+        }
+    }
+
+    suspend fun removeIndirectPatchNode(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        mutex.withLock {
+            patches = null
+            if (
+                depthTracker.removeIndirectUpstream(oldDepth) or
+                    depthTracker.updateIndirectRoots(removals = indirectSet)
+            ) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+
+    suspend fun removeDirectPatchNode(scheduler: Scheduler, depth: Int) {
+        mutex.withLock {
+            patches = null
+            if (depthTracker.removeDirectUpstream(depth)) {
+                depthTracker.schedule(scheduler, this)
+            }
+        }
+    }
+}
+
+internal class MuxPromptEvalNode<K, V>(
+    private val movingNode: PullNode<Pair<Map<K, V>, Map<K, PullNode<V>>?>>
+) : PullNode<Map<K, V>> {
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<Map<K, V>> =
+        movingNode.getPushEvent(evalScope).map { (preSwitchResults, newlySwitchedIn) ->
+            coroutineScope {
+                newlySwitchedIn
+                    ?.map { (k, v) -> async { v.getPushEvent(evalScope).map { k to it } } }
+                    ?.awaitAll()
+                    ?.asSequence()
+                    ?.filterJust()
+                    ?.toMap(preSwitchResults.toMutableMap()) ?: preSwitchResults
+            }
+        }
+}
+
+// TODO: inner class?
+internal class MuxPromptPatchNode<K : Any, V>(private val muxNode: MuxPromptMovingNode<K, V>) :
+    SchedulableNode {
+
+    val schedulable = Schedulable.N(this)
+
+    lateinit var upstream: NodeConnection<Map<K, Maybe<TFlowImpl<V>>>>
+
+    override suspend fun schedule(evalScope: EvalScope) {
+        val upstreamResult = upstream.getPushEvent(evalScope)
+        if (upstreamResult is Just) {
+            muxNode.patchData = upstreamResult.value
+            evalScope.schedule(muxNode)
+        }
+    }
+
+    override suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int) {
+        muxNode.adjustDirectUpstream(scheduler, oldDepth, newDepth)
+    }
+
+    override suspend fun moveIndirectUpstreamToDirect(
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectSet: Set<MuxDeferredNode<*, *>>,
+        newDirectDepth: Int,
+    ) {
+        muxNode.moveIndirectUpstreamToDirect(
+            scheduler,
+            oldIndirectDepth,
+            oldIndirectSet,
+            newDirectDepth,
+        )
+    }
+
+    override suspend fun adjustIndirectUpstream(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    ) {
+        muxNode.adjustIndirectUpstream(scheduler, oldDepth, newDepth, removals, additions)
+    }
+
+    override suspend fun moveDirectUpstreamToIndirect(
+        scheduler: Scheduler,
+        oldDirectDepth: Int,
+        newIndirectDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        muxNode.moveDirectUpstreamToIndirect(
+            scheduler,
+            oldDirectDepth,
+            newIndirectDepth,
+            newIndirectSet,
+        )
+    }
+
+    override suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int) {
+        muxNode.removeDirectPatchNode(scheduler, depth)
+    }
+
+    override suspend fun removeIndirectUpstream(
+        scheduler: Scheduler,
+        depth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    ) {
+        muxNode.removeIndirectPatchNode(scheduler, depth, indirectSet)
+    }
+}
+
+internal fun <K : Any, A> switchPromptImpl(
+    getStorage: suspend EvalScope.() -> Map<K, TFlowImpl<A>>,
+    getPatches: suspend EvalScope.() -> TFlowImpl<Map<K, Maybe<TFlowImpl<A>>>>,
+): TFlowImpl<Map<K, A>> {
+    val moving =
+        MuxLifecycle(
+            object : MuxActivator<Pair<Map<K, A>, Map<K, PullNode<A>>?>> {
+                override suspend fun activate(
+                    evalScope: EvalScope,
+                    lifecycle: MuxLifecycle<Pair<Map<K, A>, Map<K, PullNode<A>>?>>,
+                ): MuxNode<*, *, Pair<Map<K, A>, Map<K, PullNode<A>>?>>? {
+                    val storage: Map<K, TFlowImpl<A>> = getStorage(evalScope)
+                    // Initialize mux node and switched-in connections.
+                    val movingNode =
+                        MuxPromptMovingNode(lifecycle, this).apply {
+                            coroutineScope {
+                                launch {
+                                    storage.mapValuesNotNullParallelTo(switchedIn) { (key, flow) ->
+                                        val branchNode = MuxBranchNode(this@apply, key)
+                                        flow
+                                            .activate(
+                                                evalScope = evalScope,
+                                                downstream = branchNode.schedulable,
+                                            )
+                                            ?.let { (conn, needsEval) ->
+                                                branchNode
+                                                    .apply { upstream = conn }
+                                                    .also {
+                                                        if (needsEval) {
+                                                            val result =
+                                                                conn.getPushEvent(evalScope)
+                                                            if (result is Just) {
+                                                                upstreamData[key] = result.value
+                                                            }
+                                                        }
+                                                    }
+                                            }
+                                    }
+                                }
+                                // Setup patches connection
+                                val patchNode = MuxPromptPatchNode(this@apply)
+                                getPatches(evalScope)
+                                    .activate(
+                                        evalScope = evalScope,
+                                        downstream = patchNode.schedulable,
+                                    )
+                                    ?.let { (conn, needsEval) ->
+                                        patchNode.upstream = conn
+                                        patches = patchNode
+
+                                        if (needsEval) {
+                                            val result = conn.getPushEvent(evalScope)
+                                            if (result is Just) {
+                                                patchData = result.value
+                                            }
+                                        }
+                                    }
+                            }
+                        }
+                    // Update depth based on all initial switched-in nodes.
+                    movingNode.switchedIn.values.forEach { branch ->
+                        val conn = branch.upstream
+                        if (conn.depthTracker.snapshotIsDirect) {
+                            movingNode.depthTracker.addDirectUpstream(
+                                oldDepth = null,
+                                newDepth = conn.depthTracker.snapshotDirectDepth,
+                            )
+                        } else {
+                            movingNode.depthTracker.addIndirectUpstream(
+                                oldDepth = null,
+                                newDepth = conn.depthTracker.snapshotIndirectDepth,
+                            )
+                            movingNode.depthTracker.updateIndirectRoots(
+                                additions = conn.depthTracker.snapshotIndirectRoots,
+                                butNot = null,
+                            )
+                        }
+                    }
+                    // Update depth based on patches node.
+                    movingNode.patches?.upstream?.let { conn ->
+                        if (conn.depthTracker.snapshotIsDirect) {
+                            movingNode.depthTracker.addDirectUpstream(
+                                oldDepth = null,
+                                newDepth = conn.depthTracker.snapshotDirectDepth,
+                            )
+                        } else {
+                            movingNode.depthTracker.addIndirectUpstream(
+                                oldDepth = null,
+                                newDepth = conn.depthTracker.snapshotIndirectDepth,
+                            )
+                            movingNode.depthTracker.updateIndirectRoots(
+                                additions = conn.depthTracker.snapshotIndirectRoots,
+                                butNot = null,
+                            )
+                        }
+                    }
+                    movingNode.depthTracker.reset()
+
+                    // Schedule for evaluation if any switched-in nodes or the patches node have
+                    // already emitted within this transaction.
+                    if (movingNode.patchData != null || movingNode.upstreamData.isNotEmpty()) {
+                        evalScope.schedule(movingNode)
+                    }
+
+                    return movingNode.takeUnless { it.patches == null && it.switchedIn.isEmpty() }
+                }
+            }
+        )
+
+    val eval = TFlowCheap { downstream ->
+        moving.activate(evalScope = this, downstream)?.let { (connection, needsEval) ->
+            val evalNode = MuxPromptEvalNode(connection.directUpstream)
+            ActivationResult(
+                connection = NodeConnection(evalNode, connection.schedulerUpstream),
+                needsEval = needsEval,
+            )
+        }
+    }
+    return eval.cached()
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Network.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Network.kt
new file mode 100644
index 0000000..f0df89d
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Network.kt
@@ -0,0 +1,252 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.TState
+import com.android.systemui.kairos.internal.util.HeteroMap
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.none
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.ConcurrentLinkedDeque
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.atomic.AtomicLong
+import kotlin.coroutines.ContinuationInterceptor
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.yield
+
+private val nextNetworkId = AtomicLong()
+
+internal class Network(val coroutineScope: CoroutineScope) : NetworkScope {
+
+    override val networkId: Any = nextNetworkId.getAndIncrement()
+
+    @Volatile
+    override var epoch: Long = 0L
+        private set
+
+    override val network
+        get() = this
+
+    override val compactor = SchedulerImpl()
+    override val scheduler = SchedulerImpl()
+    override val transactionStore = HeteroMap()
+
+    private val stateWrites = ConcurrentLinkedQueue<TStateSource<*>>()
+    private val outputsByDispatcher =
+        ConcurrentHashMap<ContinuationInterceptor, ConcurrentLinkedQueue<Output<*>>>()
+    private val muxMovers = ConcurrentLinkedQueue<MuxDeferredNode<*, *>>()
+    private val deactivations = ConcurrentLinkedDeque<PushNode<*>>()
+    private val outputDeactivations = ConcurrentLinkedQueue<Output<*>>()
+    private val transactionMutex = Mutex()
+    private val inputScheduleChan = Channel<ScheduledAction<*>>()
+
+    override fun scheduleOutput(output: Output<*>) {
+        val continuationInterceptor =
+            output.context[ContinuationInterceptor] ?: Dispatchers.Unconfined
+        outputsByDispatcher
+            .computeIfAbsent(continuationInterceptor) { ConcurrentLinkedQueue() }
+            .add(output)
+    }
+
+    override fun scheduleMuxMover(muxMover: MuxDeferredNode<*, *>) {
+        muxMovers.add(muxMover)
+    }
+
+    override fun schedule(state: TStateSource<*>) {
+        stateWrites.add(state)
+    }
+
+    // TODO: weird that we have this *and* scheduler exposed
+    override suspend fun schedule(node: MuxNode<*, *, *>) {
+        scheduler.schedule(node.depthTracker.dirty_directDepth, node)
+    }
+
+    override fun scheduleDeactivation(node: PushNode<*>) {
+        deactivations.add(node)
+    }
+
+    override fun scheduleDeactivation(output: Output<*>) {
+        outputDeactivations.add(output)
+    }
+
+    /** Listens for external events and starts FRP transactions. Runs forever. */
+    suspend fun runInputScheduler() = coroutineScope {
+        launch { scheduler.activate() }
+        launch { compactor.activate() }
+        val actions = mutableListOf<ScheduledAction<*>>()
+        for (first in inputScheduleChan) {
+            // Drain and conflate all transaction requests into a single transaction
+            actions.add(first)
+            while (true) {
+                yield()
+                val func = inputScheduleChan.tryReceive().getOrNull() ?: break
+                actions.add(func)
+            }
+            transactionMutex.withLock {
+                // Run all actions
+                evalScope {
+                    for (action in actions) {
+                        launch { action.started(evalScope = this@evalScope) }
+                    }
+                }
+                // Step through the network
+                doTransaction()
+                // Signal completion
+                while (actions.isNotEmpty()) {
+                    actions.removeLast().completed()
+                }
+            }
+        }
+    }
+
+    /** Evaluates [block] inside of a new transaction when the network is ready. */
+    fun <R> transaction(block: suspend EvalScope.() -> R): Deferred<R> =
+        CompletableDeferred<R>(parent = coroutineScope.coroutineContext.job).also { onResult ->
+            val job =
+                coroutineScope.launch {
+                    inputScheduleChan.send(
+                        ScheduledAction(onStartTransaction = block, onResult = onResult)
+                    )
+                }
+            onResult.invokeOnCompletion { job.cancel() }
+        }
+
+    suspend fun <R> evalScope(block: suspend EvalScope.() -> R): R = deferScope {
+        block(EvalScopeImpl(this@Network, this))
+    }
+
+    /** Performs a transactional update of the FRP network. */
+    private suspend fun doTransaction() {
+        // Traverse network, then run outputs
+        do {
+            scheduler.drainEval(this)
+        } while (evalScope { evalOutputs(this) })
+        // Update states
+        evalScope { evalStateWriters(this) }
+        transactionStore.clear()
+        // Perform deferred switches
+        evalScope { evalMuxMovers(this) }
+        // Compact depths
+        scheduler.drainCompact()
+        compactor.drainCompact()
+        // Deactivate nodes with no downstream
+        evalDeactivations()
+        epoch++
+    }
+
+    /** Invokes all [Output]s that have received data within this transaction. */
+    private suspend fun evalOutputs(evalScope: EvalScope): Boolean {
+        // Outputs can enqueue other outputs, so we need two loops
+        if (outputsByDispatcher.isEmpty()) return false
+        while (outputsByDispatcher.isNotEmpty()) {
+            var launchedAny = false
+            coroutineScope {
+                for ((key, outputs) in outputsByDispatcher) {
+                    if (outputs.isNotEmpty()) {
+                        launchedAny = true
+                        launch(key) {
+                            while (outputs.isNotEmpty()) {
+                                val output = outputs.remove()
+                                launch { output.visit(evalScope) }
+                            }
+                        }
+                    }
+                }
+            }
+            if (!launchedAny) outputsByDispatcher.clear()
+        }
+        return true
+    }
+
+    private suspend fun evalMuxMovers(evalScope: EvalScope) {
+        while (muxMovers.isNotEmpty()) {
+            coroutineScope {
+                val toMove = muxMovers.remove()
+                launch { toMove.performMove(evalScope) }
+            }
+        }
+    }
+
+    /** Updates all [TState]es that have changed within this transaction. */
+    private suspend fun evalStateWriters(evalScope: EvalScope) {
+        coroutineScope {
+            while (stateWrites.isNotEmpty()) {
+                val latch = stateWrites.remove()
+                launch { latch.updateState(evalScope) }
+            }
+        }
+    }
+
+    private suspend fun evalDeactivations() {
+        coroutineScope {
+            launch {
+                while (deactivations.isNotEmpty()) {
+                    // traverse in reverse order
+                    //   - deactivations are added in depth-order during the node traversal phase
+                    //   - perform deactivations in reverse order, in case later ones propagate to
+                    //     earlier ones
+                    val toDeactivate = deactivations.removeLast()
+                    launch { toDeactivate.deactivateIfNeeded() }
+                }
+            }
+            while (outputDeactivations.isNotEmpty()) {
+                val toDeactivate = outputDeactivations.remove()
+                launch {
+                    toDeactivate.upstream?.removeDownstreamAndDeactivateIfNeeded(
+                        downstream = toDeactivate.schedulable
+                    )
+                }
+            }
+        }
+        check(deactivations.isEmpty()) { "unexpected lingering deactivations" }
+        check(outputDeactivations.isEmpty()) { "unexpected lingering output deactivations" }
+    }
+}
+
+internal class ScheduledAction<T>(
+    private val onResult: CompletableDeferred<T>? = null,
+    private val onStartTransaction: suspend EvalScope.() -> T,
+) {
+    private var result: Maybe<T> = none
+
+    suspend fun started(evalScope: EvalScope) {
+        result = just(onStartTransaction(evalScope))
+    }
+
+    fun completed() {
+        if (onResult != null) {
+            when (val result = result) {
+                is Just -> onResult.complete(result.value)
+                else -> {}
+            }
+        }
+        result = none
+    }
+}
+
+internal typealias TransactionStore = HeteroMap
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/NoScope.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/NoScope.kt
new file mode 100644
index 0000000..fbd9689
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/NoScope.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.FrpScope
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.coroutineContext
+import kotlin.coroutines.startCoroutine
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.completeWith
+import kotlinx.coroutines.job
+
+internal object NoScope {
+    private object FrpScopeImpl : FrpScope
+
+    suspend fun <R> runInFrpScope(block: suspend FrpScope.() -> R): R {
+        val complete = CompletableDeferred<R>(coroutineContext.job)
+        block.startCoroutine(
+            FrpScopeImpl,
+            object : Continuation<R> {
+                override val context: CoroutineContext
+                    get() = EmptyCoroutineContext
+
+                override fun resumeWith(result: Result<R>) {
+                    complete.completeWith(result)
+                }
+            },
+        )
+        return complete.await()
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/NodeTypes.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/NodeTypes.kt
new file mode 100644
index 0000000..0002407
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/NodeTypes.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.util.Maybe
+
+/*
+Dmux
+Muxes + Branch
+*/
+internal sealed interface SchedulableNode {
+    /** schedule this node w/ given NodeEvalScope */
+    suspend fun schedule(evalScope: EvalScope)
+
+    suspend fun adjustDirectUpstream(scheduler: Scheduler, oldDepth: Int, newDepth: Int)
+
+    suspend fun moveIndirectUpstreamToDirect(
+        scheduler: Scheduler,
+        oldIndirectDepth: Int,
+        oldIndirectSet: Set<MuxDeferredNode<*, *>>,
+        newDirectDepth: Int,
+    )
+
+    suspend fun adjustIndirectUpstream(
+        scheduler: Scheduler,
+        oldDepth: Int,
+        newDepth: Int,
+        removals: Set<MuxDeferredNode<*, *>>,
+        additions: Set<MuxDeferredNode<*, *>>,
+    )
+
+    suspend fun moveDirectUpstreamToIndirect(
+        scheduler: Scheduler,
+        oldDirectDepth: Int,
+        newIndirectDepth: Int,
+        newIndirectSet: Set<MuxDeferredNode<*, *>>,
+    )
+
+    suspend fun removeIndirectUpstream(
+        scheduler: Scheduler,
+        depth: Int,
+        indirectSet: Set<MuxDeferredNode<*, *>>,
+    )
+
+    suspend fun removeDirectUpstream(scheduler: Scheduler, depth: Int)
+}
+
+/*
+All but Dmux
+ */
+internal sealed interface PullNode<out A> {
+    /**
+     * query the result of this node within the current transaction. if the node is cached, this
+     * will read from the cache, otherwise it will perform a full evaluation, even if invoked
+     * multiple times within a transaction.
+     */
+    suspend fun getPushEvent(evalScope: EvalScope): Maybe<A>
+}
+
+/*
+Muxes + DmuxBranch
+ */
+internal sealed interface PushNode<A> : PullNode<A> {
+
+    suspend fun hasCurrentValue(transactionStore: TransactionStore): Boolean
+
+    val depthTracker: DepthTracker
+
+    suspend fun removeDownstream(downstream: Schedulable)
+
+    /** called during cleanup phase */
+    suspend fun deactivateIfNeeded()
+
+    /** called from mux nodes after severs */
+    suspend fun scheduleDeactivationIfNeeded(evalScope: EvalScope)
+
+    suspend fun addDownstream(downstream: Schedulable)
+
+    suspend fun removeDownstreamAndDeactivateIfNeeded(downstream: Schedulable)
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Output.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Output.kt
new file mode 100644
index 0000000..a3af2d3
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Output.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.util.Just
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+internal class Output<A>(
+    val context: CoroutineContext = EmptyCoroutineContext,
+    val onDeath: suspend () -> Unit = {},
+    val onEmit: suspend EvalScope.(A) -> Unit,
+) {
+
+    val schedulable = Schedulable.O(this)
+
+    @Volatile var upstream: NodeConnection<A>? = null
+    @Volatile var result: Any? = NoResult
+
+    private object NoResult
+
+    // invoked by network
+    suspend fun visit(evalScope: EvalScope) {
+        val upstreamResult = result
+        check(upstreamResult !== NoResult) { "output visited with null upstream result" }
+        result = null
+        @Suppress("UNCHECKED_CAST") evalScope.onEmit(upstreamResult as A)
+    }
+
+    suspend fun kill() {
+        onDeath()
+    }
+
+    suspend fun schedule(evalScope: EvalScope) {
+        val upstreamResult =
+            checkNotNull(upstream) { "output scheduled with null upstream" }.getPushEvent(evalScope)
+        if (upstreamResult is Just) {
+            result = upstreamResult.value
+            evalScope.scheduleOutput(this)
+        }
+    }
+}
+
+internal inline fun OneShot(crossinline onEmit: suspend EvalScope.() -> Unit): Output<Unit> =
+    Output<Unit>(onEmit = { onEmit() }).apply { result = Unit }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/PullNodes.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/PullNodes.kt
new file mode 100644
index 0000000..dac98e0
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/PullNodes.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.map
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+
+internal val neverImpl: TFlowImpl<Nothing> = TFlowCheap { null }
+
+internal class MapNode<A, B>(val upstream: PullNode<A>, val transform: suspend EvalScope.(A) -> B) :
+    PullNode<B> {
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<B> =
+        upstream.getPushEvent(evalScope).map { evalScope.transform(it) }
+}
+
+internal inline fun <A, B> mapImpl(
+    crossinline upstream: suspend EvalScope.() -> TFlowImpl<A>,
+    noinline transform: suspend EvalScope.(A) -> B,
+): TFlowImpl<B> = TFlowCheap { downstream ->
+    upstream().activate(evalScope = this, downstream)?.let { (connection, needsEval) ->
+        ActivationResult(
+            connection =
+                NodeConnection(
+                    directUpstream = MapNode(connection.directUpstream, transform),
+                    schedulerUpstream = connection.schedulerUpstream,
+                ),
+            needsEval = needsEval,
+        )
+    }
+}
+
+internal class CachedNode<A>(val key: Key<Deferred<Maybe<A>>>, val upstream: PullNode<A>) :
+    PullNode<A> {
+    override suspend fun getPushEvent(evalScope: EvalScope): Maybe<A> {
+        val deferred =
+            evalScope.transactionStore.getOrPut(key) {
+                evalScope.deferAsync(CoroutineStart.LAZY) { upstream.getPushEvent(evalScope) }
+            }
+        return deferred.await()
+    }
+}
+
+internal fun <A> TFlowImpl<A>.cached(): TFlowImpl<A> {
+    val key = object : Key<Deferred<Maybe<A>>> {}
+    return TFlowCheap {
+        activate(this, it)?.let { (connection, needsEval) ->
+            ActivationResult(
+                connection =
+                    NodeConnection(
+                        directUpstream = CachedNode(key, connection.directUpstream),
+                        schedulerUpstream = connection.schedulerUpstream,
+                    ),
+                needsEval = needsEval,
+            )
+        }
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Scheduler.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Scheduler.kt
new file mode 100644
index 0000000..872fb7a
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/Scheduler.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.kairos.internal
+
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.PriorityBlockingQueue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+internal interface Scheduler {
+    suspend fun schedule(depth: Int, node: MuxNode<*, *, *>)
+
+    suspend fun scheduleIndirect(indirectDepth: Int, node: MuxNode<*, *, *>)
+}
+
+internal class SchedulerImpl : Scheduler {
+    val enqueued = ConcurrentHashMap<MuxNode<*, *, *>, Any>()
+    val scheduledQ = PriorityBlockingQueue<Pair<Int, MuxNode<*, *, *>>>(16, compareBy { it.first })
+    val chan = Channel<Pair<Int, MuxNode<*, *, *>>>(Channel.UNLIMITED)
+
+    override suspend fun schedule(depth: Int, node: MuxNode<*, *, *>) {
+        if (enqueued.putIfAbsent(node, node) == null) {
+            chan.send(Pair(depth, node))
+        }
+    }
+
+    override suspend fun scheduleIndirect(indirectDepth: Int, node: MuxNode<*, *, *>) {
+        schedule(Int.MIN_VALUE + indirectDepth, node)
+    }
+
+    suspend fun activate() {
+        for (nodeSchedule in chan) {
+            scheduledQ.add(nodeSchedule)
+            drainChan()
+        }
+    }
+
+    internal suspend fun drainEval(network: Network) {
+        drain { runStep ->
+            runStep { muxNode -> network.evalScope { muxNode.visit(this) } }
+            // If any visited MuxPromptNodes had their depths increased, eagerly propagate those
+            // depth
+            // changes now before performing further network evaluation.
+            network.compactor.drainCompact()
+        }
+    }
+
+    internal suspend fun drainCompact() {
+        drain { runStep -> runStep { muxNode -> muxNode.visitCompact(scheduler = this) } }
+    }
+
+    private suspend inline fun drain(
+        crossinline onStep:
+            suspend (runStep: suspend (visit: suspend (MuxNode<*, *, *>) -> Unit) -> Unit) -> Unit
+    ): Unit = coroutineScope {
+        while (!chan.isEmpty || scheduledQ.isNotEmpty()) {
+            drainChan()
+            val maxDepth = scheduledQ.peek()?.first ?: error("Unexpected empty scheduler")
+            onStep { visit -> runStep(maxDepth, visit) }
+        }
+    }
+
+    private suspend fun drainChan() {
+        while (!chan.isEmpty) {
+            scheduledQ.add(chan.receive())
+        }
+    }
+
+    private suspend inline fun runStep(
+        maxDepth: Int,
+        crossinline visit: suspend (MuxNode<*, *, *>) -> Unit,
+    ) = coroutineScope {
+        while (scheduledQ.peek()?.first?.let { it <= maxDepth } == true) {
+            val (d, node) = scheduledQ.remove()
+            if (
+                node.depthTracker.dirty_hasDirectUpstream() &&
+                    d < node.depthTracker.dirty_directDepth
+            ) {
+                scheduledQ.add(node.depthTracker.dirty_directDepth to node)
+            } else {
+                launch {
+                    enqueued.remove(node)
+                    visit(node)
+                }
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/StateScopeImpl.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/StateScopeImpl.kt
new file mode 100644
index 0000000..baf4101
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/StateScopeImpl.kt
@@ -0,0 +1,257 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.FrpDeferredValue
+import com.android.systemui.kairos.FrpStateScope
+import com.android.systemui.kairos.FrpStateful
+import com.android.systemui.kairos.FrpTransactionScope
+import com.android.systemui.kairos.GroupedTFlow
+import com.android.systemui.kairos.TFlow
+import com.android.systemui.kairos.TFlowInit
+import com.android.systemui.kairos.TFlowLoop
+import com.android.systemui.kairos.TState
+import com.android.systemui.kairos.TStateInit
+import com.android.systemui.kairos.emptyTFlow
+import com.android.systemui.kairos.groupByKey
+import com.android.systemui.kairos.init
+import com.android.systemui.kairos.internal.util.mapValuesParallel
+import com.android.systemui.kairos.mapCheap
+import com.android.systemui.kairos.merge
+import com.android.systemui.kairos.switch
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.map
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.startCoroutine
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.completeWith
+import kotlinx.coroutines.job
+
+internal class StateScopeImpl(val evalScope: EvalScope, override val endSignal: TFlow<Any>) :
+    StateScope, EvalScope by evalScope {
+
+    private val endSignalOnce: TFlow<Any> = endSignal.nextOnlyInternal("StateScope.endSignal")
+
+    private fun <A> TFlow<A>.truncateToScope(operatorName: String): TFlow<A> =
+        if (endSignalOnce === emptyTFlow) {
+            this
+        } else {
+            endSignalOnce.mapCheap { emptyTFlow }.toTStateInternal(operatorName, this).switch()
+        }
+
+    private fun <A> TFlow<A>.nextOnlyInternal(operatorName: String): TFlow<A> =
+        if (this === emptyTFlow) {
+            this
+        } else {
+            TFlowLoop<A>().apply {
+                loopback =
+                    mapCheap { emptyTFlow }
+                        .toTStateInternal(operatorName, this@nextOnlyInternal)
+                        .switch()
+            }
+        }
+
+    private fun <A> TFlow<A>.toTStateInternal(operatorName: String, init: A): TState<A> =
+        toTStateInternalDeferred(operatorName, CompletableDeferred(init))
+
+    private fun <A> TFlow<A>.toTStateInternalDeferred(
+        operatorName: String,
+        init: Deferred<A>,
+    ): TState<A> {
+        val changes = this@toTStateInternalDeferred
+        val name = operatorName
+        val impl =
+            mkState(name, operatorName, evalScope, { changes.init.connect(evalScope = this) }, init)
+        return TStateInit(constInit(name, impl))
+    }
+
+    private fun <R> deferredInternal(block: suspend FrpStateScope.() -> R): FrpDeferredValue<R> =
+        FrpDeferredValue(deferAsync { runInStateScope(block) })
+
+    private fun <A> TFlow<A>.toTStateDeferredInternal(
+        initialValue: FrpDeferredValue<A>
+    ): TState<A> {
+        val operatorName = "toTStateDeferred"
+        // Ensure state is only collected until the end of this scope
+        return truncateToScope(operatorName)
+            .toTStateInternalDeferred(operatorName, initialValue.unwrapped)
+    }
+
+    private fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyInternal(
+        storage: TState<Map<K, TFlow<V>>>
+    ): TFlow<Map<K, V>> {
+        val name = "mergeIncrementally"
+        return TFlowInit(
+            constInit(
+                name,
+                switchDeferredImpl(
+                    getStorage = {
+                        storage.init
+                            .connect(this)
+                            .getCurrentWithEpoch(this)
+                            .first
+                            .mapValuesParallel { (_, flow) -> flow.init.connect(this) }
+                    },
+                    getPatches = {
+                        mapImpl({ init.connect(this) }) { patch ->
+                            patch.mapValuesParallel { (_, m) ->
+                                m.map { flow -> flow.init.connect(this) }
+                            }
+                        }
+                    },
+                ),
+            )
+        )
+    }
+
+    private fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptInternal(
+        storage: TState<Map<K, TFlow<V>>>
+    ): TFlow<Map<K, V>> {
+        val name = "mergeIncrementallyPrompt"
+        return TFlowInit(
+            constInit(
+                name,
+                switchPromptImpl(
+                    getStorage = {
+                        storage.init
+                            .connect(this)
+                            .getCurrentWithEpoch(this)
+                            .first
+                            .mapValuesParallel { (_, flow) -> flow.init.connect(this) }
+                    },
+                    getPatches = {
+                        mapImpl({ init.connect(this) }) { patch ->
+                            patch.mapValuesParallel { (_, m) ->
+                                m.map { flow -> flow.init.connect(this) }
+                            }
+                        }
+                    },
+                ),
+            )
+        )
+    }
+
+    private fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKeyInternal(
+        init: FrpDeferredValue<Map<K, FrpStateful<B>>>,
+        numKeys: Int?,
+    ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> {
+        val eventsByKey: GroupedTFlow<K, Maybe<FrpStateful<A>>> = groupByKey(numKeys)
+        val initOut: Deferred<Map<K, B>> = deferAsync {
+            init.unwrapped.await().mapValuesParallel { (k, stateful) ->
+                val newEnd = with(frpScope) { eventsByKey[k].skipNext() }
+                val newScope = childStateScope(newEnd)
+                newScope.runInStateScope(stateful)
+            }
+        }
+        val changesNode: TFlowImpl<Map<K, Maybe<A>>> =
+            mapImpl(
+                upstream = { this@applyLatestStatefulForKeyInternal.init.connect(evalScope = this) }
+            ) { upstreamMap ->
+                upstreamMap.mapValuesParallel { (k: K, ma: Maybe<FrpStateful<A>>) ->
+                    reenterStateScope(this@StateScopeImpl).run {
+                        ma.map { stateful ->
+                            val newEnd = with(frpScope) { eventsByKey[k].skipNext() }
+                            val newScope = childStateScope(newEnd)
+                            newScope.runInStateScope(stateful)
+                        }
+                    }
+                }
+            }
+        val operatorName = "applyLatestStatefulForKey"
+        val name = operatorName
+        val changes: TFlow<Map<K, Maybe<A>>> = TFlowInit(constInit(name, changesNode.cached()))
+        return changes to FrpDeferredValue(initOut)
+    }
+
+    private fun <A> TFlow<FrpStateful<A>>.observeStatefulsInternal(): TFlow<A> {
+        val operatorName = "observeStatefuls"
+        val name = operatorName
+        return TFlowInit(
+            constInit(
+                name,
+                mapImpl(
+                        upstream = { this@observeStatefulsInternal.init.connect(evalScope = this) }
+                    ) { stateful ->
+                        reenterStateScope(outerScope = this@StateScopeImpl)
+                            .runInStateScope(stateful)
+                    }
+                    .cached(),
+            )
+        )
+    }
+
+    override val frpScope: FrpStateScope = FrpStateScopeImpl()
+
+    private inner class FrpStateScopeImpl :
+        FrpStateScope, FrpTransactionScope by evalScope.frpScope {
+
+        override fun <A> deferredStateScope(
+            block: suspend FrpStateScope.() -> A
+        ): FrpDeferredValue<A> = deferredInternal(block)
+
+        override fun <A> TFlow<A>.holdDeferred(initialValue: FrpDeferredValue<A>): TState<A> =
+            toTStateDeferredInternal(initialValue)
+
+        override fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementally(
+            initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>>
+        ): TFlow<Map<K, V>> {
+            val storage: TState<Map<K, TFlow<V>>> = foldMapIncrementally(initialTFlows)
+            return mergeIncrementallyInternal(storage)
+        }
+
+        override fun <K : Any, V> TFlow<Map<K, Maybe<TFlow<V>>>>.mergeIncrementallyPromptly(
+            initialTFlows: FrpDeferredValue<Map<K, TFlow<V>>>
+        ): TFlow<Map<K, V>> {
+            val storage: TState<Map<K, TFlow<V>>> = foldMapIncrementally(initialTFlows)
+            return mergeIncrementallyPromptInternal(storage)
+        }
+
+        override fun <K, A, B> TFlow<Map<K, Maybe<FrpStateful<A>>>>.applyLatestStatefulForKey(
+            init: FrpDeferredValue<Map<K, FrpStateful<B>>>,
+            numKeys: Int?,
+        ): Pair<TFlow<Map<K, Maybe<A>>>, FrpDeferredValue<Map<K, B>>> =
+            applyLatestStatefulForKeyInternal(init, numKeys)
+
+        override fun <A> TFlow<FrpStateful<A>>.applyStatefuls(): TFlow<A> =
+            observeStatefulsInternal()
+    }
+
+    override suspend fun <R> runInStateScope(block: suspend FrpStateScope.() -> R): R {
+        val complete = CompletableDeferred<R>(parent = coroutineContext.job)
+        block.startCoroutine(
+            frpScope,
+            object : Continuation<R> {
+                override val context: CoroutineContext
+                    get() = EmptyCoroutineContext
+
+                override fun resumeWith(result: Result<R>) {
+                    complete.completeWith(result)
+                }
+            },
+        )
+        return complete.await()
+    }
+
+    override fun childStateScope(newEnd: TFlow<Any>) =
+        StateScopeImpl(evalScope, merge(newEnd, endSignal))
+}
+
+private fun EvalScope.reenterStateScope(outerScope: StateScopeImpl) =
+    StateScopeImpl(evalScope = this, endSignal = outerScope.endSignal)
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TFlowImpl.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TFlowImpl.kt
new file mode 100644
index 0000000..b904b48
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TFlowImpl.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.util.Maybe
+
+/* Initialized TFlow */
+internal fun interface TFlowImpl<out A> {
+    suspend fun activate(evalScope: EvalScope, downstream: Schedulable): ActivationResult<A>?
+}
+
+internal data class ActivationResult<out A>(
+    val connection: NodeConnection<A>,
+    val needsEval: Boolean,
+)
+
+internal inline fun <A> TFlowCheap(crossinline cheap: CheapNodeSubscribe<A>) =
+    TFlowImpl { scope, ds ->
+        scope.cheap(ds)
+    }
+
+internal typealias CheapNodeSubscribe<A> =
+    suspend EvalScope.(downstream: Schedulable) -> ActivationResult<A>?
+
+internal data class NodeConnection<out A>(
+    val directUpstream: PullNode<A>,
+    val schedulerUpstream: PushNode<*>,
+)
+
+internal suspend fun <A> NodeConnection<A>.hasCurrentValue(
+    transactionStore: TransactionStore
+): Boolean = schedulerUpstream.hasCurrentValue(transactionStore)
+
+internal suspend fun <A> NodeConnection<A>.removeDownstreamAndDeactivateIfNeeded(
+    downstream: Schedulable
+) = schedulerUpstream.removeDownstreamAndDeactivateIfNeeded(downstream)
+
+internal suspend fun <A> NodeConnection<A>.scheduleDeactivationIfNeeded(evalScope: EvalScope) =
+    schedulerUpstream.scheduleDeactivationIfNeeded(evalScope)
+
+internal suspend fun <A> NodeConnection<A>.removeDownstream(downstream: Schedulable) =
+    schedulerUpstream.removeDownstream(downstream)
+
+internal suspend fun <A> NodeConnection<A>.getPushEvent(evalScope: EvalScope): Maybe<A> =
+    directUpstream.getPushEvent(evalScope)
+
+internal val <A> NodeConnection<A>.depthTracker: DepthTracker
+    get() = schedulerUpstream.depthTracker
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TStateImpl.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TStateImpl.kt
new file mode 100644
index 0000000..5cec05c
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TStateImpl.kt
@@ -0,0 +1,377 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.internal.util.associateByIndex
+import com.android.systemui.kairos.internal.util.hashString
+import com.android.systemui.kairos.internal.util.mapValuesParallel
+import com.android.systemui.kairos.util.Just
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.none
+import java.util.concurrent.atomic.AtomicLong
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+
+internal sealed interface TStateImpl<out A> {
+    val name: String?
+    val operatorName: String
+    val changes: TFlowImpl<A>
+
+    suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<A, Long>
+}
+
+internal sealed class TStateDerived<A>(override val changes: TFlowImpl<A>) :
+    TStateImpl<A>, Key<Deferred<Pair<A, Long>>> {
+
+    @Volatile
+    var invalidatedEpoch = Long.MIN_VALUE
+        private set
+
+    @Volatile
+    protected var cache: Any? = EmptyCache
+        private set
+
+    override suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<A, Long> =
+        evalScope.transactionStore
+            .getOrPut(this) { evalScope.deferAsync(CoroutineStart.LAZY) { pull(evalScope) } }
+            .await()
+
+    suspend fun pull(evalScope: EvalScope): Pair<A, Long> {
+        @Suppress("UNCHECKED_CAST")
+        return recalc(evalScope)?.also { (a, epoch) -> setCache(a, epoch) }
+            ?: ((cache as A) to invalidatedEpoch)
+    }
+
+    fun setCache(value: A, epoch: Long) {
+        if (epoch > invalidatedEpoch) {
+            cache = value
+            invalidatedEpoch = epoch
+        }
+    }
+
+    fun getCachedUnsafe(): Maybe<A> {
+        @Suppress("UNCHECKED_CAST")
+        return if (cache == EmptyCache) none else just(cache as A)
+    }
+
+    protected abstract suspend fun recalc(evalScope: EvalScope): Pair<A, Long>?
+
+    private data object EmptyCache
+}
+
+internal class TStateSource<A>(
+    override val name: String?,
+    override val operatorName: String,
+    init: Deferred<A>,
+    override val changes: TFlowImpl<A>,
+) : TStateImpl<A> {
+    constructor(
+        name: String?,
+        operatorName: String,
+        init: A,
+        changes: TFlowImpl<A>,
+    ) : this(name, operatorName, CompletableDeferred(init), changes)
+
+    lateinit var upstreamConnection: NodeConnection<A>
+
+    // Note: Don't need to synchronize; we will never interleave reads and writes, since all writes
+    // are performed at the end of a network step, after any reads would have taken place.
+
+    @Volatile private var _current: Deferred<A> = init
+    @Volatile
+    var writeEpoch = 0L
+        private set
+
+    override suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<A, Long> =
+        _current.await() to writeEpoch
+
+    /** called by network after eval phase has completed */
+    suspend fun updateState(evalScope: EvalScope) {
+        // write the latch
+        val eventResult = upstreamConnection.getPushEvent(evalScope)
+        if (eventResult is Just) {
+            _current = CompletableDeferred(eventResult.value)
+            writeEpoch = evalScope.epoch
+        }
+    }
+
+    override fun toString(): String = "TStateImpl(changes=$changes, current=$_current)"
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun getStorageUnsafe(): Maybe<A> =
+        if (_current.isCompleted) just(_current.getCompleted()) else none
+}
+
+internal fun <A> constS(name: String?, operatorName: String, init: A): TStateImpl<A> =
+    TStateSource(name, operatorName, init, neverImpl)
+
+internal inline fun <A> mkState(
+    name: String?,
+    operatorName: String,
+    evalScope: EvalScope,
+    crossinline getChanges: suspend EvalScope.() -> TFlowImpl<A>,
+    init: Deferred<A>,
+): TStateImpl<A> {
+    lateinit var state: TStateSource<A>
+    val calm: TFlowImpl<A> =
+        filterNode(getChanges) { new -> new != state.getCurrentWithEpoch(evalScope = this).first }
+            .cached()
+    return TStateSource(name, operatorName, init, calm).also {
+        state = it
+        evalScope.scheduleOutput(
+            OneShot {
+                calm.activate(evalScope = this, downstream = Schedulable.S(state))?.let {
+                    (connection, needsEval) ->
+                    state.upstreamConnection = connection
+                    if (needsEval) {
+                        schedule(state)
+                    }
+                }
+            }
+        )
+    }
+}
+
+private inline fun <A> TFlowImpl<A>.calm(
+    crossinline getState: () -> TStateDerived<A>
+): TFlowImpl<A> =
+    filterNode({ this@calm }) { new ->
+            val state = getState()
+            val (current, _) = state.getCurrentWithEpoch(evalScope = this)
+            if (new != current) {
+                state.setCache(new, epoch)
+                true
+            } else {
+                false
+            }
+        }
+        .cached()
+
+internal fun <A, B> TStateImpl<A>.mapCheap(
+    name: String?,
+    operatorName: String,
+    transform: suspend EvalScope.(A) -> B,
+): TStateImpl<B> =
+    DerivedMapCheap(name, operatorName, this, mapImpl({ changes }) { transform(it) }, transform)
+
+internal class DerivedMapCheap<A, B>(
+    override val name: String?,
+    override val operatorName: String,
+    val upstream: TStateImpl<A>,
+    override val changes: TFlowImpl<B>,
+    private val transform: suspend EvalScope.(A) -> B,
+) : TStateImpl<B> {
+
+    override suspend fun getCurrentWithEpoch(evalScope: EvalScope): Pair<B, Long> {
+        val (a, epoch) = upstream.getCurrentWithEpoch(evalScope)
+        return evalScope.transform(a) to epoch
+    }
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+internal fun <A, B> TStateImpl<A>.map(
+    name: String?,
+    operatorName: String,
+    transform: suspend EvalScope.(A) -> B,
+): TStateImpl<B> {
+    lateinit var state: TStateDerived<B>
+    val mappedChanges = mapImpl({ changes }) { transform(it) }.cached().calm { state }
+    state = DerivedMap(name, operatorName, transform, this, mappedChanges)
+    return state
+}
+
+internal class DerivedMap<A, B>(
+    override val name: String?,
+    override val operatorName: String,
+    private val transform: suspend EvalScope.(A) -> B,
+    val upstream: TStateImpl<A>,
+    changes: TFlowImpl<B>,
+) : TStateDerived<B>(changes) {
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+
+    override suspend fun recalc(evalScope: EvalScope): Pair<B, Long>? {
+        val (a, epoch) = upstream.getCurrentWithEpoch(evalScope)
+        return if (epoch > invalidatedEpoch) {
+            evalScope.transform(a) to epoch
+        } else {
+            null
+        }
+    }
+}
+
+internal fun <A> TStateImpl<TStateImpl<A>>.flatten(name: String?, operator: String): TStateImpl<A> {
+    // emits the current value of the new inner state, when that state is emitted
+    val switchEvents = mapImpl({ changes }) { newInner -> newInner.getCurrentWithEpoch(this).first }
+    // emits the new value of the new inner state when that state is emitted, or
+    // falls back to the current value if a new state is *not* being emitted this
+    // transaction
+    val innerChanges =
+        mapImpl({ changes }) { newInner ->
+            mergeNodes({ switchEvents }, { newInner.changes }) { _, new -> new }
+        }
+    val switchedChanges: TFlowImpl<A> =
+        mapImpl({
+            switchPromptImpl(
+                getStorage = {
+                    mapOf(Unit to this@flatten.getCurrentWithEpoch(evalScope = this).first.changes)
+                },
+                getPatches = { mapImpl({ innerChanges }) { new -> mapOf(Unit to just(new)) } },
+            )
+        }) { map ->
+            map.getValue(Unit)
+        }
+    lateinit var state: DerivedFlatten<A>
+    state = DerivedFlatten(name, operator, this, switchedChanges.calm { state })
+    return state
+}
+
+internal class DerivedFlatten<A>(
+    override val name: String?,
+    override val operatorName: String,
+    val upstream: TStateImpl<TStateImpl<A>>,
+    changes: TFlowImpl<A>,
+) : TStateDerived<A>(changes) {
+    override suspend fun recalc(evalScope: EvalScope): Pair<A, Long> {
+        val (inner, epoch0) = upstream.getCurrentWithEpoch(evalScope)
+        val (a, epoch1) = inner.getCurrentWithEpoch(evalScope)
+        return a to maxOf(epoch0, epoch1)
+    }
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+@Suppress("NOTHING_TO_INLINE")
+internal inline fun <A, B> TStateImpl<A>.flatMap(
+    name: String?,
+    operatorName: String,
+    noinline transform: suspend EvalScope.(A) -> TStateImpl<B>,
+): TStateImpl<B> = map(null, operatorName, transform).flatten(name, operatorName)
+
+internal fun <A, B, Z> zipStates(
+    name: String?,
+    operatorName: String,
+    l1: TStateImpl<A>,
+    l2: TStateImpl<B>,
+    transform: suspend EvalScope.(A, B) -> Z,
+): TStateImpl<Z> =
+    zipStates(null, operatorName, mapOf(0 to l1, 1 to l2)).map(name, operatorName) {
+        val a = it.getValue(0)
+        val b = it.getValue(1)
+        @Suppress("UNCHECKED_CAST") transform(a as A, b as B)
+    }
+
+internal fun <A, B, C, Z> zipStates(
+    name: String?,
+    operatorName: String,
+    l1: TStateImpl<A>,
+    l2: TStateImpl<B>,
+    l3: TStateImpl<C>,
+    transform: suspend EvalScope.(A, B, C) -> Z,
+): TStateImpl<Z> =
+    zipStates(null, operatorName, mapOf(0 to l1, 1 to l2, 2 to l3)).map(name, operatorName) {
+        val a = it.getValue(0)
+        val b = it.getValue(1)
+        val c = it.getValue(2)
+        @Suppress("UNCHECKED_CAST") transform(a as A, b as B, c as C)
+    }
+
+internal fun <A, B, C, D, Z> zipStates(
+    name: String?,
+    operatorName: String,
+    l1: TStateImpl<A>,
+    l2: TStateImpl<B>,
+    l3: TStateImpl<C>,
+    l4: TStateImpl<D>,
+    transform: suspend EvalScope.(A, B, C, D) -> Z,
+): TStateImpl<Z> =
+    zipStates(null, operatorName, mapOf(0 to l1, 1 to l2, 2 to l3, 3 to l4)).map(
+        name,
+        operatorName,
+    ) {
+        val a = it.getValue(0)
+        val b = it.getValue(1)
+        val c = it.getValue(2)
+        val d = it.getValue(3)
+        @Suppress("UNCHECKED_CAST") transform(a as A, b as B, c as C, d as D)
+    }
+
+internal fun <K : Any, A> zipStates(
+    name: String?,
+    operatorName: String,
+    states: Map<K, TStateImpl<A>>,
+): TStateImpl<Map<K, A>> {
+    if (states.isEmpty()) return constS(name, operatorName, emptyMap())
+    val stateChanges: Map<K, TFlowImpl<A>> = states.mapValues { it.value.changes }
+    lateinit var state: DerivedZipped<K, A>
+    // No need for calm; invariant ensures that changes will only emit when there's a difference
+    val changes: TFlowImpl<Map<K, A>> =
+        mapImpl({
+            switchDeferredImpl(getStorage = { stateChanges }, getPatches = { neverImpl })
+        }) { patch ->
+            states
+                .mapValues { (k, v) ->
+                    if (k in patch) {
+                        patch.getValue(k)
+                    } else {
+                        v.getCurrentWithEpoch(evalScope = this).first
+                    }
+                }
+                .also { state.setCache(it, epoch) }
+        }
+    state = DerivedZipped(name, operatorName, states, changes)
+    return state
+}
+
+internal class DerivedZipped<K : Any, A>(
+    override val name: String?,
+    override val operatorName: String,
+    val upstream: Map<K, TStateImpl<A>>,
+    changes: TFlowImpl<Map<K, A>>,
+) : TStateDerived<Map<K, A>>(changes) {
+    override suspend fun recalc(evalScope: EvalScope): Pair<Map<K, A>, Long> {
+        val newEpoch = AtomicLong()
+        return upstream.mapValuesParallel {
+            val (a, epoch) = it.value.getCurrentWithEpoch(evalScope)
+            newEpoch.accumulateAndGet(epoch, ::maxOf)
+            a
+        } to newEpoch.get()
+    }
+
+    override fun toString(): String = "${this::class.simpleName}@$hashString"
+}
+
+@Suppress("NOTHING_TO_INLINE")
+internal inline fun <A> zipStates(
+    name: String?,
+    operatorName: String,
+    states: List<TStateImpl<A>>,
+): TStateImpl<List<A>> =
+    if (states.isEmpty()) {
+        constS(name, operatorName, emptyList())
+    } else {
+        zipStates(null, operatorName, states.asIterable().associateByIndex()).mapCheap(
+            name,
+            operatorName,
+        ) {
+            it.values.toList()
+        }
+    }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TransactionalImpl.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TransactionalImpl.kt
new file mode 100644
index 0000000..8647bdd
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/TransactionalImpl.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.systemui.kairos.internal
+
+import com.android.systemui.kairos.internal.util.Key
+import com.android.systemui.kairos.internal.util.hashString
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+
+internal sealed class TransactionalImpl<out A> {
+    data class Const<out A>(val value: Deferred<A>) : TransactionalImpl<A>()
+
+    class Impl<A>(val block: suspend EvalScope.() -> A) : TransactionalImpl<A>(), Key<Deferred<A>> {
+        override fun toString(): String = "${this::class.simpleName}@$hashString"
+    }
+}
+
+@Suppress("NOTHING_TO_INLINE")
+internal inline fun <A> transactionalImpl(
+    noinline block: suspend EvalScope.() -> A
+): TransactionalImpl<A> = TransactionalImpl.Impl(block)
+
+internal fun <A> TransactionalImpl<A>.sample(evalScope: EvalScope): Deferred<A> =
+    when (this) {
+        is TransactionalImpl.Const -> value
+        is TransactionalImpl.Impl ->
+            evalScope.transactionStore
+                .getOrPut(this) {
+                    evalScope.deferAsync(start = CoroutineStart.LAZY) { evalScope.block() }
+                }
+                .also { it.start() }
+    }
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/Bag.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/Bag.kt
new file mode 100644
index 0000000..4718519
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/Bag.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.systemui.kairos.internal.util
+
+internal class Bag<T> private constructor(private val intMap: MutableMap<T, Int>) :
+    Set<T> by intMap.keys {
+
+    constructor() : this(hashMapOf())
+
+    override fun toString(): String = intMap.toString()
+
+    fun add(element: T): Boolean {
+        val entry = intMap[element]
+        return if (entry != null) {
+            intMap[element] = entry + 1
+            false
+        } else {
+            intMap[element] = 1
+            true
+        }
+    }
+
+    fun remove(element: T): Boolean {
+        val entry = intMap[element]
+        return when {
+            entry == null -> {
+                false
+            }
+            entry <= 1 -> {
+                intMap.remove(element)
+                true
+            }
+            else -> {
+                intMap[element] = entry - 1
+                false
+            }
+        }
+    }
+
+    fun addAll(elements: Iterable<T>, butNot: T? = null): Set<T>? {
+        val newlyAdded = hashSetOf<T>()
+        for (value in elements) {
+            if (value != butNot) {
+                if (add(value)) {
+                    newlyAdded.add(value)
+                }
+            }
+        }
+        return newlyAdded.ifEmpty { null }
+    }
+
+    fun clear() {
+        intMap.clear()
+    }
+
+    fun removeAll(elements: Collection<T>): Set<T>? {
+        val result = hashSetOf<T>()
+        for (element in elements) {
+            if (remove(element)) {
+                result.add(element)
+            }
+        }
+        return result.ifEmpty { null }
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/ConcurrentNullableHashMap.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/ConcurrentNullableHashMap.kt
new file mode 100644
index 0000000..6c8ae7c
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/ConcurrentNullableHashMap.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.systemui.kairos.internal.util
+
+import java.util.concurrent.ConcurrentHashMap
+
+internal class ConcurrentNullableHashMap<K : Any, V>
+private constructor(private val inner: ConcurrentHashMap<K, Any>) {
+    constructor() : this(ConcurrentHashMap())
+
+    @Suppress("UNCHECKED_CAST")
+    operator fun get(key: K): V? = inner[key]?.takeIf { it !== NullValue } as V?
+
+    @Suppress("UNCHECKED_CAST")
+    fun put(key: K, value: V?): V? =
+        inner.put(key, value ?: NullValue)?.takeIf { it !== NullValue } as V?
+
+    operator fun set(key: K, value: V?) {
+        put(key, value)
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    fun toMap(): Map<K, V> = inner.mapValues { (_, v) -> v.takeIf { it !== NullValue } as V }
+
+    fun clear() {
+        inner.clear()
+    }
+
+    fun isNotEmpty(): Boolean = inner.isNotEmpty()
+}
+
+private object NullValue
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/HeteroMap.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/HeteroMap.kt
new file mode 100644
index 0000000..5cee2dd
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/HeteroMap.kt
@@ -0,0 +1,53 @@
+/*
+ * 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.systemui.kairos.internal.util
+
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.None
+import com.android.systemui.kairos.util.just
+import java.util.concurrent.ConcurrentHashMap
+
+internal interface Key<A>
+
+private object NULL
+
+internal class HeteroMap {
+
+    private val store = ConcurrentHashMap<Key<*>, Any>()
+
+    @Suppress("UNCHECKED_CAST")
+    operator fun <A> get(key: Key<A>): Maybe<A> =
+        store[key]?.let { just((if (it === NULL) null else it) as A) } ?: None
+
+    operator fun <A> set(key: Key<A>, value: A) {
+        store[key] = value ?: NULL
+    }
+
+    operator fun contains(key: Key<*>): Boolean = store.containsKey(key)
+
+    fun clear() {
+        store.clear()
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    fun <A> remove(key: Key<A>): Maybe<A> =
+        store.remove(key)?.let { just((if (it === NULL) null else it) as A) } ?: None
+
+    @Suppress("UNCHECKED_CAST")
+    fun <A> getOrPut(key: Key<A>, defaultValue: () -> A): A =
+        store.compute(key) { _, value -> value ?: defaultValue() ?: NULL } as A
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/MapUtils.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/MapUtils.kt
new file mode 100644
index 0000000..ebf9a66
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/MapUtils.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.systemui.kairos.internal.util
+
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.yield
+
+// TODO: It's possible that this is less efficient than having each coroutine directly insert into a
+//  ConcurrentHashMap, but then we would lose ordering
+internal suspend inline fun <K, A, B : Any, M : MutableMap<K, B>> Map<K, A>
+    .mapValuesNotNullParallelTo(
+    destination: M,
+    crossinline block: suspend (Map.Entry<K, A>) -> B?,
+): M =
+    destination.also {
+        coroutineScope {
+                mapValues {
+                    async {
+                        yield()
+                        block(it)
+                    }
+                }
+            }
+            .mapValuesNotNullTo(it) { (_, deferred) -> deferred.await() }
+    }
+
+internal inline fun <K, A, B : Any, M : MutableMap<K, B>> Map<K, A>.mapValuesNotNullTo(
+    destination: M,
+    block: (Map.Entry<K, A>) -> B?,
+): M =
+    destination.also {
+        for (entry in this@mapValuesNotNullTo) {
+            block(entry)?.let { destination.put(entry.key, it) }
+        }
+    }
+
+internal suspend fun <A, B> Iterable<A>.mapParallel(transform: suspend (A) -> B): List<B> =
+    coroutineScope {
+        map { async(start = CoroutineStart.LAZY) { transform(it) } }.awaitAll()
+    }
+
+internal suspend fun <K, A, B, M : MutableMap<K, B>> Map<K, A>.mapValuesParallelTo(
+    destination: M,
+    transform: suspend (Map.Entry<K, A>) -> B,
+): Map<K, B> = entries.mapParallel { it.key to transform(it) }.toMap(destination)
+
+internal suspend fun <K, A, B> Map<K, A>.mapValuesParallel(
+    transform: suspend (Map.Entry<K, A>) -> B
+): Map<K, B> = mapValuesParallelTo(mutableMapOf(), transform)
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/Util.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/Util.kt
new file mode 100644
index 0000000..6bb7f9f
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/internal/util/Util.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.systemui.kairos.internal.util
+
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.newCoroutineContext
+
+internal fun <A> CoroutineScope.asyncImmediate(
+    start: CoroutineStart = CoroutineStart.UNDISPATCHED,
+    context: CoroutineContext = EmptyCoroutineContext,
+    block: suspend CoroutineScope.() -> A,
+): Deferred<A> = async(start = start, context = Dispatchers.Unconfined + context, block = block)
+
+internal fun CoroutineScope.launchImmediate(
+    start: CoroutineStart = CoroutineStart.UNDISPATCHED,
+    context: CoroutineContext = EmptyCoroutineContext,
+    block: suspend CoroutineScope.() -> Unit,
+): Job = launch(start = start, context = Dispatchers.Unconfined + context, block = block)
+
+internal suspend fun awaitCancellationAndThen(block: suspend () -> Unit) {
+    try {
+        awaitCancellation()
+    } finally {
+        block()
+    }
+}
+
+internal fun CoroutineScope.launchOnCancel(
+    context: CoroutineContext = EmptyCoroutineContext,
+    block: () -> Unit,
+): Job =
+    launch(context = context, start = CoroutineStart.UNDISPATCHED) {
+        awaitCancellationAndThen(block)
+    }
+
+internal fun CoroutineScope.childScope(
+    context: CoroutineContext = EmptyCoroutineContext
+): CoroutineScope {
+    val newContext = newCoroutineContext(context)
+    val newJob = Job(parent = newContext[Job])
+    return CoroutineScope(newContext + newJob)
+}
+
+internal fun <A> Iterable<A>.associateByIndex(): Map<Int, A> = buildMap {
+    forEachIndexed { index, a -> put(index, a) }
+}
+
+internal fun <A, M : MutableMap<Int, A>> Iterable<A>.associateByIndexTo(destination: M): M =
+    destination.apply { forEachIndexed { index, a -> put(index, a) } }
+
+internal val Any.hashString: String
+    get() = Integer.toHexString(System.identityHashCode(this))
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/Either.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/Either.kt
new file mode 100644
index 0000000..ad9f7d7
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/Either.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.android.systemui.kairos.util
+
+/**
+ * Contains a value of two possibilities: `Left<A>` or `Right<B>`
+ *
+ * [Either] generalizes sealed classes the same way that [Pair] generalizes data classes; if a
+ * [Pair] is effectively an anonymous grouping of two instances, then an [Either] is an anonymous
+ * set of two options.
+ */
+sealed class Either<out A, out B>
+
+/** An [Either] that contains a [Left] value. */
+data class Left<out A>(val value: A) : Either<A, Nothing>()
+
+/** An [Either] that contains a [Right] value. */
+data class Right<out B>(val value: B) : Either<Nothing, B>()
+
+/**
+ * Returns an [Either] containing the result of applying [transform] to the [Left] value, or the
+ * [Right] value unchanged.
+ */
+inline fun <A, B, C> Either<A, C>.mapLeft(transform: (A) -> B): Either<B, C> =
+    when (this) {
+        is Left -> Left(transform(value))
+        is Right -> this
+    }
+
+/**
+ * Returns an [Either] containing the result of applying [transform] to the [Right] value, or the
+ * [Left] value unchanged.
+ */
+inline fun <A, B, C> Either<A, B>.mapRight(transform: (B) -> C): Either<A, C> =
+    when (this) {
+        is Left -> this
+        is Right -> Right(transform(value))
+    }
+
+/** Returns a [Maybe] containing the [Left] value held by this [Either], if present. */
+inline fun <A> Either<A, *>.leftMaybe(): Maybe<A> =
+    when (this) {
+        is Left -> just(value)
+        else -> None
+    }
+
+/** Returns the [Left] value held by this [Either], or `null` if this is a [Right] value. */
+inline fun <A> Either<A, *>.leftOrNull(): A? =
+    when (this) {
+        is Left -> value
+        else -> null
+    }
+
+/** Returns a [Maybe] containing the [Right] value held by this [Either], if present. */
+inline fun <B> Either<*, B>.rightMaybe(): Maybe<B> =
+    when (this) {
+        is Right -> just(value)
+        else -> None
+    }
+
+/** Returns the [Right] value held by this [Either], or `null` if this is a [Left] value. */
+inline fun <B> Either<*, B>.rightOrNull(): B? =
+    when (this) {
+        is Right -> value
+        else -> null
+    }
+
+/**
+ * Partitions this sequence of [Either] into two lists; [Pair.first] contains all [Left] values, and
+ * [Pair.second] contains all [Right] values.
+ */
+fun <A, B> Sequence<Either<A, B>>.partitionEithers(): Pair<List<A>, List<B>> {
+    val lefts = mutableListOf<A>()
+    val rights = mutableListOf<B>()
+    for (either in this) {
+        when (either) {
+            is Left -> lefts.add(either.value)
+            is Right -> rights.add(either.value)
+        }
+    }
+    return lefts to rights
+}
+
+/**
+ * Partitions this map of [Either] values into two maps; [Pair.first] contains all [Left] values,
+ * and [Pair.second] contains all [Right] values.
+ */
+fun <K, A, B> Map<K, Either<A, B>>.partitionEithers(): Pair<Map<K, A>, Map<K, B>> {
+    val lefts = mutableMapOf<K, A>()
+    val rights = mutableMapOf<K, B>()
+    for ((k, e) in this) {
+        when (e) {
+            is Left -> lefts[k] = e.value
+            is Right -> rights[k] = e.value
+        }
+    }
+    return lefts to rights
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/Maybe.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/Maybe.kt
new file mode 100644
index 0000000..c3cae38
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/Maybe.kt
@@ -0,0 +1,271 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE", "SuspendCoroutine")
+
+package com.android.systemui.kairos.util
+
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.RestrictsSuspension
+import kotlin.coroutines.resume
+import kotlin.coroutines.startCoroutine
+import kotlin.coroutines.suspendCoroutine
+
+/** Represents a value that may or may not be present. */
+sealed class Maybe<out A>
+
+/** A [Maybe] value that is present. */
+data class Just<out A> internal constructor(val value: A) : Maybe<A>()
+
+/** A [Maybe] value that is not present. */
+data object None : Maybe<Nothing>()
+
+/** Utilities to query [Maybe] instances from within a [maybe] block. */
+@RestrictsSuspension
+object MaybeScope {
+    suspend operator fun <A> Maybe<A>.not(): A = suspendCoroutine { k ->
+        if (this is Just) k.resume(value)
+    }
+
+    suspend inline fun guard(crossinline block: () -> Boolean): Unit = suspendCoroutine { k ->
+        if (block()) k.resume(Unit)
+    }
+}
+
+/**
+ * Returns a [Maybe] value produced by evaluating [block].
+ *
+ * [block] can use its [MaybeScope] receiver to query other [Maybe] values, automatically cancelling
+ * execution of [block] and producing [None] when attempting to query a [Maybe] that is not present.
+ *
+ * This can be used instead of Kotlin's built-in nullability (`?.` and `?:`) operators when dealing
+ * with complex combinations of nullables:
+ * ``` kotlin
+ * val aMaybe: Maybe<Any> = ...
+ * val bMaybe: Maybe<Any> = ...
+ * val result: String = maybe {
+ *   val a = !aMaybe
+ *   val b = !bMaybe
+ *   "Got: $a and $b"
+ * }
+ * ```
+ */
+fun <A> maybe(block: suspend MaybeScope.() -> A): Maybe<A> {
+    var maybeResult: Maybe<A> = None
+    val k =
+        object : Continuation<A> {
+            override val context: CoroutineContext = EmptyCoroutineContext
+
+            override fun resumeWith(result: Result<A>) {
+                maybeResult = result.getOrNull()?.let { just(it) } ?: None
+            }
+        }
+    block.startCoroutine(MaybeScope, k)
+    return maybeResult
+}
+
+/** Returns a [Just] containing this value, or [None] if `null`. */
+inline fun <A> (A?).toMaybe(): Maybe<A> = maybe(this)
+
+/** Returns a [Just] containing a non-null [value], or [None] if `null`. */
+inline fun <A> maybe(value: A?): Maybe<A> = value?.let(::just) ?: None
+
+/** Returns a [Just] containing [value]. */
+fun <A> just(value: A): Maybe<A> = Just(value)
+
+/** A [Maybe] that is not present. */
+val none: Maybe<Nothing> = None
+
+/** A [Maybe] that is not present. */
+inline fun <A> none(): Maybe<A> = None
+
+/** Returns the value present in this [Maybe], or `null` if not present. */
+inline fun <A> Maybe<A>.orNull(): A? = orElse(null)
+
+/**
+ * Returns a [Maybe] holding the result of applying [transform] to the value in the original
+ * [Maybe].
+ */
+inline fun <A, B> Maybe<A>.map(transform: (A) -> B): Maybe<B> =
+    when (this) {
+        is Just -> just(transform(value))
+        is None -> None
+    }
+
+/** Returns the result of applying [transform] to the value in the original [Maybe]. */
+inline fun <A, B> Maybe<A>.flatMap(transform: (A) -> Maybe<B>): Maybe<B> =
+    when (this) {
+        is Just -> transform(value)
+        is None -> None
+    }
+
+/** Returns the value present in this [Maybe], or the result of [defaultValue] if not present. */
+inline fun <A> Maybe<A>.orElseGet(defaultValue: () -> A): A =
+    when (this) {
+        is Just -> value
+        is None -> defaultValue()
+    }
+
+/**
+ * Returns the value present in this [Maybe], or invokes [error] with the message returned from
+ * [getMessage].
+ */
+inline fun <A> Maybe<A>.orError(getMessage: () -> Any): A = orElseGet { error(getMessage()) }
+
+/** Returns the value present in this [Maybe], or [defaultValue] if not present. */
+inline fun <A> Maybe<A>.orElse(defaultValue: A): A =
+    when (this) {
+        is Just -> value
+        is None -> defaultValue
+    }
+
+/**
+ * Returns a [Maybe] that contains the present in the original [Maybe], only if it satisfies
+ * [predicate].
+ */
+inline fun <A> Maybe<A>.filter(predicate: (A) -> Boolean): Maybe<A> =
+    when (this) {
+        is Just -> if (predicate(value)) this else None
+        else -> this
+    }
+
+/** Returns a [List] containing all values that are present in this [Iterable]. */
+fun <A> Iterable<Maybe<A>>.filterJust(): List<A> = asSequence().filterJust().toList()
+
+/** Returns a [List] containing all values that are present in this [Sequence]. */
+fun <A> Sequence<Maybe<A>>.filterJust(): Sequence<A> = filterIsInstance<Just<A>>().map { it.value }
+
+// Align
+
+/**
+ * Returns a [Maybe] containing the result of applying the values present in the original [Maybe]
+ * and other, applied to [transform] as a [These].
+ */
+inline fun <A, B, C> Maybe<A>.alignWith(other: Maybe<B>, transform: (These<A, B>) -> C): Maybe<C> =
+    when (this) {
+        is Just -> {
+            val a = value
+            when (other) {
+                is Just -> {
+                    val b = other.value
+                    just(transform(These.both(a, b)))
+                }
+                None -> just(transform(These.thiz(a)))
+            }
+        }
+        None ->
+            when (other) {
+                is Just -> {
+                    val b = other.value
+                    just(transform(These.that(b)))
+                }
+                None -> none
+            }
+    }
+
+// Alt
+
+/** Returns a [Maybe] containing the value present in the original [Maybe], or [other]. */
+infix fun <A> Maybe<A>.orElseMaybe(other: Maybe<A>): Maybe<A> = orElseGetMaybe { other }
+
+/**
+ * Returns a [Maybe] containing the value present in the original [Maybe], or the result of [other].
+ */
+inline fun <A> Maybe<A>.orElseGetMaybe(other: () -> Maybe<A>): Maybe<A> =
+    when (this) {
+        is Just -> this
+        else -> other()
+    }
+
+// Apply
+
+/**
+ * Returns a [Maybe] containing the value present in [argMaybe] applied to the function present in
+ * the original [Maybe].
+ */
+fun <A, B> Maybe<(A) -> B>.apply(argMaybe: Maybe<A>): Maybe<B> = flatMap { f ->
+    argMaybe.map { a -> f(a) }
+}
+
+/**
+ * Returns a [Maybe] containing the result of applying [transform] to the values present in the
+ * original [Maybe] and [other].
+ */
+inline fun <A, B, C> Maybe<A>.zipWith(other: Maybe<B>, transform: (A, B) -> C) = flatMap { a ->
+    other.map { b -> transform(a, b) }
+}
+
+// Bind
+
+/**
+ * Returns a [Maybe] containing the value present in the [Maybe] present in the original [Maybe].
+ */
+fun <A> Maybe<Maybe<A>>.flatten(): Maybe<A> = flatMap { it }
+
+// Semigroup
+
+/**
+ * Returns a [Maybe] containing the result of applying the values present in the original [Maybe]
+ * and other, applied to [transform].
+ */
+fun <A> Maybe<A>.mergeWith(other: Maybe<A>, transform: (A, A) -> A): Maybe<A> =
+    alignWith(other) { it.merge(transform) }
+
+/**
+ * Returns a list containing only the present results of applying [transform] to each element in the
+ * original iterable.
+ */
+fun <A, B> Iterable<A>.mapMaybe(transform: (A) -> Maybe<B>): List<B> =
+    asSequence().mapMaybe(transform).toList()
+
+/**
+ * Returns a sequence containing only the present results of applying [transform] to each element in
+ * the original sequence.
+ */
+fun <A, B> Sequence<A>.mapMaybe(transform: (A) -> Maybe<B>): Sequence<B> =
+    map(transform).filterIsInstance<Just<B>>().map { it.value }
+
+/**
+ * Returns a map with values of only the present results of applying [transform] to each entry in
+ * the original map.
+ */
+inline fun <K, A, B> Map<K, A>.mapMaybeValues(
+    crossinline p: (Map.Entry<K, A>) -> Maybe<B>
+): Map<K, B> = asSequence().mapMaybe { entry -> p(entry).map { entry.key to it } }.toMap()
+
+/** Returns a map with all non-present values filtered out. */
+fun <K, A> Map<K, Maybe<A>>.filterJustValues(): Map<K, A> =
+    asSequence().mapMaybe { (key, mValue) -> mValue.map { key to it } }.toMap()
+
+/**
+ * Returns a pair of [Maybes][Maybe] that contain the [Pair.first] and [Pair.second] values present
+ * in the original [Maybe].
+ */
+fun <A, B> Maybe<Pair<A, B>>.splitPair(): Pair<Maybe<A>, Maybe<B>> =
+    map { it.first } to map { it.second }
+
+/** Returns the value associated with [key] in this map as a [Maybe]. */
+fun <K, V> Map<K, V>.getMaybe(key: K): Maybe<V> {
+    val value = get(key)
+    if (value == null && !containsKey(key)) {
+        return none
+    } else {
+        @Suppress("UNCHECKED_CAST")
+        return just(value as V)
+    }
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/These.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/These.kt
new file mode 100644
index 0000000..aa95e0d
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/These.kt
@@ -0,0 +1,148 @@
+/*
+ * 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.systemui.kairos.util
+
+/** Contains at least one of two potential values. */
+sealed class These<A, B> {
+    /** Contains a single potential value. */
+    class This<A, B> internal constructor(val thiz: A) : These<A, B>()
+
+    /** Contains a single potential value. */
+    class That<A, B> internal constructor(val that: B) : These<A, B>()
+
+    /** Contains both potential values. */
+    class Both<A, B> internal constructor(val thiz: A, val that: B) : These<A, B>()
+
+    companion object {
+        /** Constructs a [These] containing only [thiz]. */
+        fun <A, B> thiz(thiz: A): These<A, B> = This(thiz)
+
+        /** Constructs a [These] containing only [that]. */
+        fun <A, B> that(that: B): These<A, B> = That(that)
+
+        /** Constructs a [These] containing both [thiz] and [that]. */
+        fun <A, B> both(thiz: A, that: B): These<A, B> = Both(thiz, that)
+    }
+}
+
+/**
+ * Returns a single value from this [These]; either the single value held within, or the result of
+ * applying [f] to both values.
+ */
+inline fun <A> These<A, A>.merge(f: (A, A) -> A): A =
+    when (this) {
+        is These.This -> thiz
+        is These.That -> that
+        is These.Both -> f(thiz, that)
+    }
+
+/** Returns the [These.This] [value][These.This.thiz] present in this [These] as a [Maybe]. */
+fun <A> These<A, *>.maybeThis(): Maybe<A> =
+    when (this) {
+        is These.Both -> just(thiz)
+        is These.That -> None
+        is These.This -> just(thiz)
+    }
+
+/**
+ * Returns the [These.This] [value][These.This.thiz] present in this [These], or `null` if not
+ * present.
+ */
+fun <A : Any> These<A, *>.thisOrNull(): A? =
+    when (this) {
+        is These.Both -> thiz
+        is These.That -> null
+        is These.This -> thiz
+    }
+
+/** Returns the [These.That] [value][These.That.that] present in this [These] as a [Maybe]. */
+fun <A> These<*, A>.maybeThat(): Maybe<A> =
+    when (this) {
+        is These.Both -> just(that)
+        is These.That -> just(that)
+        is These.This -> None
+    }
+
+/**
+ * Returns the [These.That] [value][These.That.that] present in this [These], or `null` if not
+ * present.
+ */
+fun <A : Any> These<*, A>.thatOrNull(): A? =
+    when (this) {
+        is These.Both -> that
+        is These.That -> that
+        is These.This -> null
+    }
+
+/** Returns [These.Both] values present in this [These] as a [Maybe]. */
+fun <A, B> These<A, B>.maybeBoth(): Maybe<Pair<A, B>> =
+    when (this) {
+        is These.Both -> just(thiz to that)
+        else -> None
+    }
+
+/** Returns a [These] containing [thiz] and/or [that] if they are present. */
+fun <A, B> these(thiz: Maybe<A>, that: Maybe<B>): Maybe<These<A, B>> =
+    when (thiz) {
+        is Just ->
+            just(
+                when (that) {
+                    is Just -> These.both(thiz.value, that.value)
+                    else -> These.thiz(thiz.value)
+                }
+            )
+        else ->
+            when (that) {
+                is Just -> just(These.that(that.value))
+                else -> none
+            }
+    }
+
+/**
+ * Returns a [These] containing [thiz] and/or [that] if they are non-null, or `null` if both are
+ * `null`.
+ */
+fun <A : Any, B : Any> theseNull(thiz: A?, that: B?): These<A, B>? =
+    thiz?.let { that?.let { These.both(thiz, that) } ?: These.thiz(thiz) }
+        ?: that?.let { These.that(that) }
+
+/**
+ * Returns two maps, with [Pair.first] containing all [These.This] values and [Pair.second]
+ * containing all [These.That] values.
+ *
+ * If the value is [These.Both], then the associated key with appear in both output maps, bound to
+ * [These.Both.thiz] and [These.Both.that] in each respective output.
+ */
+fun <K, A, B> Map<K, These<A, B>>.partitionThese(): Pair<Map<K, A>, Map<K, B>> {
+    val a = mutableMapOf<K, A>()
+    val b = mutableMapOf<K, B>()
+    for ((k, t) in this) {
+        when (t) {
+            is These.Both -> {
+                a[k] = t.thiz
+                b[k] = t.that
+            }
+            is These.That -> {
+                b[k] = t.that
+            }
+            is These.This -> {
+                a[k] = t.thiz
+            }
+        }
+    }
+    return a to b
+}
diff --git a/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/WithPrev.kt b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/WithPrev.kt
new file mode 100644
index 0000000..5cfaa3e
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/src/com/android/systemui/kairos/util/WithPrev.kt
@@ -0,0 +1,20 @@
+/*
+ * 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.systemui.kairos.util
+
+/** Holds a [newValue] emitted from a `TFlow`, along with the [previousValue] emitted value. */
+data class WithPrev<out S, out T : S>(val previousValue: S, val newValue: T)
diff --git a/packages/SystemUI/utils/kairos/test/com/android/systemui/kairos/KairosTests.kt b/packages/SystemUI/utils/kairos/test/com/android/systemui/kairos/KairosTests.kt
new file mode 100644
index 0000000..165230b
--- /dev/null
+++ b/packages/SystemUI/utils/kairos/test/com/android/systemui/kairos/KairosTests.kt
@@ -0,0 +1,1370 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalFrpApi::class)
+
+package com.android.systemui.kairos
+
+import com.android.systemui.kairos.util.Either
+import com.android.systemui.kairos.util.Left
+import com.android.systemui.kairos.util.Maybe
+import com.android.systemui.kairos.util.None
+import com.android.systemui.kairos.util.Right
+import com.android.systemui.kairos.util.just
+import com.android.systemui.kairos.util.map
+import com.android.systemui.kairos.util.maybe
+import com.android.systemui.kairos.util.none
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+import kotlin.time.measureTime
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.toCollection
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class KairosTests {
+
+    @Test
+    fun basic() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        var result: Int? = null
+        activateSpec(network) { emitter.observe { result = it } }
+        runCurrent()
+        emitter.emit(3)
+        runCurrent()
+        assertEquals(3, result)
+        runCurrent()
+    }
+
+    @Test
+    fun basicTFlow() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        println("starting network")
+        val result = activateSpecWithResult(network) { emitter.nextDeferred() }
+        runCurrent()
+        println("emitting")
+        emitter.emit(3)
+        runCurrent()
+        println("awaiting")
+        assertEquals(3, result.await())
+        runCurrent()
+    }
+
+    @Test
+    fun basicTState() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        val result = activateSpecWithResult(network) { emitter.hold(0).stateChanges.nextDeferred() }
+        runCurrent()
+
+        emitter.emit(3)
+        runCurrent()
+
+        assertEquals(3, result.await())
+    }
+
+    @Test
+    fun basicEvent() = runFrpTest { network ->
+        val emitter = MutableSharedFlow<Int>()
+        val result = activateSpecWithResult(network) { async { emitter.first() } }
+        runCurrent()
+        emitter.emit(1)
+        runCurrent()
+        assertTrue("Result eventual has not completed.", result.isCompleted)
+        assertEquals(1, result.await())
+    }
+
+    @Test
+    fun basicTransactional() = runFrpTest { network ->
+        var value: Int? = null
+        var bSource = 1
+        val emitter = network.mutableTFlow<Unit>()
+        // Sampling this transactional will increment the source count.
+        val transactional = transactionally { bSource++ }
+        measureTime {
+                activateSpecWithResult(network) {
+                        // Two different flows that sample the same transactional.
+                        (0 until 2).map {
+                            val sampled = emitter.sample(transactional) { _, v -> v }
+                            sampled.toSharedFlow()
+                        }
+                    }
+                    .forEach { backgroundScope.launch { it.collect { value = it } } }
+                runCurrent()
+            }
+            .also { println("setup: ${it.toString(DurationUnit.MILLISECONDS, 2)}") }
+
+        measureTime {
+                emitter.emit(Unit)
+                runCurrent()
+            }
+            .also { println("emit 1: ${it.toString(DurationUnit.MILLISECONDS, 2)}") }
+
+        // Even though the transactional would be sampled twice, the first result is cached.
+        assertEquals(2, bSource)
+        assertEquals(1, value)
+
+        measureTime {
+                bSource = 10
+                emitter.emit(Unit)
+                runCurrent()
+            }
+            .also { println("emit 2: ${it.toString(DurationUnit.MILLISECONDS, 2)}") }
+
+        assertEquals(11, bSource)
+        assertEquals(10, value)
+    }
+
+    @Test
+    fun diamondGraph() = runFrpTest { network ->
+        val flow = network.mutableTFlow<Int>()
+        val outFlow =
+            activateSpecWithResult(network) {
+                // map TFlow like we map Flow
+                val left = flow.map { "left" to it }.onEach { println("left: $it") }
+                val right = flow.map { "right" to it }.onEach { println("right: $it") }
+
+                // convert TFlows to TStates so that they can be combined
+                val combined =
+                    left.hold("left" to 0).combineWith(right.hold("right" to 0)) { l, r -> l to r }
+                combined.stateChanges // get TState changes
+                    .onEach { println("merged: $it") }
+                    .toSharedFlow() // convert back to Flow
+            }
+        runCurrent()
+
+        val results = mutableListOf<Pair<Pair<String, Int>, Pair<String, Int>>>()
+        backgroundScope.launch { outFlow.toCollection(results) }
+        runCurrent()
+
+        flow.emit(1)
+        runCurrent()
+
+        flow.emit(2)
+        runCurrent()
+
+        assertEquals(
+            listOf(("left" to 1) to ("right" to 1), ("left" to 2) to ("right" to 2)),
+            results,
+        )
+    }
+
+    @Test
+    fun staticNetwork() = runFrpTest { network ->
+        var finalSum: Int? = null
+
+        val intEmitter = network.mutableTFlow<Int>()
+        val sampleEmitter = network.mutableTFlow<Unit>()
+
+        activateSpecWithResult(network) {
+                val updates = intEmitter.map { a -> { b: Int -> a + b } }
+
+                val sumD =
+                    TStateLoop<Int>().apply {
+                        loopback =
+                            updates
+                                .sample(this) { f, sum -> f(sum) }
+                                .onEach { println("sum update: $it") }
+                                .hold(0)
+                    }
+                sampleEmitter
+                    .onEach { println("sampleEmitter emitted") }
+                    .sample(sumD) { _, sum -> sum }
+                    .onEach { println("sampled: $it") }
+                    .nextDeferred()
+            }
+            .let { launch { finalSum = it.await() } }
+
+        runCurrent()
+
+        (1..5).forEach { i ->
+            println("emitting: $i")
+            intEmitter.emit(i)
+            runCurrent()
+        }
+        runCurrent()
+
+        sampleEmitter.emit(Unit)
+        runCurrent()
+
+        assertEquals(15, finalSum)
+    }
+
+    @Test
+    fun recursiveDefinition() = runFrpTest { network ->
+        var wasSold = false
+        var currentAmt: Int? = null
+
+        val coin = network.mutableTFlow<Unit>()
+        val price = 50
+        val frpSpec = frpSpec {
+            val eSold = TFlowLoop<Unit>()
+
+            val eInsert =
+                coin.map {
+                    { runningTotal: Int ->
+                        println("TEST: $runningTotal - 10 = ${runningTotal - 10}")
+                        runningTotal - 10
+                    }
+                }
+
+            val eReset =
+                eSold.map {
+                    { _: Int ->
+                        println("TEST: Resetting")
+                        price
+                    }
+                }
+
+            val eUpdate = eInsert.mergeWith(eReset) { f, g -> { a -> g(f(a)) } }
+
+            val dTotal = TStateLoop<Int>()
+            dTotal.loopback = eUpdate.sample(dTotal) { f, total -> f(total) }.hold(price)
+
+            val eAmt = dTotal.stateChanges
+            val bAmt = transactionally { dTotal.sample() }
+            eSold.loopback =
+                coin
+                    .sample(bAmt) { coin, total -> coin to total }
+                    .mapMaybe { (_, total) -> maybe { guard { total <= 10 } } }
+
+            val amts = eAmt.filter { amt -> amt >= 0 }
+
+            amts.observe { currentAmt = it }
+            eSold.observe { wasSold = true }
+
+            eSold.nextDeferred()
+        }
+
+        activateSpec(network) { frpSpec.applySpec() }
+
+        runCurrent()
+
+        println()
+        println()
+        coin.emit(Unit)
+        runCurrent()
+
+        assertEquals(40, currentAmt)
+
+        println()
+        println()
+        coin.emit(Unit)
+        runCurrent()
+
+        assertEquals(30, currentAmt)
+
+        println()
+        println()
+        coin.emit(Unit)
+        runCurrent()
+
+        assertEquals(20, currentAmt)
+
+        println()
+        println()
+        coin.emit(Unit)
+        runCurrent()
+
+        assertEquals(10, currentAmt)
+        assertEquals(false, wasSold)
+
+        println()
+        println()
+        coin.emit(Unit)
+        runCurrent()
+
+        assertEquals(true, wasSold)
+        assertEquals(50, currentAmt)
+    }
+
+    @Test
+    fun promptCleanup() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        val stopper = network.mutableTFlow<Unit>()
+
+        var result: Int? = null
+
+        val flow = activateSpecWithResult(network) { emitter.takeUntil(stopper).toSharedFlow() }
+        backgroundScope.launch { flow.collect { result = it } }
+        runCurrent()
+
+        emitter.emit(2)
+        runCurrent()
+
+        assertEquals(2, result)
+
+        stopper.emit(Unit)
+        runCurrent()
+    }
+
+    @Test
+    fun switchTFlow() = runFrpTest { network ->
+        var currentSum: Int? = null
+
+        val switchHandler = network.mutableTFlow<Pair<TFlow<Int>, String>>()
+        val aHandler = network.mutableTFlow<Int>()
+        val stopHandler = network.mutableTFlow<Unit>()
+        val bHandler = network.mutableTFlow<Int>()
+
+        val sumFlow =
+            activateSpecWithResult(network) {
+                val switchE = TFlowLoop<TFlow<Int>>()
+                switchE.loopback =
+                    switchHandler.mapStateful { (intFlow, name) ->
+                        println("[onEach] Switching to: $name")
+                        val nextSwitch =
+                            switchE.skipNext().onEach { println("[onEach] switched-out") }
+                        val stopEvent =
+                            stopHandler
+                                .onEach { println("[onEach] stopped") }
+                                .mergeWith(nextSwitch) { _, b -> b }
+                        intFlow.takeUntil(stopEvent)
+                    }
+
+                val adderE: TFlow<(Int) -> Int> =
+                    switchE.hold(emptyTFlow).switch().map { a ->
+                        println("[onEach] new number $a")
+                        ({ sum: Int ->
+                            println("$a+$sum=${a + sum}")
+                            sum + a
+                        })
+                    }
+
+                val sumD = TStateLoop<Int>()
+                sumD.loopback =
+                    adderE
+                        .sample(sumD) { f, sum -> f(sum) }
+                        .onEach { println("[onEach] writing sum: $it") }
+                        .hold(0)
+                val sumE = sumD.stateChanges
+
+                sumE.toSharedFlow()
+            }
+
+        runCurrent()
+
+        backgroundScope.launch { sumFlow.collect { currentSum = it } }
+
+        runCurrent()
+
+        switchHandler.emit(aHandler to "A")
+        runCurrent()
+
+        aHandler.emit(1)
+        runCurrent()
+
+        assertEquals(1, currentSum)
+
+        aHandler.emit(2)
+        runCurrent()
+
+        assertEquals(3, currentSum)
+
+        aHandler.emit(3)
+        runCurrent()
+
+        assertEquals(6, currentSum)
+
+        aHandler.emit(4)
+        runCurrent()
+
+        assertEquals(10, currentSum)
+
+        aHandler.emit(5)
+        runCurrent()
+
+        assertEquals(15, currentSum)
+
+        switchHandler.emit(bHandler to "B")
+        runCurrent()
+
+        aHandler.emit(6)
+        runCurrent()
+
+        assertEquals(15, currentSum)
+
+        bHandler.emit(6)
+        runCurrent()
+
+        assertEquals(21, currentSum)
+
+        bHandler.emit(7)
+        runCurrent()
+
+        assertEquals(28, currentSum)
+
+        bHandler.emit(8)
+        runCurrent()
+
+        assertEquals(36, currentSum)
+
+        bHandler.emit(9)
+        runCurrent()
+
+        assertEquals(45, currentSum)
+
+        bHandler.emit(10)
+        runCurrent()
+
+        assertEquals(55, currentSum)
+
+        println()
+        println("Stopping: B")
+        stopHandler.emit(Unit) // bHandler.complete()
+        runCurrent()
+
+        bHandler.emit(20)
+        runCurrent()
+
+        assertEquals(55, currentSum)
+
+        println()
+        println("Switching to: A2")
+        switchHandler.emit(aHandler to "A2")
+        runCurrent()
+
+        println("aHandler.emit(11)")
+        aHandler.emit(11)
+        runCurrent()
+
+        assertEquals(66, currentSum)
+
+        aHandler.emit(12)
+        runCurrent()
+
+        assertEquals(78, currentSum)
+
+        aHandler.emit(13)
+        runCurrent()
+
+        assertEquals(91, currentSum)
+
+        aHandler.emit(14)
+        runCurrent()
+
+        assertEquals(105, currentSum)
+
+        aHandler.emit(15)
+        runCurrent()
+
+        assertEquals(120, currentSum)
+
+        stopHandler.emit(Unit)
+        runCurrent()
+
+        aHandler.emit(100)
+        runCurrent()
+
+        assertEquals(120, currentSum)
+    }
+
+    @Test
+    fun switchIndirect() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Unit>()
+        activateSpec(network) {
+            emptyTFlow.map { emitter.map { 1 } }.flatten().map { "$it" }.observe()
+        }
+        runCurrent()
+    }
+
+    @Test
+    fun switchInWithResult() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Unit>()
+        val out =
+            activateSpecWithResult(network) {
+                emitter.map { emitter.map { 1 } }.flatten().toSharedFlow()
+            }
+        val result = out.stateIn(backgroundScope, SharingStarted.Eagerly, null)
+        runCurrent()
+        emitter.emit(Unit)
+        runCurrent()
+        assertEquals(null, result.value)
+    }
+
+    @Test
+    fun switchInCompleted() = runFrpTest { network ->
+        val outputs = mutableListOf<Int>()
+
+        val switchAH = network.mutableTFlow<Unit>()
+        val intAH = network.mutableTFlow<Int>()
+        val stopEmitter = network.mutableTFlow<Unit>()
+
+        val top = frpSpec {
+            val intS = intAH.takeUntil(stopEmitter)
+            val switched = switchAH.map { intS }.flatten()
+            switched.toSharedFlow()
+        }
+        val flow = activateSpecWithResult(network) { top.applySpec() }
+        backgroundScope.launch { flow.collect { outputs.add(it) } }
+        runCurrent()
+
+        switchAH.emit(Unit)
+        runCurrent()
+
+        stopEmitter.emit(Unit)
+        runCurrent()
+
+        // assertEquals(0, intAH.subscriptionCount.value)
+        intAH.emit(10)
+        runCurrent()
+
+        assertEquals(true, outputs.isEmpty())
+
+        switchAH.emit(Unit)
+        runCurrent()
+
+        // assertEquals(0, intAH.subscriptionCount.value)
+        intAH.emit(10)
+        runCurrent()
+
+        assertEquals(true, outputs.isEmpty())
+    }
+
+    @Test
+    fun switchTFlow_outerCompletesFirst() = runFrpTest { network ->
+        var stepResult: Int? = null
+
+        val switchAH = network.mutableTFlow<Unit>()
+        val switchStopEmitter = network.mutableTFlow<Unit>()
+        val intStopEmitter = network.mutableTFlow<Unit>()
+        val intAH = network.mutableTFlow<Int>()
+        val flow =
+            activateSpecWithResult(network) {
+                val intS = intAH.takeUntil(intStopEmitter)
+                val switchS = switchAH.takeUntil(switchStopEmitter)
+
+                val switched = switchS.map { intS }.flatten()
+                switched.toSharedFlow()
+            }
+        backgroundScope.launch { flow.collect { stepResult = it } }
+        runCurrent()
+
+        // assertEquals(0, intAH.subscriptionCount.value)
+        intAH.emit(100)
+        runCurrent()
+
+        assertEquals(null, stepResult)
+
+        switchAH.emit(Unit)
+        runCurrent()
+
+        //            assertEquals(1, intAH.subscriptionCount.value)
+
+        intAH.emit(5)
+        runCurrent()
+
+        assertEquals(5, stepResult)
+
+        println("stop outer")
+        switchStopEmitter.emit(Unit) // switchAH.complete()
+        runCurrent()
+
+        // assertEquals(1, intAH.subscriptionCount.value)
+        // assertEquals(0, switchAH.subscriptionCount.value)
+
+        intAH.emit(10)
+        runCurrent()
+
+        assertEquals(10, stepResult)
+
+        println("stop inner")
+        intStopEmitter.emit(Unit) // intAH.complete()
+        runCurrent()
+
+        // assertEquals(just(10), network.await())
+    }
+
+    @Test
+    fun mapTFlow() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        var stepResult: Int? = null
+
+        val flow =
+            activateSpecWithResult(network) {
+                val mappedS = emitter.map { it * it }
+                mappedS.toSharedFlow()
+            }
+
+        backgroundScope.launch { flow.collect { stepResult = it } }
+        runCurrent()
+
+        emitter.emit(1)
+        runCurrent()
+
+        assertEquals(1, stepResult)
+
+        emitter.emit(2)
+        runCurrent()
+
+        assertEquals(4, stepResult)
+
+        emitter.emit(10)
+        runCurrent()
+
+        assertEquals(100, stepResult)
+    }
+
+    @Test
+    fun mapTransactional() = runFrpTest { network ->
+        var doubledResult: Int? = null
+        var pullValue = 0
+        val a = transactionally { pullValue }
+        val b = transactionally { a.sample() * 2 }
+        val emitter = network.mutableTFlow<Unit>()
+        val flow =
+            activateSpecWithResult(network) {
+                val sampleB = emitter.sample(b) { _, b -> b }
+                sampleB.toSharedFlow()
+            }
+
+        backgroundScope.launch { flow.collect { doubledResult = it } }
+
+        runCurrent()
+
+        emitter.emit(Unit)
+        runCurrent()
+
+        assertEquals(0, doubledResult)
+
+        pullValue = 5
+        emitter.emit(Unit)
+        runCurrent()
+
+        assertEquals(10, doubledResult)
+    }
+
+    @Test
+    fun mapTState() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        var stepResult: Int? = null
+        val flow =
+            activateSpecWithResult(network) {
+                val state = emitter.hold(0).map { it + 2 }
+                val stateCurrent = transactionally { state.sample() }
+                val stateChanges = state.stateChanges
+                val sampleState = emitter.sample(stateCurrent) { _, b -> b }
+                val merge = stateChanges.mergeWith(sampleState) { a, b -> a + b }
+                merge.toSharedFlow()
+            }
+        backgroundScope.launch { flow.collect { stepResult = it } }
+        runCurrent()
+
+        emitter.emit(1)
+        runCurrent()
+
+        assertEquals(5, stepResult)
+
+        emitter.emit(10)
+        runCurrent()
+
+        assertEquals(15, stepResult)
+    }
+
+    @Test
+    fun partitionEither() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Either<Int, Int>>()
+        val result =
+            activateSpecWithResult(network) {
+                val (l, r) = emitter.partitionEither()
+                val pDiamond =
+                    l.map { it * 2 }
+                        .mergeWith(r.map { it * -1 }) { _, _ -> error("unexpected coincidence") }
+                pDiamond.hold(null).toStateFlow()
+            }
+        runCurrent()
+
+        emitter.emit(Left(10))
+        runCurrent()
+
+        assertEquals(20, result.value)
+
+        emitter.emit(Right(30))
+        runCurrent()
+
+        assertEquals(-30, result.value)
+    }
+
+    @Test
+    fun accumTState() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Int>()
+        val sampler = network.mutableTFlow<Unit>()
+        var stepResult: Int? = null
+        val flow =
+            activateSpecWithResult(network) {
+                val sumState = emitter.map { a -> { b: Int -> a + b } }.fold(0) { f, a -> f(a) }
+
+                sumState.stateChanges
+                    .mergeWith(sampler.sample(sumState) { _, sum -> sum }) { _, _ ->
+                        error("Unexpected coincidence")
+                    }
+                    .toSharedFlow()
+            }
+
+        backgroundScope.launch { flow.collect { stepResult = it } }
+        runCurrent()
+
+        emitter.emit(5)
+        runCurrent()
+        assertEquals(5, stepResult)
+
+        emitter.emit(10)
+        runCurrent()
+        assertEquals(15, stepResult)
+
+        sampler.emit(Unit)
+        runCurrent()
+        assertEquals(15, stepResult)
+    }
+
+    @Test
+    fun mergeTFlows() = runFrpTest { network ->
+        val first = network.mutableTFlow<Int>()
+        val stopFirst = network.mutableTFlow<Unit>()
+        val second = network.mutableTFlow<Int>()
+        val stopSecond = network.mutableTFlow<Unit>()
+        var stepResult: Int? = null
+
+        val flow: SharedFlow<Int>
+        val setupDuration = measureTime {
+            flow =
+                activateSpecWithResult(network) {
+                    val firstS = first.takeUntil(stopFirst)
+                    val secondS = second.takeUntil(stopSecond)
+                    val mergedS =
+                        firstS.mergeWith(secondS) { _, _ -> error("Unexpected coincidence") }
+                    mergedS.toSharedFlow()
+                    // mergedS.last("onComplete")
+                }
+            backgroundScope.launch { flow.collect { stepResult = it } }
+            runCurrent()
+        }
+
+        //            assertEquals(1, first.subscriptionCount.value)
+        //            assertEquals(1, second.subscriptionCount.value)
+
+        val firstEmitDuration = measureTime {
+            first.emit(1)
+            runCurrent()
+        }
+
+        assertEquals(1, stepResult)
+
+        val secondEmitDuration = measureTime {
+            second.emit(2)
+            runCurrent()
+        }
+
+        assertEquals(2, stepResult)
+
+        val stopFirstDuration = measureTime {
+            stopFirst.emit(Unit)
+            runCurrent()
+        }
+
+        // assertEquals(0, first.subscriptionCount.value)
+        val testDeadEmitFirstDuration = measureTime {
+            first.emit(10)
+            runCurrent()
+        }
+
+        assertEquals(2, stepResult)
+
+        //            assertEquals(1, second.subscriptionCount.value)
+
+        val secondEmitDuration2 = measureTime {
+            second.emit(3)
+            runCurrent()
+        }
+
+        assertEquals(3, stepResult)
+
+        val stopSecondDuration = measureTime {
+            stopSecond.emit(Unit)
+            runCurrent()
+        }
+
+        // assertEquals(0, second.subscriptionCount.value)
+        val testDeadEmitSecondDuration = measureTime {
+            second.emit(10)
+            runCurrent()
+        }
+
+        assertEquals(3, stepResult)
+
+        println(
+            """
+                setupDuration: ${setupDuration.toString(DurationUnit.MILLISECONDS, 2)}
+                firstEmitDuration: ${firstEmitDuration.toString(DurationUnit.MILLISECONDS, 2)}
+                secondEmitDuration: ${secondEmitDuration.toString(DurationUnit.MILLISECONDS, 2)}
+                stopFirstDuration: ${stopFirstDuration.toString(DurationUnit.MILLISECONDS, 2)}
+                testDeadEmitFirstDuration: ${
+                    testDeadEmitFirstDuration.toString(
+                        DurationUnit.MILLISECONDS,
+                        2,
+                    )
+                }
+                secondEmitDuration2: ${secondEmitDuration2.toString(DurationUnit.MILLISECONDS, 2)}
+                stopSecondDuration: ${stopSecondDuration.toString(DurationUnit.MILLISECONDS, 2)}
+                testDeadEmitSecondDuration: ${
+                    testDeadEmitSecondDuration.toString(
+                        DurationUnit.MILLISECONDS,
+                        2,
+                    )
+                }
+            """
+                .trimIndent()
+        )
+    }
+
+    @Test
+    fun sampleCancel() = runFrpTest { network ->
+        val updater = network.mutableTFlow<Int>()
+        val stopUpdater = network.mutableTFlow<Unit>()
+        val sampler = network.mutableTFlow<Unit>()
+        val stopSampler = network.mutableTFlow<Unit>()
+        var stepResult: Int? = null
+        val flow =
+            activateSpecWithResult(network) {
+                val stopSamplerFirst = stopSampler
+                val samplerS = sampler.takeUntil(stopSamplerFirst)
+                val stopUpdaterFirst = stopUpdater
+                val updaterS = updater.takeUntil(stopUpdaterFirst)
+                val sampledS = samplerS.sample(updaterS.hold(0)) { _, b -> b }
+                sampledS.toSharedFlow()
+            }
+
+        backgroundScope.launch { flow.collect { stepResult = it } }
+        runCurrent()
+
+        updater.emit(1)
+        runCurrent()
+
+        sampler.emit(Unit)
+        runCurrent()
+
+        assertEquals(1, stepResult)
+
+        stopSampler.emit(Unit)
+        runCurrent()
+
+        // assertEquals(0, updater.subscriptionCount.value)
+        // assertEquals(0, sampler.subscriptionCount.value)
+        updater.emit(10)
+        runCurrent()
+
+        sampler.emit(Unit)
+        runCurrent()
+
+        assertEquals(1, stepResult)
+    }
+
+    @Test
+    fun combineStates_differentUpstreams() = runFrpTest { network ->
+        val a = network.mutableTFlow<Int>()
+        val b = network.mutableTFlow<Int>()
+        var observed: Pair<Int, Int>? = null
+        val tState =
+            activateSpecWithResult(network) {
+                val state = combine(a.hold(0), b.hold(0)) { a, b -> Pair(a, b) }
+                state.stateChanges.observe { observed = it }
+                state
+            }
+        assertEquals(0 to 0, network.transact { tState.sample() })
+        assertEquals(null, observed)
+        a.emit(5)
+        assertEquals(5 to 0, observed)
+        assertEquals(5 to 0, network.transact { tState.sample() })
+        b.emit(3)
+        assertEquals(5 to 3, observed)
+        assertEquals(5 to 3, network.transact { tState.sample() })
+    }
+
+    @Test
+    fun sampleCombinedStates() = runFrpTest { network ->
+        val updater = network.mutableTFlow<Int>()
+        val emitter = network.mutableTFlow<Unit>()
+
+        val result =
+            activateSpecWithResult(network) {
+                val bA = updater.map { it * 2 }.hold(0)
+                val bB = updater.hold(0)
+                val combineD: TState<Pair<Int, Int>> = bA.combineWith(bB) { a, b -> a to b }
+                val sampleS = emitter.sample(combineD) { _, b -> b }
+                sampleS.nextDeferred()
+            }
+        println("launching")
+        runCurrent()
+
+        println("emitting update")
+        updater.emit(10)
+        runCurrent()
+
+        println("emitting sampler")
+        emitter.emit(Unit)
+        runCurrent()
+
+        println("asserting")
+        assertEquals(20 to 10, result.await())
+    }
+
+    @Test
+    fun switchMapPromptly() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Unit>()
+        val result =
+            activateSpecWithResult(network) {
+                emitter
+                    .map { emitter.map { 1 }.map { it + 1 }.map { it * 2 } }
+                    .hold(emptyTFlow)
+                    .switchPromptly()
+                    .nextDeferred()
+            }
+        runCurrent()
+
+        emitter.emit(Unit)
+        runCurrent()
+
+        assertTrue("Not complete", result.isCompleted)
+        assertEquals(4, result.await())
+    }
+
+    @Test
+    fun switchDeeper() = runFrpTest { network ->
+        val emitter = network.mutableTFlow<Unit>()
+        val e2 = network.mutableTFlow<Unit>()
+        val result =
+            activateSpecWithResult(network) {
+                val tres =
+                    merge(e2.map { 1 }, e2.map { 2 }, transformCoincidence = { a, b -> a + b })
+                tres.observeBuild()
+                val switch = emitter.map { tres }.flatten()
+                merge(switch, e2.map { null }, transformCoincidence = { a, _ -> a })
+                    .filterNotNull()
+                    .nextDeferred()
+            }
+        runCurrent()
+
+        emitter.emit(Unit)
+        runCurrent()
+
+        e2.emit(Unit)
+        runCurrent()
+
+        assertTrue("Not complete", result.isCompleted)
+        assertEquals(3, result.await())
+    }
+
+    @Test
+    fun recursionBasic() = runFrpTest { network ->
+        val add1 = network.mutableTFlow<Unit>()
+        val sub1 = network.mutableTFlow<Unit>()
+        val stepResult: StateFlow<Int> =
+            activateSpecWithResult(network) {
+                val dSum = TStateLoop<Int>()
+                val sAdd1 = add1.sample(dSum) { _, sum -> sum + 1 }
+                val sMinus1 = sub1.sample(dSum) { _, sum -> sum - 1 }
+                dSum.loopback = sAdd1.mergeWith(sMinus1) { a, _ -> a }.hold(0)
+                dSum.toStateFlow()
+            }
+        runCurrent()
+
+        add1.emit(Unit)
+        runCurrent()
+
+        assertEquals(1, stepResult.value)
+
+        add1.emit(Unit)
+        runCurrent()
+
+        assertEquals(2, stepResult.value)
+
+        sub1.emit(Unit)
+        runCurrent()
+
+        assertEquals(1, stepResult.value)
+    }
+
+    @Test
+    fun recursiveTState() = runFrpTest { network ->
+        val e = network.mutableTFlow<Unit>()
+        var changes = 0
+        val state =
+            activateSpecWithResult(network) {
+                val s = TFlowLoop<Unit>()
+                val deferred = s.map { tStateOf(null) }
+                val e3 = e.map { tStateOf(Unit) }
+                val flattened = e3.mergeWith(deferred) { a, _ -> a }.hold(tStateOf(null)).flatten()
+                s.loopback = emptyTFlow
+                flattened.toStateFlow()
+            }
+
+        backgroundScope.launch { state.collect { changes++ } }
+        runCurrent()
+    }
+
+    @Test
+    fun fanOut() = runFrpTest { network ->
+        val e = network.mutableTFlow<Map<String, Int>>()
+        val (fooFlow, barFlow) =
+            activateSpecWithResult(network) {
+                val selector = e.groupByKey()
+                val foos = selector.eventsForKey("foo")
+                val bars = selector.eventsForKey("bar")
+                foos.toSharedFlow() to bars.toSharedFlow()
+            }
+        val stateFlow = fooFlow.stateIn(backgroundScope, SharingStarted.Eagerly, null)
+        backgroundScope.launch { barFlow.collect { error("unexpected bar") } }
+        runCurrent()
+
+        assertEquals(null, stateFlow.value)
+
+        e.emit(mapOf("foo" to 1))
+        runCurrent()
+
+        assertEquals(1, stateFlow.value)
+    }
+
+    @Test
+    fun fanOutLateSubscribe() = runFrpTest { network ->
+        val e = network.mutableTFlow<Map<String, Int>>()
+        val barFlow =
+            activateSpecWithResult(network) {
+                val selector = e.groupByKey()
+                selector
+                    .eventsForKey("foo")
+                    .map { selector.eventsForKey("bar") }
+                    .hold(emptyTFlow)
+                    .switchPromptly()
+                    .toSharedFlow()
+            }
+        val stateFlow = barFlow.stateIn(backgroundScope, SharingStarted.Eagerly, null)
+        runCurrent()
+
+        assertEquals(null, stateFlow.value)
+
+        e.emit(mapOf("foo" to 0, "bar" to 1))
+        runCurrent()
+
+        assertEquals(1, stateFlow.value)
+    }
+
+    @Test
+    fun inputFlowCompleted() = runFrpTest { network ->
+        val results = mutableListOf<Int>()
+        val e = network.mutableTFlow<Int>()
+        activateSpec(network) { e.nextOnly().observe { results.add(it) } }
+        runCurrent()
+
+        e.emit(10)
+        runCurrent()
+
+        assertEquals(listOf(10), results)
+
+        e.emit(20)
+        runCurrent()
+        assertEquals(listOf(10), results)
+    }
+
+    @Test
+    fun fanOutThenMergeIncrementally() = runFrpTest { network ->
+        // A tflow of group updates, where a group is a tflow of child updates, where a child is a
+        // stateflow
+        val e = network.mutableTFlow<Map<Int, Maybe<TFlow<Map<Int, Maybe<StateFlow<String>>>>>>>()
+        println("fanOutMergeInc START")
+        val state =
+            activateSpecWithResult(network) {
+                // Convert nested Flows to nested TFlow/TState
+                val emitter: TFlow<Map<Int, Maybe<TFlow<Map<Int, Maybe<TState<String>>>>>>> =
+                    e.mapBuild { m ->
+                        m.mapValues { (_, mFlow) ->
+                            mFlow.map {
+                                it.mapBuild { m2 ->
+                                    m2.mapValues { (_, mState) ->
+                                        mState.map { stateFlow -> stateFlow.toTState() }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                // Accumulate all of our updates into a single TState
+                val accState: TState<Map<Int, Map<Int, String>>> =
+                    emitter
+                        .mapStateful {
+                            changeMap: Map<Int, Maybe<TFlow<Map<Int, Maybe<TState<String>>>>>> ->
+                            changeMap.mapValues { (groupId, mGroupChanges) ->
+                                mGroupChanges.map {
+                                    groupChanges: TFlow<Map<Int, Maybe<TState<String>>>> ->
+                                    // New group
+                                    val childChangeById = groupChanges.groupByKey()
+                                    val map: TFlow<Map<Int, Maybe<TFlow<Maybe<TState<String>>>>>> =
+                                        groupChanges.mapStateful {
+                                            gChangeMap: Map<Int, Maybe<TState<String>>> ->
+                                            gChangeMap.mapValues { (childId, mChild) ->
+                                                mChild.map { child: TState<String> ->
+                                                    println("new child $childId in the house")
+                                                    // New child
+                                                    val eRemoved =
+                                                        childChangeById
+                                                            .eventsForKey(childId)
+                                                            .filter { it === None }
+                                                            .nextOnly()
+
+                                                    val addChild: TFlow<Maybe<TState<String>>> =
+                                                        now.map { mChild }
+                                                            .onEach {
+                                                                println(
+                                                                    "addChild (groupId=$groupId, childId=$childId) ${child.sample()}"
+                                                                )
+                                                            }
+
+                                                    val removeChild: TFlow<Maybe<TState<String>>> =
+                                                        eRemoved
+                                                            .onEach {
+                                                                println(
+                                                                    "removeChild (groupId=$groupId, childId=$childId)"
+                                                                )
+                                                            }
+                                                            .map { none() }
+
+                                                    addChild.mergeWith(removeChild) { _, _ ->
+                                                        error("unexpected coincidence")
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    val mergeIncrementally: TFlow<Map<Int, Maybe<TState<String>>>> =
+                                        map.onEach { println("merge patch: $it") }
+                                            .mergeIncrementallyPromptly()
+                                    mergeIncrementally
+                                        .onEach { println("patch: $it") }
+                                        .foldMapIncrementally()
+                                        .flatMap { it.combineValues() }
+                                }
+                            }
+                        }
+                        .foldMapIncrementally()
+                        .flatMap { it.combineValues() }
+
+                accState.toStateFlow()
+            }
+        runCurrent()
+
+        assertEquals(emptyMap(), state.value)
+
+        val emitter2 = network.mutableTFlow<Map<Int, Maybe<StateFlow<String>>>>()
+        println()
+        println("init outer 0")
+        e.emit(mapOf(0 to just(emitter2.onEach { println("emitter2 emit: $it") })))
+        runCurrent()
+
+        assertEquals(mapOf(0 to emptyMap()), state.value)
+
+        println()
+        println("init inner 10")
+        emitter2.emit(mapOf(10 to just(MutableStateFlow("(0, 10)"))))
+        runCurrent()
+
+        assertEquals(mapOf(0 to mapOf(10 to "(0, 10)")), state.value)
+
+        // replace
+        println()
+        println("replace inner 10")
+        emitter2.emit(mapOf(10 to just(MutableStateFlow("(1, 10)"))))
+        runCurrent()
+
+        assertEquals(mapOf(0 to mapOf(10 to "(1, 10)")), state.value)
+
+        // remove
+        emitter2.emit(mapOf(10 to none()))
+        runCurrent()
+
+        assertEquals(mapOf(0 to emptyMap()), state.value)
+
+        // add again
+        emitter2.emit(mapOf(10 to just(MutableStateFlow("(2, 10)"))))
+        runCurrent()
+
+        assertEquals(mapOf(0 to mapOf(10 to "(2, 10)")), state.value)
+
+        // batch update
+        emitter2.emit(
+            mapOf(
+                10 to none(),
+                11 to just(MutableStateFlow("(0, 11)")),
+                12 to just(MutableStateFlow("(0, 12)")),
+            )
+        )
+        runCurrent()
+
+        assertEquals(mapOf(0 to mapOf(11 to "(0, 11)", 12 to "(0, 12)")), state.value)
+    }
+
+    @Test
+    fun applyLatestNetworkChanges() = runFrpTest { network ->
+        val newCount = network.mutableTFlow<FrpSpec<Flow<Int>>>()
+        val flowOfFlows: Flow<Flow<Int>> =
+            activateSpecWithResult(network) { newCount.applyLatestSpec().toSharedFlow() }
+        runCurrent()
+
+        val incCount = network.mutableTFlow<Unit>()
+        fun newFlow(): FrpSpec<SharedFlow<Int>> = frpSpec {
+            launchEffect {
+                try {
+                    println("new flow!")
+                    awaitCancellation()
+                } finally {
+                    println("cancelling old flow")
+                }
+            }
+            lateinit var count: TState<Int>
+            count =
+                incCount
+                    .onEach { println("incrementing ${count.sample()}") }
+                    .fold(0) { _, c -> c + 1 }
+            count.stateChanges.toSharedFlow()
+        }
+
+        var outerCount = 0
+        val lastFlows: StateFlow<Pair<StateFlow<Int?>, StateFlow<Int?>>> =
+            flowOfFlows
+                .map { it.stateIn(backgroundScope, SharingStarted.Eagerly, null) }
+                .pairwise(MutableStateFlow(null))
+                .onEach { outerCount++ }
+                .stateIn(
+                    backgroundScope,
+                    SharingStarted.Eagerly,
+                    MutableStateFlow(null) to MutableStateFlow(null),
+                )
+
+        runCurrent()
+
+        newCount.emit(newFlow())
+        runCurrent()
+
+        assertEquals(1, outerCount)
+        //        assertEquals(1, incCount.subscriptionCount)
+        assertNull(lastFlows.value.second.value)
+
+        incCount.emit(Unit)
+        runCurrent()
+
+        println("checking")
+        assertEquals(1, lastFlows.value.second.value)
+
+        incCount.emit(Unit)
+        runCurrent()
+
+        assertEquals(2, lastFlows.value.second.value)
+
+        newCount.emit(newFlow())
+        runCurrent()
+        incCount.emit(Unit)
+        runCurrent()
+
+        // verify old flow is not getting updates
+        assertEquals(2, lastFlows.value.first.value)
+        // but the new one is
+        assertEquals(1, lastFlows.value.second.value)
+    }
+
+    @Test
+    fun effect() = runFrpTest { network ->
+        val input = network.mutableTFlow<Unit>()
+        var effectRunning = false
+        var count = 0
+        activateSpec(network) {
+            val j = launchEffect {
+                effectRunning = true
+                try {
+                    awaitCancellation()
+                } finally {
+                    effectRunning = false
+                }
+            }
+            merge(emptyTFlow, input.nextOnly()).observe {
+                count++
+                j.cancel()
+            }
+        }
+        runCurrent()
+        assertEquals(true, effectRunning)
+        assertEquals(0, count)
+
+        println("1")
+        input.emit(Unit)
+        assertEquals(false, effectRunning)
+        assertEquals(1, count)
+
+        println("2")
+        input.emit(Unit)
+        assertEquals(1, count)
+        println("3")
+        input.emit(Unit)
+        assertEquals(1, count)
+    }
+
+    private fun runFrpTest(
+        timeout: Duration = 3.seconds,
+        block: suspend TestScope.(FrpNetwork) -> Unit,
+    ) {
+        runTest(timeout = timeout) {
+            val network = backgroundScope.newFrpNetwork()
+            runCurrent()
+            block(network)
+        }
+    }
+
+    private fun TestScope.activateSpec(network: FrpNetwork, spec: FrpSpec<*>) =
+        backgroundScope.launch { network.activateSpec(spec) }
+
+    private suspend fun <R> TestScope.activateSpecWithResult(
+        network: FrpNetwork,
+        spec: FrpSpec<R>,
+    ): R =
+        CompletableDeferred<R>()
+            .apply { activateSpec(network) { complete(spec.applySpec()) } }
+            .await()
+}
+
+private fun <T> assertEquals(expected: T, actual: T) =
+    org.junit.Assert.assertEquals(expected, actual)
+
+private fun <A> Flow<A>.pairwise(init: A): Flow<Pair<A, A>> = flow {
+    var prev = init
+    collect {
+        emit(prev to it)
+        prev = it
+    }
+}
diff --git a/proto/src/system_messages.proto b/proto/src/system_messages.proto
index 994bdb5..6489905 100644
--- a/proto/src/system_messages.proto
+++ b/proto/src/system_messages.proto
@@ -206,7 +206,7 @@
 
     // Inform that DND settings have changed on OS upgrade
     // Package: android
-    NOTE_ZEN_UPGRADE = 48;
+    NOTE_ZEN_UPGRADE = 48  [deprecated = true];
 
     // Notification to suggest automatic battery saver.
     // Package: android
diff --git a/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java b/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java
index 6b6b39d..a77ba62 100644
--- a/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java
+++ b/services/accessibility/java/com/android/server/accessibility/magnification/FullScreenMagnificationController.java
@@ -47,7 +47,6 @@
 import android.util.Slog;
 import android.util.SparseArray;
 import android.util.TypedValue;
-import android.view.Display;
 import android.view.DisplayInfo;
 import android.view.MagnificationSpec;
 import android.view.View;
@@ -1637,9 +1636,10 @@
      * <strong>if scale is >= {@link MagnificationConstants.PERSISTED_SCALE_MIN_VALUE}</strong>.
      * We assume if the scale is < {@link MagnificationConstants.PERSISTED_SCALE_MIN_VALUE}, there
      * will be no obvious magnification effect.
+     * Only the value of the default display is persisted in user's settings.
      */
     public void persistScale(int displayId) {
-        final float scale = getScale(Display.DEFAULT_DISPLAY);
+        final float scale = getScale(displayId);
         if (scale < MagnificationConstants.PERSISTED_SCALE_MIN_VALUE) {
             return;
         }
diff --git a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
index c87d516..c522b64 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
@@ -205,6 +205,7 @@
                 .verifyCallerCanExecuteAppFunction(
                         callingUid,
                         callingPid,
+                        targetUser,
                         requestInternal.getCallingPackage(),
                         targetPackageName,
                         requestInternal.getClientRequest().getFunctionIdentifier())
@@ -492,7 +493,7 @@
             Slog.e(TAG, "Failed to bind to the AppFunctionService");
             safeExecuteAppFunctionCallback.onResult(
                     ExecuteAppFunctionResponse.newFailure(
-                            ExecuteAppFunctionResponse.RESULT_TIMED_OUT,
+                            ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR,
                             "Failed to bind the AppFunctionService.",
                             /* extras= */ null));
         }
diff --git a/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java b/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java
index 3592ed5..5393b93 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java
@@ -64,6 +64,9 @@
      * {@link Manifest.permission#EXECUTE_APP_FUNCTIONS} granted. In some cases, app functions can
      * still opt-out of caller having {@link Manifest.permission#EXECUTE_APP_FUNCTIONS}.
      *
+     * @param callingUid The calling uid.
+     * @param callingPid The calling pid.
+     * @param targetUser The user which the caller is requesting to execute as.
      * @param callerPackageName The calling package (as previously validated).
      * @param targetPackageName The package that owns the app function to execute.
      * @param functionId The id of the app function to execute.
@@ -72,6 +75,7 @@
     AndroidFuture<Boolean> verifyCallerCanExecuteAppFunction(
             int callingUid,
             int callingPid,
+            @NonNull UserHandle targetUser,
             @NonNull String callerPackageName,
             @NonNull String targetPackageName,
             @NonNull String functionId);
diff --git a/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java b/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java
index 8b6251a..e85a70d 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java
@@ -93,6 +93,7 @@
     public AndroidFuture<Boolean> verifyCallerCanExecuteAppFunction(
             int callingUid,
             int callingPid,
+            @NonNull UserHandle targetUser,
             @NonNull String callerPackageName,
             @NonNull String targetPackageName,
             @NonNull String functionId) {
@@ -122,7 +123,10 @@
 
         FutureAppSearchSession futureAppSearchSession =
                 new FutureAppSearchSessionImpl(
-                        mContext.getSystemService(AppSearchManager.class),
+                        Objects.requireNonNull(
+                                mContext
+                                        .createContextAsUser(targetUser, 0)
+                                        .getSystemService(AppSearchManager.class)),
                         THREAD_POOL_EXECUTOR,
                         new SearchContext.Builder(APP_FUNCTION_STATIC_METADATA_DB).build());
 
diff --git a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
index cd2dd3a..81ae717 100644
--- a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
+++ b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
@@ -42,6 +42,7 @@
 import android.app.admin.DevicePolicyManager;
 import android.app.compat.CompatChanges;
 import android.companion.AssociationInfo;
+import android.companion.AssociationRequest;
 import android.companion.virtual.ActivityPolicyExemption;
 import android.companion.virtual.IVirtualDevice;
 import android.companion.virtual.IVirtualDeviceActivityListener;
@@ -153,6 +154,9 @@
 
     private static final String PERSISTENT_ID_PREFIX_CDM_ASSOCIATION = "companion:";
 
+    private static final List<String> DEVICE_PROFILES_ALLOWING_MIRROR_DISPLAYS = List.of(
+            AssociationRequest.DEVICE_PROFILE_APP_STREAMING);
+
     /**
      * Timeout until {@link #launchPendingIntent} stops waiting for an activity to be launched.
      */
@@ -498,6 +502,10 @@
         return mAssociationInfo == null ? mParams.getName() : mAssociationInfo.getDisplayName();
     }
 
+    String getDeviceProfile() {
+        return mAssociationInfo == null ? null : mAssociationInfo.getDeviceProfile();
+    }
+
     /** Returns the public representation of the device. */
     VirtualDevice getPublicVirtualDeviceObject() {
         return mPublicVirtualDeviceObject;
@@ -1294,6 +1302,11 @@
         return hasCustomAudioInputSupportInternal();
     }
 
+    @Override
+    public boolean canCreateMirrorDisplays() {
+        return DEVICE_PROFILES_ALLOWING_MIRROR_DISPLAYS.contains(getDeviceProfile());
+    }
+
     private boolean hasCustomAudioInputSupportInternal() {
         if (!Flags.vdmPublicApis()) {
             return false;
diff --git a/services/core/java/com/android/server/PackageWatchdog.java b/services/core/java/com/android/server/PackageWatchdog.java
index 9060250..2acedd5 100644
--- a/services/core/java/com/android/server/PackageWatchdog.java
+++ b/services/core/java/com/android/server/PackageWatchdog.java
@@ -47,6 +47,7 @@
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.AtomicFile;
+import android.util.EventLog;
 import android.util.IndentingPrintWriter;
 import android.util.LongArrayQueue;
 import android.util.Slog;
@@ -200,6 +201,13 @@
     // aborted.
     private static final String METADATA_FILE = "/metadata/watchdog/mitigation_count.txt";
 
+    /**
+     * EventLog tags used when logging into the event log. Note the values must be sync with
+     * frameworks/base/services/core/java/com/android/server/EventLogTags.logtags to get correct
+     * name translation.
+     */
+    private static final int LOG_TAG_RESCUE_NOTE = 2900;
+
     private static final Object sPackageWatchdogLock = new Object();
     @GuardedBy("sPackageWatchdogLock")
     private static PackageWatchdog sPackageWatchdog;
@@ -2024,7 +2032,7 @@
             } else {
                 int count = getCount() + 1;
                 setCount(count);
-                EventLogTags.writeRescueNote(Process.ROOT_UID, count, window);
+                EventLog.writeEvent(LOG_TAG_RESCUE_NOTE, Process.ROOT_UID, count, window);
                 if (Flags.recoverabilityDetection()) {
                     // After a reboot (e.g. by WARM_REBOOT or mainline rollback) we apply
                     // mitigations without waiting for DEFAULT_BOOT_LOOP_TRIGGER_COUNT.
diff --git a/services/core/java/com/android/server/RescueParty.java b/services/core/java/com/android/server/RescueParty.java
index ada1953..feb5775 100644
--- a/services/core/java/com/android/server/RescueParty.java
+++ b/services/core/java/com/android/server/RescueParty.java
@@ -42,6 +42,7 @@
 import android.sysprop.CrashRecoveryProperties;
 import android.text.TextUtils;
 import android.util.ArraySet;
+import android.util.EventLog;
 import android.util.Log;
 import android.util.Slog;
 
@@ -154,6 +155,14 @@
     private static final int PERSISTENT_MASK = ApplicationInfo.FLAG_PERSISTENT
             | ApplicationInfo.FLAG_SYSTEM;
 
+    /**
+     * EventLog tags used when logging into the event log. Note the values must be sync with
+     * frameworks/base/services/core/java/com/android/server/EventLogTags.logtags to get correct
+     * name translation.
+     */
+    private static final int LOG_TAG_RESCUE_SUCCESS = 2902;
+    private static final int LOG_TAG_RESCUE_FAILURE = 2903;
+
     /** Register the Rescue Party observer as a Package Watchdog health observer */
     public static void registerHealthObserver(Context context) {
         PackageWatchdog.getInstance(context).registerHealthObserver(
@@ -523,7 +532,7 @@
         Slog.w(TAG, "Attempting rescue level " + levelToString(level));
         try {
             executeRescueLevelInternal(context, level, failedPackage);
-            EventLogTags.writeRescueSuccess(level);
+            EventLog.writeEvent(LOG_TAG_RESCUE_SUCCESS, level);
             String successMsg = "Finished rescue level " + levelToString(level);
             if (!TextUtils.isEmpty(failedPackage)) {
                 successMsg += " for package " + failedPackage;
@@ -704,7 +713,7 @@
     private static void logRescueException(int level, @Nullable String failedPackageName,
             Throwable t) {
         final String msg = getCompleteMessage(t);
-        EventLogTags.writeRescueFailure(level, msg);
+        EventLog.writeEvent(LOG_TAG_RESCUE_FAILURE, level, msg);
         String failureMsg = "Failed rescue level " + levelToString(level);
         if (!TextUtils.isEmpty(failedPackageName)) {
             failureMsg += " for package " + failedPackageName;
diff --git a/services/core/java/com/android/server/TEST_MAPPING b/services/core/java/com/android/server/TEST_MAPPING
index a459ea9..ce66dc3 100644
--- a/services/core/java/com/android/server/TEST_MAPPING
+++ b/services/core/java/com/android/server/TEST_MAPPING
@@ -114,6 +114,9 @@
             "options": [
                 {
                     "include-filter": "android.os.storage.cts.StorageManagerTest"
+                },
+                {
+                    "include-filter": "android.os.storage.cts.StorageStatsManagerTest"
                 }
             ]
         }
@@ -173,15 +176,6 @@
                     "include-filter": "com.android.server.wm.BackgroundActivityStart*"
                 }
             ]
-        },
-        {
-            "name": "CtsOsTestCases",
-            "file_patterns": ["StorageManagerService\\.java"],
-            "options": [
-                {
-                    "include-filter": "android.os.storage.cts.StorageStatsManagerTest"
-                }
-            ]
         }
    ]
 }
diff --git a/services/core/java/com/android/server/accounts/AccountManagerService.java b/services/core/java/com/android/server/accounts/AccountManagerService.java
index 3499a3a..0ca3b56 100644
--- a/services/core/java/com/android/server/accounts/AccountManagerService.java
+++ b/services/core/java/com/android/server/accounts/AccountManagerService.java
@@ -5062,6 +5062,8 @@
                     Log.e(TAG, String.format(tmpl, activityName, pkgName, mAccountType));
                     return false;
                 }
+                intent.setComponent(targetActivityInfo.getComponentName());
+                bundle.putParcelable(AccountManager.KEY_INTENT, intent);
                 return true;
             } finally {
                 Binder.restoreCallingIdentity(bid);
@@ -5083,14 +5085,15 @@
             Bundle simulateBundle = p.readBundle();
             p.recycle();
             Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT, Intent.class);
-            if (intent != null && intent.getClass() != Intent.class) {
-                return false;
-            }
             Intent simulateIntent = simulateBundle.getParcelable(AccountManager.KEY_INTENT,
                     Intent.class);
             if (intent == null) {
                 return (simulateIntent == null);
             }
+            if (intent.getClass() != Intent.class || simulateIntent.getClass() != Intent.class) {
+                return false;
+            }
+
             if (!intent.filterEquals(simulateIntent)) {
                 return false;
             }
diff --git a/services/core/java/com/android/server/am/AppStartInfoTracker.java b/services/core/java/com/android/server/am/AppStartInfoTracker.java
index 71b6456..aca6d0b 100644
--- a/services/core/java/com/android/server/am/AppStartInfoTracker.java
+++ b/services/core/java/com/android/server/am/AppStartInfoTracker.java
@@ -1005,7 +1005,8 @@
                 case (int) AppsStartInfoProto.Package.USERS:
                     AppStartInfoContainer container =
                             new AppStartInfoContainer(mAppStartInfoHistoryListSize);
-                    int uid = container.readFromProto(proto, AppsStartInfoProto.Package.USERS);
+                    int uid = container.readFromProto(proto, AppsStartInfoProto.Package.USERS,
+                            pkgName);
                     synchronized (mLock) {
                         mData.put(pkgName, uid, container);
                     }
@@ -1403,7 +1404,7 @@
             proto.end(token);
         }
 
-        int readFromProto(ProtoInputStream proto, long fieldId)
+        int readFromProto(ProtoInputStream proto, long fieldId, String packageName)
                 throws IOException, WireTypeMismatchException, ClassNotFoundException {
             long token = proto.start(fieldId);
             for (int next = proto.nextField();
@@ -1418,6 +1419,7 @@
                         // have a create time.
                         ApplicationStartInfo info = new ApplicationStartInfo(0);
                         info.readFromProto(proto, AppsStartInfoProto.Package.User.APP_START_INFO);
+                        info.setPackageName(packageName);
                         mInfos.add(info);
                         break;
                     case (int) AppsStartInfoProto.Package.User.MONITORING_ENABLED:
diff --git a/services/core/java/com/android/server/appop/AppOpsService.java b/services/core/java/com/android/server/appop/AppOpsService.java
index e0cf96f..c2e62d0 100644
--- a/services/core/java/com/android/server/appop/AppOpsService.java
+++ b/services/core/java/com/android/server/appop/AppOpsService.java
@@ -540,11 +540,11 @@
             this.uid = uid;
         }
 
+        @SuppressWarnings("GuardedBy")
         public void clear() {
             mAppOpsCheckingService.removeUid(uid);
             for (int i = 0; i < pkgOps.size(); i++) {
-                String packageName = pkgOps.keyAt(i);
-                mAppOpsCheckingService.removePackage(packageName, UserHandle.getUserId(uid));
+                packageRemovedLocked(uid, pkgOps.keyAt(i));
             }
         }
 
diff --git a/services/core/java/com/android/server/audio/AudioDeviceBroker.java b/services/core/java/com/android/server/audio/AudioDeviceBroker.java
index 87504154..0475b94 100644
--- a/services/core/java/com/android/server/audio/AudioDeviceBroker.java
+++ b/services/core/java/com/android/server/audio/AudioDeviceBroker.java
@@ -384,11 +384,12 @@
     /**
      * Indicates if a Bluetooth SCO activation request owner is controlling
      * the SCO audio state itself or not.
-     * @param uid the UI of the SOC request owner app
+     * @param uid the UID of the SOC request owner app
      * @return true if we should control SCO audio state, false otherwise
      */
     private boolean shouldStartScoForUid(int uid) {
-        return !(uid == Process.BLUETOOTH_UID || uid == Process.PHONE_UID);
+        return !(UserHandle.isSameApp(uid, Process.BLUETOOTH_UID)
+                || UserHandle.isSameApp(uid, Process.PHONE_UID));
     }
 
     @GuardedBy("mDeviceStateLock")
diff --git a/services/core/java/com/android/server/audio/AudioDeviceInventory.java b/services/core/java/com/android/server/audio/AudioDeviceInventory.java
index 5fd12c2..09de894 100644
--- a/services/core/java/com/android/server/audio/AudioDeviceInventory.java
+++ b/services/core/java/com/android/server/audio/AudioDeviceInventory.java
@@ -385,11 +385,6 @@
                     || !updatedDevice.getDeviceAddress().equals(ads.getDeviceAddress())) {
                 continue;
             }
-            if (mDeviceBroker.isSADevice(updatedDevice) == mDeviceBroker.isSADevice(ads)) {
-                ads.setHasHeadTracker(updatedDevice.hasHeadTracker());
-                ads.setHeadTrackerEnabled(updatedDevice.isHeadTrackerEnabled());
-                ads.setSAEnabled(updatedDevice.isSAEnabled());
-            }
             ads.setAudioDeviceCategory(updatedDevice.getAudioDeviceCategory());
 
             mDeviceBroker.postUpdatedAdiDeviceState(ads, false /*initSA*/);
diff --git a/services/core/java/com/android/server/audio/AudioService.java b/services/core/java/com/android/server/audio/AudioService.java
index 561030e..c37d471 100644
--- a/services/core/java/com/android/server/audio/AudioService.java
+++ b/services/core/java/com/android/server/audio/AudioService.java
@@ -1583,8 +1583,11 @@
 
         synchronized (mCachedAbsVolDrivingStreamsLock) {
             mCachedAbsVolDrivingStreams.forEach((dev, stream) -> {
-                mAudioSystem.setDeviceAbsoluteVolumeEnabled(dev, /*address=*/"", /*enabled=*/true,
-                        stream);
+                boolean enabled = true;
+                if (dev == AudioSystem.DEVICE_OUT_BLUETOOTH_A2DP) {
+                    enabled = mAvrcpAbsVolSupported;
+                }
+                mAudioSystem.setDeviceAbsoluteVolumeEnabled(dev, /*address=*/"", enabled, stream);
             });
         }
     }
@@ -4881,7 +4884,7 @@
                 if (absDev == AudioSystem.DEVICE_OUT_BLUETOOTH_A2DP) {
                     enabled = mAvrcpAbsVolSupported;
                 }
-                if (stream != streamType) {
+                if (stream != streamType || !enabled) {
                     mAudioSystem.setDeviceAbsoluteVolumeEnabled(absDev, /*address=*/"",
                             enabled, streamType);
                 }
@@ -10097,9 +10100,6 @@
 
                 case MSG_INIT_SPATIALIZER:
                     onInitSpatializer();
-                    // the device inventory can only be synchronized after the
-                    // spatializer has been initialized
-                    mDeviceBroker.postSynchronizeAdiDevicesInInventory(null);
                     mAudioEventWakeLock.release();
                     break;
 
@@ -10383,10 +10383,10 @@
     }
 
     /*package*/ void setAvrcpAbsoluteVolumeSupported(boolean support) {
-        mAvrcpAbsVolSupported = support;
-        if (absVolumeIndexFix()) {
-            int a2dpDev = AudioSystem.DEVICE_OUT_BLUETOOTH_A2DP;
-            synchronized (mCachedAbsVolDrivingStreamsLock) {
+        synchronized (mCachedAbsVolDrivingStreamsLock) {
+            mAvrcpAbsVolSupported = support;
+            if (absVolumeIndexFix()) {
+                int a2dpDev = AudioSystem.DEVICE_OUT_BLUETOOTH_A2DP;
                 mCachedAbsVolDrivingStreams.compute(a2dpDev, (dev, stream) -> {
                     if (!mAvrcpAbsVolSupported) {
                         mAudioSystem.setDeviceAbsoluteVolumeEnabled(a2dpDev, /*address=*/
@@ -12499,6 +12499,12 @@
         pw.println("\nLoudness alignment:");
         mLoudnessCodecHelper.dump(pw);
 
+        pw.println("\nAbsolute voume devices:");
+        synchronized (mCachedAbsVolDrivingStreamsLock) {
+            mCachedAbsVolDrivingStreams.forEach((dev, stream) -> pw.println(
+                    "Device type: 0x" + Integer.toHexString(dev) + ", driving stream " + stream));
+        }
+
         mAudioSystem.dump(pw);
     }
 
diff --git a/services/core/java/com/android/server/biometrics/AuthSession.java b/services/core/java/com/android/server/biometrics/AuthSession.java
index abfbddc..3afecf1 100644
--- a/services/core/java/com/android/server/biometrics/AuthSession.java
+++ b/services/core/java/com/android/server/biometrics/AuthSession.java
@@ -879,6 +879,14 @@
                     );
                     break;
 
+                case BiometricPrompt.DISMISSED_REASON_ERROR_NO_WM:
+                    mClientReceiver.onError(
+                            getEligibleModalities(),
+                            BiometricConstants.BIOMETRIC_ERROR_HW_UNAVAILABLE,
+                            0 /* vendorCode */
+                    );
+                    break;
+
                 default:
                     Slog.w(TAG, "Unhandled reason: " + reason);
                     break;
diff --git a/services/core/java/com/android/server/biometrics/Utils.java b/services/core/java/com/android/server/biometrics/Utils.java
index de7bce7..8734136 100644
--- a/services/core/java/com/android/server/biometrics/Utils.java
+++ b/services/core/java/com/android/server/biometrics/Utils.java
@@ -253,9 +253,15 @@
 
         // Check if any of the non-biometric and non-credential bits are set. If so, this is
         // invalid.
-        final int testBits = ~(Authenticators.DEVICE_CREDENTIAL
-                | Authenticators.BIOMETRIC_MIN_STRENGTH
-                | Authenticators.MANDATORY_BIOMETRICS);
+        final int testBits;
+        if (Flags.mandatoryBiometrics()) {
+            testBits = ~(Authenticators.DEVICE_CREDENTIAL
+                    | Authenticators.BIOMETRIC_MIN_STRENGTH
+                    | Authenticators.MANDATORY_BIOMETRICS);
+        } else {
+            testBits = ~(Authenticators.DEVICE_CREDENTIAL
+                    | Authenticators.BIOMETRIC_MIN_STRENGTH);
+        }
         if ((authenticators & testBits) != 0) {
             Slog.e(BiometricService.TAG, "Non-biometric, non-credential bits found."
                     + " Authenticators: " + authenticators);
diff --git a/services/core/java/com/android/server/display/DisplayGroup.java b/services/core/java/com/android/server/display/DisplayGroup.java
index 2dcd5cc..f73b66c 100644
--- a/services/core/java/com/android/server/display/DisplayGroup.java
+++ b/services/core/java/com/android/server/display/DisplayGroup.java
@@ -87,4 +87,14 @@
     int getIdLocked(int index) {
         return mDisplays.get(index).getDisplayIdLocked();
     }
+
+    /** Returns the IDs of the {@link LogicalDisplay}s belonging to the DisplayGroup. */
+    int[] getIdsLocked() {
+        final int numDisplays = mDisplays.size();
+        final int[] displayIds = new int[numDisplays];
+        for (int i = 0; i < numDisplays; i++) {
+            displayIds[i] = mDisplays.get(i).getDisplayIdLocked();
+        }
+        return displayIds;
+    }
 }
diff --git a/services/core/java/com/android/server/display/DisplayManagerService.java b/services/core/java/com/android/server/display/DisplayManagerService.java
index ec1ec3a..99a7743 100644
--- a/services/core/java/com/android/server/display/DisplayManagerService.java
+++ b/services/core/java/com/android/server/display/DisplayManagerService.java
@@ -1661,33 +1661,49 @@
         return false;
     }
 
+    private boolean hasVideoOutputPermission(String func) {
+        return checkCallingPermission(CAPTURE_VIDEO_OUTPUT, func)
+                || hasSecureVideoOutputPermission(func);
+    }
+
+    private boolean hasSecureVideoOutputPermission(String func) {
+        return checkCallingPermission(CAPTURE_SECURE_VIDEO_OUTPUT, func);
+    }
+
+    private boolean canCreateMirrorDisplays(IVirtualDevice virtualDevice) {
+        if (virtualDevice == null) {
+            return false;
+        }
+        try {
+            return virtualDevice.canCreateMirrorDisplays();
+        } catch (RemoteException e) {
+            Slog.e(TAG, "Unable to query virtual device for permissions", e);
+            return false;
+        }
+    }
+
     private boolean canProjectVideo(IMediaProjection projection) {
-        if (projection != null) {
-            try {
-                if (projection.canProjectVideo()) {
-                    return true;
-                }
-            } catch (RemoteException e) {
-                Slog.e(TAG, "Unable to query projection service for permissions", e);
-            }
+        if (projection == null) {
+            return false;
         }
-        if (checkCallingPermission(CAPTURE_VIDEO_OUTPUT, "canProjectVideo()")) {
-            return true;
+        try {
+            return projection.canProjectVideo();
+        } catch (RemoteException e) {
+            Slog.e(TAG, "Unable to query projection service for permissions", e);
+            return false;
         }
-        return canProjectSecureVideo(projection);
     }
 
     private boolean canProjectSecureVideo(IMediaProjection projection) {
-        if (projection != null) {
-            try {
-                if (projection.canProjectSecureVideo()) {
-                    return true;
-                }
-            } catch (RemoteException e) {
-                Slog.e(TAG, "Unable to query projection service for permissions", e);
-            }
+        if (projection == null) {
+            return false;
         }
-        return checkCallingPermission(CAPTURE_SECURE_VIDEO_OUTPUT, "canProjectSecureVideo()");
+        try {
+            return projection.canProjectSecureVideo();
+        } catch (RemoteException e) {
+            Slog.e(TAG, "Unable to query projection service for permissions", e);
+            return false;
+        }
     }
 
     private boolean checkCallingPermission(String permission, String func) {
@@ -1793,7 +1809,8 @@
                 && (flags & VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR) != 0) {
             // Only a valid media projection or a virtual device can create a mirror virtual
             // display.
-            if (!canProjectVideo(projection) && virtualDevice == null) {
+            if (!canProjectVideo(projection) && !canCreateMirrorDisplays(virtualDevice)
+                    && !hasVideoOutputPermission("createVirtualDisplayInternal")) {
                 throw new SecurityException("Requires CAPTURE_VIDEO_OUTPUT or "
                         + "CAPTURE_SECURE_VIDEO_OUTPUT permission, or an appropriate "
                         + "MediaProjection token in order to create a screen sharing virtual "
@@ -1803,7 +1820,8 @@
             }
         }
         if (callingUid != Process.SYSTEM_UID && (flags & VIRTUAL_DISPLAY_FLAG_SECURE) != 0) {
-            if (!canProjectSecureVideo(projection)) {
+            if (!canProjectSecureVideo(projection)
+                    && !hasSecureVideoOutputPermission("createVirtualDisplayInternal")) {
                 throw new SecurityException("Requires CAPTURE_SECURE_VIDEO_OUTPUT "
                         + "or an appropriate MediaProjection token to create a "
                         + "secure virtual display.");
@@ -2093,16 +2111,6 @@
         }
     }
 
-    private void setVirtualDisplayStateInternal(IBinder appToken, boolean isOn) {
-        synchronized (mSyncRoot) {
-            if (mVirtualDisplayAdapter == null) {
-                return;
-            }
-
-            mVirtualDisplayAdapter.setVirtualDisplayStateLocked(appToken, isOn);
-        }
-    }
-
     private void setVirtualDisplayRotationInternal(IBinder appToken,
             @Surface.Rotation int rotation) {
         int displayId;
@@ -4615,16 +4623,6 @@
         }
 
         @Override // Binder call
-        public void setVirtualDisplayState(IVirtualDisplayCallback callback, boolean isOn) {
-            final long token = Binder.clearCallingIdentity();
-            try {
-                setVirtualDisplayStateInternal(callback.asBinder(), isOn);
-            } finally {
-                Binder.restoreCallingIdentity(token);
-            }
-        }
-
-        @Override // Binder call
         public void setVirtualDisplayRotation(IVirtualDisplayCallback callback,
                 @Surface.Rotation int rotation) {
             if (!android.companion.virtualdevice.flags.Flags.virtualDisplayRotationApi()) {
@@ -5589,6 +5587,20 @@
         }
 
         @Override
+        public int[] getDisplayIdsForGroup(int groupId) {
+            synchronized (mSyncRoot) {
+                return mLogicalDisplayMapper.getDisplayIdsForGroupLocked(groupId);
+            }
+        }
+
+        @Override
+        public SparseArray<int[]> getDisplayIdsByGroupsIds() {
+            synchronized (mSyncRoot) {
+                return mLogicalDisplayMapper.getDisplayIdsByGroupIdLocked();
+            }
+        }
+
+        @Override
         public IntArray getDisplayIds() {
             IntArray displayIds = new IntArray();
             synchronized (mSyncRoot) {
diff --git a/services/core/java/com/android/server/display/LogicalDisplayMapper.java b/services/core/java/com/android/server/display/LogicalDisplayMapper.java
index c3f6a52..06a9103 100644
--- a/services/core/java/com/android/server/display/LogicalDisplayMapper.java
+++ b/services/core/java/com/android/server/display/LogicalDisplayMapper.java
@@ -344,6 +344,23 @@
         return displayIds;
     }
 
+    public int[] getDisplayIdsForGroupLocked(int groupId) {
+        DisplayGroup displayGroup = mDisplayGroups.get(groupId);
+        if (displayGroup == null) {
+            return new int[]{};
+        }
+        return displayGroup.getIdsLocked();
+    }
+
+    public SparseArray<int[]> getDisplayIdsByGroupIdLocked() {
+        SparseArray<int[]> displayIdsByGroupIds = new SparseArray<>();
+        for (int i = 0; i < mDisplayGroups.size(); i++) {
+            final int displayGroupId = mDisplayGroups.keyAt(i);
+            displayIdsByGroupIds.put(displayGroupId, getDisplayIdsForGroupLocked(displayGroupId));
+        }
+        return displayIdsByGroupIds;
+    }
+
     public void forEachLocked(Consumer<LogicalDisplay> consumer) {
         forEachLocked(consumer, /* includeDisabled= */ true);
     }
diff --git a/services/core/java/com/android/server/display/VirtualDisplayAdapter.java b/services/core/java/com/android/server/display/VirtualDisplayAdapter.java
index 9b02f4b..e77c5ec 100644
--- a/services/core/java/com/android/server/display/VirtualDisplayAdapter.java
+++ b/services/core/java/com/android/server/display/VirtualDisplayAdapter.java
@@ -207,13 +207,6 @@
         return device;
     }
 
-    void setVirtualDisplayStateLocked(IBinder appToken, boolean isOn) {
-        VirtualDisplayDevice device = mVirtualDisplayDevices.get(appToken);
-        if (device != null) {
-            device.setDisplayState(isOn);
-        }
-    }
-
     DisplayDevice getDisplayDevice(IBinder appToken) {
         return mVirtualDisplayDevices.get(appToken);
     }
@@ -273,7 +266,6 @@
         private boolean mStopped;
         private int mPendingChanges;
         private Display.Mode mMode;
-        private boolean mIsDisplayOn;
         private int mDisplayIdToMirror;
         private boolean mIsWindowManagerMirroring;
         private DisplayCutout mDisplayCutout;
@@ -299,9 +291,8 @@
             mCallback = callback;
             mProjection = projection;
             mMediaProjectionCallback = mediaProjectionCallback;
-            mDisplayState = Display.STATE_UNKNOWN;
+            mDisplayState = Display.STATE_ON;
             mPendingChanges |= PENDING_SURFACE_CHANGE;
-            mIsDisplayOn = surface != null;
             mDisplayIdToMirror = virtualDisplayConfig.getDisplayIdToMirror();
             mIsWindowManagerMirroring = virtualDisplayConfig.isWindowManagerMirroringEnabled();
         }
@@ -394,6 +385,8 @@
                 float sdrBrightnessState, DisplayOffloadSessionImpl displayOffloadSession) {
             if (state != mDisplayState) {
                 mDisplayState = state;
+                mInfo = null;
+                sendDisplayDeviceEventLocked(this, DISPLAY_DEVICE_EVENT_CHANGED);
                 if (state == Display.STATE_OFF) {
                     mCallback.dispatchDisplayPaused();
                 } else {
@@ -416,12 +409,13 @@
 
         public void setSurfaceLocked(Surface surface) {
             if (!mStopped && mSurface != surface) {
-                if ((mSurface != null) != (surface != null)) {
+                if (mDisplayState == Display.STATE_ON
+                        && ((mSurface == null) != (surface == null))) {
+                    mInfo = null;
                     sendDisplayDeviceEventLocked(this, DISPLAY_DEVICE_EVENT_CHANGED);
                 }
                 sendTraversalRequestLocked();
                 mSurface = surface;
-                mInfo = null;
                 mPendingChanges |= PENDING_SURFACE_CHANGE;
             }
         }
@@ -439,14 +433,6 @@
             }
         }
 
-        void setDisplayState(boolean isOn) {
-            if (mIsDisplayOn != isOn) {
-                mIsDisplayOn = isOn;
-                mInfo = null;
-                sendDisplayDeviceEventLocked(this, DISPLAY_DEVICE_EVENT_CHANGED);
-            }
-        }
-
         public void stopLocked() {
             Slog.d(TAG, "Virtual Display: stopping device " + mName);
             setSurfaceLocked(null);
@@ -567,7 +553,11 @@
                 mInfo.touch = ((mFlags & VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH) == 0) ?
                         DisplayDeviceInfo.TOUCH_NONE : DisplayDeviceInfo.TOUCH_VIRTUAL;
 
-                mInfo.state = mIsDisplayOn ? Display.STATE_ON : Display.STATE_OFF;
+                if (mSurface == null) {
+                    mInfo.state = Display.STATE_OFF;
+                } else {
+                    mInfo.state = mDisplayState;
+                }
 
                 mInfo.ownerUid = mOwnerUid;
                 mInfo.ownerPackageName = mOwnerPackageName;
diff --git a/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java b/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java
index 91a4d6f..598901e 100644
--- a/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java
+++ b/services/core/java/com/android/server/location/contexthub/ContextHubClientBroker.java
@@ -764,6 +764,7 @@
     boolean hasPermissions(List<String> permissions) {
         for (String permission : permissions) {
             if (mContext.checkPermission(permission, mPid, mUid) != PERMISSION_GRANTED) {
+                Log.e(TAG, "no permission for " + permission);
                 return false;
             }
         }
@@ -919,6 +920,14 @@
             }
         }
         if (curAuthState != newAuthState) {
+            if (newAuthState == AUTHORIZATION_DENIED
+                    || newAuthState == AUTHORIZATION_DENIED_GRACE_PERIOD) {
+                Log.e(TAG, "updateNanoAppAuthState auth error: "
+                        + Long.toHexString(nanoAppId) + ", "
+                        + nanoappPermissions + ", "
+                        + gracePeriodExpired + ", "
+                        + forceDenied);
+            }
             // Don't send the callback in the synchronized block or it could end up in a deadlock.
             sendAuthStateCallback(nanoAppId, newAuthState);
         }
diff --git a/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java b/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
index e1b8e9f..8b06dad 100644
--- a/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
+++ b/services/core/java/com/android/server/media/MediaRouter2ServiceImpl.java
@@ -1653,9 +1653,11 @@
                             manager));
         }
 
+        List<MediaRoute2Info> routes =
+                userRecord.mHandler.mLastNotifiedRoutesToPrivilegedRouters.values().stream()
+                        .toList();
         userRecord.mHandler.sendMessage(
-                obtainMessage(
-                        UserHandler::notifyInitialRoutesToManager, userRecord.mHandler, manager));
+                obtainMessage(ManagerRecord::notifyRoutesUpdated, managerRecord, routes));
     }
 
     @GuardedBy("mLock")
@@ -2433,6 +2435,51 @@
             }
         }
 
+        /**
+         * Notifies the corresponding manager of the availability of the given routes.
+         *
+         * @param routes The routes available to the manager that corresponds to this record.
+         */
+        public void notifyRoutesUpdated(List<MediaRoute2Info> routes) {
+            try {
+                mManager.notifyRoutesUpdated(routes);
+            } catch (RemoteException ex) {
+                Slog.w(TAG, "Failed to notify routes. Manager probably died.", ex);
+            }
+        }
+
+        /**
+         * Notifies the corresponding manager of an update in the given session.
+         *
+         * @param sessionInfo The updated session info.
+         */
+        public void notifySessionUpdated(RoutingSessionInfo sessionInfo) {
+            try {
+                mManager.notifySessionUpdated(sessionInfo);
+            } catch (RemoteException ex) {
+                Slog.w(
+                        TAG,
+                        "notifySessionUpdatedToManagers: Failed to notify. Manager probably died.",
+                        ex);
+            }
+        }
+
+        /**
+         * Notifies the corresponding manager that the given session has been released.
+         *
+         * @param sessionInfo The released session info.
+         */
+        public void notifySessionReleased(RoutingSessionInfo sessionInfo) {
+            try {
+                mManager.notifySessionReleased(sessionInfo);
+            } catch (RemoteException ex) {
+                Slog.w(
+                        TAG,
+                        "notifySessionReleasedToManagers: Failed to notify. Manager probably died.",
+                        ex);
+            }
+        }
+
         private void updateScanningState(@ScanningState int scanningState) {
             if (mScanningState == scanningState) {
                 return;
@@ -2761,18 +2808,20 @@
                     getRouterRecords(/* hasSystemRoutingPermission= */ true);
             List<RouterRecord> routerRecordsWithoutSystemRoutingPermission =
                     getRouterRecords(/* hasSystemRoutingPermission= */ false);
-            List<IMediaRouter2Manager> managers = getManagers();
+            List<ManagerRecord> managers = getManagerRecords();
 
             // Managers receive all provider updates with all routes.
-            notifyRoutesUpdatedToManagers(
-                    managers, new ArrayList<>(mLastNotifiedRoutesToPrivilegedRouters.values()));
+            List<MediaRoute2Info> routesForPrivilegedRouters =
+                    mLastNotifiedRoutesToPrivilegedRouters.values().stream().toList();
+            for (ManagerRecord manager : managers) {
+                manager.notifyRoutesUpdated(routesForPrivilegedRouters);
+            }
 
             // Routers with system routing access (either via {@link MODIFY_AUDIO_ROUTING} or
             // {@link BLUETOOTH_CONNECT} + {@link BLUETOOTH_SCAN}) receive all provider updates
             // with all routes.
             notifyRoutesUpdatedToRouterRecords(
-                    routerRecordsWithSystemRoutingPermission,
-                    new ArrayList<>(mLastNotifiedRoutesToPrivilegedRouters.values()));
+                    routerRecordsWithSystemRoutingPermission, routesForPrivilegedRouters);
 
             if (!isSystemProvider) {
                 // Regular routers receive updates from all non-system providers with all non-system
@@ -3068,8 +3117,10 @@
 
         private void onSessionInfoChangedOnHandler(@NonNull MediaRoute2Provider provider,
                 @NonNull RoutingSessionInfo sessionInfo) {
-            List<IMediaRouter2Manager> managers = getManagers();
-            notifySessionUpdatedToManagers(managers, sessionInfo);
+            List<ManagerRecord> managers = getManagerRecords();
+            for (ManagerRecord manager : managers) {
+                manager.notifySessionUpdated(sessionInfo);
+            }
 
             // For system provider, notify all routers.
             if (provider == mSystemProvider) {
@@ -3093,8 +3144,10 @@
 
         private void onSessionReleasedOnHandler(@NonNull MediaRoute2Provider provider,
                 @NonNull RoutingSessionInfo sessionInfo) {
-            List<IMediaRouter2Manager> managers = getManagers();
-            notifySessionReleasedToManagers(managers, sessionInfo);
+            List<ManagerRecord> managers = getManagerRecords();
+            for (ManagerRecord manager : managers) {
+                manager.notifySessionReleased(sessionInfo);
+            }
 
             RouterRecord routerRecord = mSessionToRouterMap.get(sessionInfo.getId());
             if (routerRecord == null) {
@@ -3169,20 +3222,6 @@
             return true;
         }
 
-        private List<IMediaRouter2Manager> getManagers() {
-            final List<IMediaRouter2Manager> managers = new ArrayList<>();
-            MediaRouter2ServiceImpl service = mServiceRef.get();
-            if (service == null) {
-                return managers;
-            }
-            synchronized (service.mLock) {
-                for (ManagerRecord managerRecord : mUserRecord.mManagerRecords) {
-                    managers.add(managerRecord.mManager);
-                }
-            }
-            return managers;
-        }
-
         private List<RouterRecord> getRouterRecords() {
             MediaRouter2ServiceImpl service = mServiceRef.get();
             if (service == null) {
@@ -3269,37 +3308,6 @@
             }
         }
 
-        /**
-         * Notifies {@code manager} with all known routes. This only happens once after {@code
-         * manager} is registered through {@link #registerManager(IMediaRouter2Manager, String)
-         * registerManager()}.
-         *
-         * @param manager {@link IMediaRouter2Manager} to be notified.
-         */
-        private void notifyInitialRoutesToManager(@NonNull IMediaRouter2Manager manager) {
-            if (mLastNotifiedRoutesToPrivilegedRouters.isEmpty()) {
-                return;
-            }
-            try {
-                manager.notifyRoutesUpdated(
-                        new ArrayList<>(mLastNotifiedRoutesToPrivilegedRouters.values()));
-            } catch (RemoteException ex) {
-                Slog.w(TAG, "Failed to notify all routes. Manager probably died.", ex);
-            }
-        }
-
-        private void notifyRoutesUpdatedToManagers(
-                @NonNull List<IMediaRouter2Manager> managers,
-                @NonNull List<MediaRoute2Info> routes) {
-            for (IMediaRouter2Manager manager : managers) {
-                try {
-                    manager.notifyRoutesUpdated(routes);
-                } catch (RemoteException ex) {
-                    Slog.w(TAG, "Failed to notify routes changed. Manager probably died.", ex);
-                }
-            }
-        }
-
         private void notifySessionCreatedToManagers(long managerRequestId,
                 @NonNull RoutingSessionInfo session) {
             int requesterId = toRequesterId(managerRequestId);
@@ -3317,32 +3325,6 @@
             }
         }
 
-        private void notifySessionUpdatedToManagers(
-                @NonNull List<IMediaRouter2Manager> managers,
-                @NonNull RoutingSessionInfo sessionInfo) {
-            for (IMediaRouter2Manager manager : managers) {
-                try {
-                    manager.notifySessionUpdated(sessionInfo);
-                } catch (RemoteException ex) {
-                    Slog.w(TAG, "notifySessionUpdatedToManagers: "
-                            + "Failed to notify. Manager probably died.", ex);
-                }
-            }
-        }
-
-        private void notifySessionReleasedToManagers(
-                @NonNull List<IMediaRouter2Manager> managers,
-                @NonNull RoutingSessionInfo sessionInfo) {
-            for (IMediaRouter2Manager manager : managers) {
-                try {
-                    manager.notifySessionReleased(sessionInfo);
-                } catch (RemoteException ex) {
-                    Slog.w(TAG, "notifySessionReleasedToManagers: "
-                            + "Failed to notify. Manager probably died.", ex);
-                }
-            }
-        }
-
         private void notifyDiscoveryPreferenceChangedToManager(@NonNull RouterRecord routerRecord,
                 @NonNull IMediaRouter2Manager manager) {
             try {
diff --git a/services/core/java/com/android/server/media/MediaSessionRecord.java b/services/core/java/com/android/server/media/MediaSessionRecord.java
index 0a9109b..d752429 100644
--- a/services/core/java/com/android/server/media/MediaSessionRecord.java
+++ b/services/core/java/com/android/server/media/MediaSessionRecord.java
@@ -16,7 +16,6 @@
 
 package com.android.server.media;
 
-import static android.media.MediaRoute2Info.PLAYBACK_VOLUME_FIXED;
 import static android.media.VolumeProvider.VOLUME_CONTROL_ABSOLUTE;
 import static android.media.VolumeProvider.VOLUME_CONTROL_FIXED;
 import static android.media.VolumeProvider.VOLUME_CONTROL_RELATIVE;
@@ -48,9 +47,7 @@
 import android.media.AudioManager;
 import android.media.AudioSystem;
 import android.media.MediaMetadata;
-import android.media.MediaRouter2Manager;
 import android.media.Rating;
-import android.media.RoutingSessionInfo;
 import android.media.VolumeProvider;
 import android.media.session.ISession;
 import android.media.session.ISessionCallback;
@@ -186,7 +183,6 @@
     private final MediaSessionService mService;
     private final UriGrantsManagerInternal mUgmInternal;
     private final Context mContext;
-    private final boolean mVolumeAdjustmentForRemoteGroupSessions;
 
     private final ForegroundServiceDelegationOptions mForegroundServiceDelegationOptions;
 
@@ -311,8 +307,6 @@
         mAudioAttrs = DEFAULT_ATTRIBUTES;
         mPolicies = policies;
         mUgmInternal = LocalServices.getService(UriGrantsManagerInternal.class);
-        mVolumeAdjustmentForRemoteGroupSessions = mContext.getResources().getBoolean(
-                com.android.internal.R.bool.config_volumeAdjustmentForRemoteGroupSessions);
 
         mForegroundServiceDelegationOptions = createForegroundServiceDelegationOptions();
 
@@ -659,49 +653,7 @@
             }
             return false;
         }
-        if (mVolumeAdjustmentForRemoteGroupSessions) {
-            if (DEBUG) {
-                Slog.d(
-                        TAG,
-                        "Volume adjustment for remote group sessions allowed so MediaSessionRecord"
-                                + " can handle volume key");
-            }
-            return true;
-        }
-        // See b/228021646 for details.
-        MediaRouter2Manager mRouter2Manager = MediaRouter2Manager.getInstance(mContext);
-        List<RoutingSessionInfo> sessions = mRouter2Manager.getRoutingSessions(mPackageName);
-        boolean foundNonSystemSession = false;
-        boolean remoteSessionAllowVolumeAdjustment = true;
-        if (DEBUG) {
-            Slog.d(
-                    TAG,
-                    "Found "
-                            + sessions.size()
-                            + " routing sessions for package name "
-                            + mPackageName);
-        }
-        for (RoutingSessionInfo session : sessions) {
-            if (DEBUG) {
-                Slog.d(TAG, "Found routingSessionInfo: " + session);
-            }
-            if (!session.isSystemSession()) {
-                foundNonSystemSession = true;
-                if (session.getVolumeHandling() == PLAYBACK_VOLUME_FIXED) {
-                    remoteSessionAllowVolumeAdjustment = false;
-                }
-            }
-        }
-        if (!foundNonSystemSession) {
-            if (DEBUG) {
-                Slog.d(
-                        TAG,
-                        "Package " + mPackageName
-                                + " has a remote media session but no associated routing session");
-            }
-        }
-
-        return foundNonSystemSession && remoteSessionAllowVolumeAdjustment;
+        return true;
     }
 
     @Override
diff --git a/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java b/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java
index 47f579d..e7e519e 100644
--- a/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java
+++ b/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java
@@ -42,6 +42,8 @@
 import android.app.IProcessObserver;
 import android.app.KeyguardManager;
 import android.app.compat.CompatChanges;
+import android.app.role.RoleManager;
+import android.companion.AssociationRequest;
 import android.compat.annotation.ChangeId;
 import android.compat.annotation.EnabledSince;
 import android.content.ComponentName;
@@ -94,7 +96,7 @@
 
 /**
  * Manages MediaProjection sessions.
- *
+ * <p>
  * The {@link MediaProjectionManagerService} manages the creation and lifetime of MediaProjections,
  * as well as the capabilities they grant. Any service using MediaProjection tokens as permission
  * grants <b>must</b> validate the token before use by calling {@link
@@ -137,6 +139,7 @@
     private final PackageManager mPackageManager;
     private final WindowManagerInternal mWmInternal;
     private final KeyguardManager mKeyguardManager;
+    private final RoleManager mRoleManager;
 
     private final MediaRouter mMediaRouter;
     private final MediaRouterCallback mMediaRouterCallback;
@@ -173,6 +176,7 @@
         mKeyguardManager = (KeyguardManager) mContext.getSystemService(Context.KEYGUARD_SERVICE);
         mKeyguardManager.addKeyguardLockedStateListener(
                 mContext.getMainExecutor(), this::onKeyguardLockedStateChanged);
+        mRoleManager = mContext.getSystemService(RoleManager.class);
         Watchdog.getInstance().addMonitor(this);
     }
 
@@ -182,6 +186,7 @@
      *   - be one of the bugreport allowlisted packages, or
      *   - hold the OP_PROJECT_MEDIA AppOp.
      */
+    @SuppressWarnings("BooleanMethodIsAlwaysInverted")
     private boolean canCaptureKeyguard() {
         if (!android.companion.virtualdevice.flags.Flags.mediaProjectionKeyguardRestrictions()) {
             return true;
@@ -193,6 +198,9 @@
             if (mPackageManager.checkPermission(RECORD_SENSITIVE_CONTENT,
                     mProjectionGrant.packageName)
                     == PackageManager.PERMISSION_GRANTED) {
+                Slog.v(TAG,
+                        "Allowing keyguard capture for package with RECORD_SENSITIVE_CONTENT "
+                                + "permission");
                 return true;
             }
             if (AppOpsManager.MODE_ALLOWED == mAppOps.noteOpNoThrow(AppOpsManager.OP_PROJECT_MEDIA,
@@ -200,6 +208,13 @@
                     "recording lockscreen")) {
                 // Some tools use media projection by granting the OP_PROJECT_MEDIA app
                 // op via a shell command. Those tools can be granted keyguard capture
+                Slog.v(TAG,
+                        "Allowing keyguard capture for package with OP_PROJECT_MEDIA AppOp ");
+                return true;
+            }
+            if (isProjectionAppHoldingAppStreamingRoleLocked()) {
+                Slog.v(TAG,
+                        "Allowing keyguard capture for package holding app streaming role.");
                 return true;
             }
             return SystemConfig.getInstance().getBugreportWhitelistedPackages()
@@ -698,6 +713,20 @@
         }
     }
 
+    /**
+     * Application holding the app streaming role
+     * ({@value AssociationRequest#DEVICE_PROFILE_APP_STREAMING}) are allowed to record the
+     * lockscreen.
+     *
+     * @return true if the is held by the recording application.
+     */
+    @GuardedBy("mLock")
+    private boolean isProjectionAppHoldingAppStreamingRoleLocked() {
+        return mRoleManager.getRoleHoldersAsUser(AssociationRequest.DEVICE_PROFILE_APP_STREAMING,
+                        mContext.getUser())
+                .contains(mProjectionGrant.packageName);
+    }
+
     private void dump(final PrintWriter pw) {
         pw.println("MEDIA PROJECTION MANAGER (dumpsys media_projection)");
         synchronized (mLock) {
diff --git a/services/core/java/com/android/server/notification/GroupHelper.java b/services/core/java/com/android/server/notification/GroupHelper.java
index 9d30c56..e8d14cb 100644
--- a/services/core/java/com/android/server/notification/GroupHelper.java
+++ b/services/core/java/com/android/server/notification/GroupHelper.java
@@ -1435,6 +1435,10 @@
                 return false;
             }
 
+            if (record.getSbn().getNotification().isMediaNotification()) {
+                return false;
+            }
+
             return true;
         }
     }
diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java
index 655f2e4..56e0a89 100644
--- a/services/core/java/com/android/server/notification/NotificationManagerService.java
+++ b/services/core/java/com/android/server/notification/NotificationManagerService.java
@@ -4117,6 +4117,34 @@
         }
 
         @Override
+        @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+        public void setAdjustmentTypeSupportedState(INotificationListener token,
+                @Adjustment.Keys String key, boolean supported) {
+            final long identity = Binder.clearCallingIdentity();
+            try {
+                synchronized (mNotificationLock) {
+                    final ManagedServiceInfo info = mAssistants.checkServiceTokenLocked(token);
+                    if (key == null) {
+                        return;
+                    }
+                    mAssistants.setAdjustmentTypeSupportedState(info,  key, supported);
+                }
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
+
+        @Override
+        @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+        public @NonNull List<String> getUnsupportedAdjustmentTypes() {
+            checkCallerIsSystemOrSystemUiOrShell();
+            synchronized (mNotificationLock) {
+                return new ArrayList(mAssistants.mNasUnsupported.getOrDefault(
+                        UserHandle.getUserId(Binder.getCallingUid()), new HashSet<>()));
+            }
+        }
+
+        @Override
         @FlaggedApi(android.app.Flags.FLAG_API_RICH_ONGOING)
         public boolean appCanBePromoted(String pkg, int uid) {
             checkCallerIsSystemOrSystemUiOrShell();
@@ -7435,7 +7463,7 @@
                 NotificationRecord r = findNotificationLocked(pkg, null, notificationId, userId);
                 if (r != null) {
                     if (DBG) {
-                        final String type = (flag ==  FLAG_FOREGROUND_SERVICE) ? "FGS" : "UIJ";
+                        final String type = (flag == FLAG_FOREGROUND_SERVICE) ? "FGS" : "UIJ";
                         Slog.d(TAG, "Remove " + type + " flag not allow. "
                                 + "Cancel " + type + " notification");
                     }
@@ -7452,7 +7480,11 @@
                         // strip flag from all enqueued notifications. listeners will be informed
                         // in post runnable.
                         StatusBarNotification sbn = r.getSbn();
-                        sbn.getNotification().flags = (r.mOriginalFlags & ~flag);
+                        if (notificationForceGrouping()) {
+                            sbn.getNotification().flags = (r.getFlags() & ~flag);
+                        } else {
+                            sbn.getNotification().flags = (r.mOriginalFlags & ~flag);
+                        }
                     }
                 }
 
@@ -7461,7 +7493,11 @@
                 if (r != null) {
                     // if posted notification exists, strip its flag and tell listeners
                     StatusBarNotification sbn = r.getSbn();
-                    sbn.getNotification().flags = (r.mOriginalFlags & ~flag);
+                    if (notificationForceGrouping()) {
+                        sbn.getNotification().flags = (r.getFlags() & ~flag);
+                    } else {
+                        sbn.getNotification().flags = (r.mOriginalFlags & ~flag);
+                    }
                     mRankingHelper.sort(mNotificationList);
                     mListeners.notifyPostedLocked(r, r);
                 }
@@ -9459,6 +9495,28 @@
     }
 
     /**
+     *  Check if the notification was a summary that has been auto-grouped
+     * @param r the current notification record
+     * @param old the previous notification record
+     * @return true if the notification record was a summary that was auto-grouped
+     */
+    @GuardedBy("mNotificationLock")
+    private boolean wasSummaryAutogrouped(NotificationRecord r, NotificationRecord old) {
+        boolean wasAutogrouped = false;
+        if (old != null) {
+            boolean wasSummary = (old.mOriginalFlags & FLAG_GROUP_SUMMARY) != 0;
+            boolean wasForcedGrouped = (old.getFlags() & FLAG_GROUP_SUMMARY) == 0
+                    && old.getSbn().getOverrideGroupKey() != null;
+            boolean isNotAutogroupSummary = (r.getFlags() & FLAG_AUTOGROUP_SUMMARY) == 0
+                    && (r.getFlags() & FLAG_GROUP_SUMMARY) != 0;
+            if ((wasSummary && wasForcedGrouped) || (wasForcedGrouped && isNotAutogroupSummary)) {
+                wasAutogrouped = true;
+            }
+        }
+        return wasAutogrouped;
+    }
+
+    /**
      * Ensures that grouped notification receive their special treatment.
      *
      * <p>Cancels group children if the new notification causes a group to lose
@@ -9478,14 +9536,9 @@
         }
 
         if (notificationForceGrouping()) {
-            if (old != null) {
-                // If this is an update to a summary that was forced grouped => remove summary flag
-                boolean wasSummary = (old.mOriginalFlags & FLAG_GROUP_SUMMARY) != 0;
-                boolean wasForcedGrouped = (old.getFlags() & FLAG_GROUP_SUMMARY) == 0
-                        && old.getSbn().getOverrideGroupKey() != null;
-                if (n.isGroupSummary() && wasSummary && wasForcedGrouped) {
-                    n.flags &= ~FLAG_GROUP_SUMMARY;
-                }
+            // If this is an update to a summary that was forced grouped => remove summary flag
+            if (wasSummaryAutogrouped(r, old)) {
+                n.flags &= ~FLAG_GROUP_SUMMARY;
             }
         }
 
@@ -11333,12 +11386,16 @@
         static final String TAG_ENABLED_NOTIFICATION_ASSISTANTS = "enabled_assistants";
 
         private static final String ATT_TYPES = "types";
+        private static final String ATT_NAS_UNSUPPORTED = "unsupported_adjustments";
 
         private final Object mLock = new Object();
 
         @GuardedBy("mLock")
         private Set<String> mAllowedAdjustments = new ArraySet<>();
 
+        @GuardedBy("mLock")
+        private Map<Integer, HashSet<String>> mNasUnsupported = new ArrayMap<>();
+
         protected ComponentName mDefaultFromConfig = null;
 
         @Override
@@ -11831,6 +11888,10 @@
                     setNotificationAssistantAccessGrantedForUserInternal(
                             currentComponent, userId, false, userSet);
                 }
+            } else {
+                if (android.service.notification.Flags.notificationClassification()) {
+                    mNasUnsupported.put(userId, new HashSet<>());
+                }
             }
             super.setPackageOrComponentEnabled(pkgOrComponent, userId, isPrimary, enabled, userSet);
         }
@@ -11838,6 +11899,63 @@
         private boolean isVerboseLogEnabled() {
             return Log.isLoggable("notification_assistant", Log.VERBOSE);
         }
+
+        @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+        @GuardedBy("mNotificationLock")
+        public void setAdjustmentTypeSupportedState(ManagedServiceInfo info,
+                @Adjustment.Keys String key, boolean supported) {
+            if (!android.service.notification.Flags.notificationClassification()) {
+                return;
+            }
+            HashSet<String> disabledAdjustments =
+                    mNasUnsupported.getOrDefault(info.userid, new HashSet<>());
+            if (supported) {
+                disabledAdjustments.remove(key);
+            } else {
+                disabledAdjustments.add(key);
+            }
+            mNasUnsupported.put(info.userid, disabledAdjustments);
+            handleSavePolicyFile();
+        }
+
+        @FlaggedApi(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+        @GuardedBy("mNotificationLock")
+        public @NonNull Set<String> getUnsupportedAdjustments(@UserIdInt int userId) {
+            if (!android.service.notification.Flags.notificationClassification()) {
+                return new HashSet<>();
+            }
+            return mNasUnsupported.getOrDefault(userId, new HashSet<>());
+        }
+
+        @Override
+        protected void writeExtraAttributes(TypedXmlSerializer out, @UserIdInt int approvedUserId)
+                throws IOException {
+            if (!android.service.notification.Flags.notificationClassification()) {
+                return;
+            }
+            synchronized (mLock) {
+                if (mNasUnsupported.containsKey(approvedUserId)) {
+                    out.attribute(null, ATT_NAS_UNSUPPORTED,
+                            TextUtils.join(",", mNasUnsupported.get(approvedUserId)));
+                }
+            }
+        }
+
+        @Override
+        protected void readExtraAttributes(String tag, TypedXmlPullParser parser,
+                @UserIdInt int approvedUserId) throws IOException {
+            if (!android.service.notification.Flags.notificationClassification()) {
+                return;
+            }
+            if (ManagedServices.TAG_MANAGED_SERVICES.equals(tag)) {
+                final String types = XmlUtils.readStringAttribute(parser, ATT_NAS_UNSUPPORTED);
+                synchronized (mLock) {
+                    if (!TextUtils.isEmpty(types)) {
+                        mNasUnsupported.put(approvedUserId, new HashSet(List.of(types.split(","))));
+                    }
+                }
+            }
+        }
     }
 
     /**
diff --git a/services/core/java/com/android/server/notification/ZenModeFiltering.java b/services/core/java/com/android/server/notification/ZenModeFiltering.java
index ff263d1..bdca555 100644
--- a/services/core/java/com/android/server/notification/ZenModeFiltering.java
+++ b/services/core/java/com/android/server/notification/ZenModeFiltering.java
@@ -37,7 +37,6 @@
 import android.util.ArraySet;
 import android.util.Slog;
 
-import com.android.internal.messages.nano.SystemMessageProto;
 import com.android.internal.util.NotificationMessagingUtil;
 
 import java.io.PrintWriter;
@@ -173,13 +172,6 @@
             maybeLogInterceptDecision(record, false, "criticalNotification");
             return false;
         }
-        // Make an exception to policy for the notification saying that policy has changed
-        if (NotificationManager.Policy.areAllVisualEffectsSuppressed(policy.suppressedVisualEffects)
-                && "android".equals(record.getSbn().getPackageName())
-                && SystemMessageProto.SystemMessage.NOTE_ZEN_UPGRADE == record.getSbn().getId()) {
-            maybeLogInterceptDecision(record, false, "systemDndChangedNotification");
-            return false;
-        }
         switch (zen) {
             case Global.ZEN_MODE_NO_INTERRUPTIONS:
                 // #notevenalarms
diff --git a/services/core/java/com/android/server/notification/ZenModeHelper.java b/services/core/java/com/android/server/notification/ZenModeHelper.java
index 626c3dd..ea211a9 100644
--- a/services/core/java/com/android/server/notification/ZenModeHelper.java
+++ b/services/core/java/com/android/server/notification/ZenModeHelper.java
@@ -54,10 +54,8 @@
 import android.app.AppOpsManager;
 import android.app.AutomaticZenRule;
 import android.app.Flags;
-import android.app.Notification;
 import android.app.NotificationManager;
 import android.app.NotificationManager.Policy;
-import android.app.PendingIntent;
 import android.app.compat.CompatChanges;
 import android.compat.annotation.ChangeId;
 import android.compat.annotation.EnabledSince;
@@ -74,7 +72,6 @@
 import android.content.res.Resources;
 import android.content.res.XmlResourceParser;
 import android.database.ContentObserver;
-import android.graphics.drawable.Icon;
 import android.media.AudioAttributes;
 import android.media.AudioManager;
 import android.media.AudioManagerInternal;
@@ -90,7 +87,6 @@
 import android.os.Process;
 import android.os.SystemClock;
 import android.os.UserHandle;
-import android.provider.Settings;
 import android.provider.Settings.Global;
 import android.service.notification.Condition;
 import android.service.notification.ConditionProviderService;
@@ -117,8 +113,6 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags;
 import com.android.internal.logging.MetricsLogger;
-import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
-import com.android.internal.notification.SystemNotificationChannels;
 import com.android.internal.util.FrameworkStatsLog;
 import com.android.internal.util.XmlUtils;
 import com.android.modules.utils.TypedXmlPullParser;
@@ -309,7 +303,6 @@
         mHandler.postMetricsTimer();
         cleanUpZenRules();
         mIsSystemServicesReady = true;
-        showZenUpgradeNotification(mZenMode);
     }
 
     /**
@@ -485,7 +478,7 @@
             populateZenRule(pkg, automaticZenRule, rule, origin, /* isNew= */ true);
             rule = maybeRestoreRemovedRule(newConfig, pkg, rule, automaticZenRule, origin);
             newConfig.automaticRules.put(rule.id, rule);
-            maybeReplaceDefaultRule(newConfig, automaticZenRule);
+            maybeReplaceDefaultRule(newConfig, null, automaticZenRule);
 
             if (setConfigLocked(newConfig, origin, reason, rule.component, true, callingUid)) {
                 return rule.id;
@@ -535,13 +528,24 @@
         return ruleToRestore;
     }
 
-    private static void maybeReplaceDefaultRule(ZenModeConfig config, AutomaticZenRule addedRule) {
+    /**
+     * Possibly delete built-in rules if a more suitable rule is added or updated.
+     *
+     * <p>Today, this is done in one case: delete a disabled "Sleeping" rule if a Bedtime Mode is
+     * added (or an existing mode is turned into {@link AutomaticZenRule#TYPE_BEDTIME}, when
+     * upgrading). Because only the {@code config_systemWellbeing} package is allowed to use rules
+     * of this type, this will not trigger wantonly.
+     *
+     * @param oldRule If non-null, {@code rule} is updating {@code oldRule}. Otherwise,
+     *                {@code rule} is being added.
+     */
+    private static void maybeReplaceDefaultRule(ZenModeConfig config, @Nullable ZenRule oldRule,
+            AutomaticZenRule rule) {
         if (!Flags.modesApi()) {
             return;
         }
-        if (addedRule.getType() == AutomaticZenRule.TYPE_BEDTIME) {
-            // Delete a built-in disabled "Sleeping" rule when a BEDTIME rule is added; it may have
-            // smarter triggers and it will prevent confusion about which one to use.
+        if (rule.getType() == AutomaticZenRule.TYPE_BEDTIME
+                && (oldRule == null || oldRule.type != rule.getType())) {
             // Note: we must not verify canManageAutomaticZenRule here, since most likely they
             // won't have the same owner (sleeping - system; bedtime - DWB).
             ZenRule sleepingRule = config.automaticRules.get(
@@ -589,6 +593,10 @@
                 // condition) when no changes happen.
                 return true;
             }
+
+            if (Flags.modesUi()) {
+                maybeReplaceDefaultRule(newConfig, oldRule, automaticZenRule);
+            }
             return setConfigLocked(newConfig, origin, reason,
                     newRule.component, true, callingUid);
         }
@@ -1584,8 +1592,6 @@
             String reason, @Nullable String caller, int callingUid) {
         setManualZenMode(zenMode, conditionId, origin, reason, caller, true /*setRingerMode*/,
                 callingUid);
-        Settings.Secure.putInt(mContext.getContentResolver(),
-                Settings.Secure.SHOW_ZEN_SETTINGS_SUGGESTION, 0);
     }
 
     private void setManualZenMode(int zenMode, Uri conditionId, @ConfigOrigin int origin,
@@ -1783,17 +1789,6 @@
                 SystemZenRules.maybeUpgradeRules(mContext, config);
             }
 
-            // Resolve user id for settings.
-            userId = userId == UserHandle.USER_ALL ? UserHandle.USER_SYSTEM : userId;
-            if (config.version < ZenModeConfig.XML_VERSION_ZEN_UPGRADE) {
-                Settings.Secure.putIntForUser(mContext.getContentResolver(),
-                        Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 1, userId);
-            } else {
-                // devices not restoring/upgrading already have updated zen settings
-                Settings.Secure.putIntForUser(mContext.getContentResolver(),
-                        Settings.Secure.ZEN_SETTINGS_UPDATED, 1, userId);
-            }
-
             if (Flags.modesApi() && forRestore) {
                 // Note: forBackup doesn't write deletedRules, but just in case.
                 config.deletedRules.clear();
@@ -2062,7 +2057,6 @@
         Global.putInt(mContext.getContentResolver(), Global.ZEN_MODE, zen);
         ZenLog.traceSetZenMode(Global.getInt(mContext.getContentResolver(), Global.ZEN_MODE, -1),
                 "updated setting");
-        showZenUpgradeNotification(zen);
     }
 
     private int getPreviousRingerModeSetting() {
@@ -2117,12 +2111,6 @@
             for (ZenRule automaticRule : mConfig.automaticRules.values()) {
                 if (automaticRule.isActive()) {
                     if (zenSeverity(automaticRule.zenMode) > zenSeverity(zen)) {
-                        // automatic rule triggered dnd and user hasn't seen update dnd dialog
-                        if (Settings.Secure.getInt(mContext.getContentResolver(),
-                                Settings.Secure.ZEN_SETTINGS_SUGGESTION_VIEWED, 1) == 0) {
-                            Settings.Secure.putInt(mContext.getContentResolver(),
-                                    Settings.Secure.SHOW_ZEN_SETTINGS_SUGGESTION, 1);
-                        }
                         zen = automaticRule.zenMode;
                     }
                 }
@@ -2702,62 +2690,6 @@
         }
     }
 
-    private void showZenUpgradeNotification(int zen) {
-        final boolean isWatch = mContext.getPackageManager().hasSystemFeature(
-            PackageManager.FEATURE_WATCH);
-        final boolean showNotification = mIsSystemServicesReady
-                && zen != Global.ZEN_MODE_OFF
-                && !isWatch
-                && Settings.Secure.getInt(mContext.getContentResolver(),
-                Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0) != 0
-                && Settings.Secure.getInt(mContext.getContentResolver(),
-                Settings.Secure.ZEN_SETTINGS_UPDATED, 0) != 1;
-
-        if (isWatch) {
-            Settings.Secure.putInt(mContext.getContentResolver(),
-                    Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0);
-        }
-
-        if (showNotification) {
-            mNotificationManager.notify(TAG, SystemMessage.NOTE_ZEN_UPGRADE,
-                    createZenUpgradeNotification());
-            Settings.Secure.putInt(mContext.getContentResolver(),
-                    Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0);
-        }
-    }
-
-    @VisibleForTesting
-    protected Notification createZenUpgradeNotification() {
-        final Bundle extras = new Bundle();
-        extras.putString(Notification.EXTRA_SUBSTITUTE_APP_NAME,
-                mContext.getResources().getString(R.string.global_action_settings));
-        int title = R.string.zen_upgrade_notification_title;
-        int content = R.string.zen_upgrade_notification_content;
-        int drawable = R.drawable.ic_zen_24dp;
-        if (NotificationManager.Policy.areAllVisualEffectsSuppressed(
-                getConsolidatedNotificationPolicy().suppressedVisualEffects)) {
-            title = R.string.zen_upgrade_notification_visd_title;
-            content = R.string.zen_upgrade_notification_visd_content;
-            drawable = R.drawable.ic_dnd_block_notifications;
-        }
-
-        Intent onboardingIntent = new Intent(Settings.ZEN_MODE_ONBOARDING);
-        onboardingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
-        return new Notification.Builder(mContext, SystemNotificationChannels.DO_NOT_DISTURB)
-                .setAutoCancel(true)
-                .setSmallIcon(R.drawable.ic_settings_24dp)
-                .setLargeIcon(Icon.createWithResource(mContext, drawable))
-                .setContentTitle(mContext.getResources().getString(title))
-                .setContentText(mContext.getResources().getString(content))
-                .setContentIntent(PendingIntent.getActivity(mContext, 0, onboardingIntent,
-                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE))
-                .setAutoCancel(true)
-                .setLocalOnly(true)
-                .addExtras(extras)
-                .setStyle(new Notification.BigTextStyle())
-                .build();
-    }
-
     private int drawableResNameToResId(String packageName, String resourceName) {
         if (TextUtils.isEmpty(resourceName)) {
             return 0;
diff --git a/services/core/java/com/android/server/pm/ComputerEngine.java b/services/core/java/com/android/server/pm/ComputerEngine.java
index 89ced12..4665a72 100644
--- a/services/core/java/com/android/server/pm/ComputerEngine.java
+++ b/services/core/java/com/android/server/pm/ComputerEngine.java
@@ -2208,10 +2208,10 @@
             return true;
         }
         boolean permissionGranted = requireFullPermission ? hasPermission(
-                Manifest.permission.INTERACT_ACROSS_USERS_FULL, callingUid)
+                Manifest.permission.INTERACT_ACROSS_USERS_FULL)
                 : (hasPermission(
-                        android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, callingUid)
-                        || hasPermission(Manifest.permission.INTERACT_ACROSS_USERS, callingUid));
+                        android.Manifest.permission.INTERACT_ACROSS_USERS_FULL)
+                        || hasPermission(Manifest.permission.INTERACT_ACROSS_USERS));
         if (!permissionGranted) {
             if (Process.isIsolatedUid(callingUid) && isKnownIsolatedComputeApp(callingUid)) {
                 return checkIsolatedOwnerHasPermission(callingUid, requireFullPermission);
diff --git a/services/core/java/com/android/server/pm/DexOptHelper.java b/services/core/java/com/android/server/pm/DexOptHelper.java
index 1569fa0..02afdd6 100644
--- a/services/core/java/com/android/server/pm/DexOptHelper.java
+++ b/services/core/java/com/android/server/pm/DexOptHelper.java
@@ -80,6 +80,7 @@
 import com.android.server.pm.PackageDexOptimizer.DexOptResult;
 import com.android.server.pm.dex.DexManager;
 import com.android.server.pm.dex.DexoptOptions;
+import com.android.server.pm.local.PackageManagerLocalImpl;
 import com.android.server.pm.pkg.AndroidPackage;
 import com.android.server.pm.pkg.PackageState;
 import com.android.server.pm.pkg.PackageStateInternal;
@@ -819,10 +820,16 @@
         final PackageSetting ps = installRequest.getScannedPackageSetting();
         final String packageName = ps.getPackageName();
 
+        PackageSetting uncommittedPs = null;
+        if (Flags.improveInstallFreeze()) {
+            uncommittedPs = ps;
+        }
+
         PackageManagerLocal packageManagerLocal =
                 LocalManagerRegistry.getManager(PackageManagerLocal.class);
         try (PackageManagerLocal.FilteredSnapshot snapshot =
-                     packageManagerLocal.withFilteredSnapshot()) {
+                     PackageManagerLocalImpl.withFilteredSnapshot(packageManagerLocal,
+                uncommittedPs)) {
             boolean ignoreDexoptProfile =
                     (installRequest.getInstallFlags()
                             & PackageManager.INSTALL_IGNORE_DEXOPT_PROFILE)
diff --git a/services/core/java/com/android/server/pm/InstallPackageHelper.java b/services/core/java/com/android/server/pm/InstallPackageHelper.java
index aca65bf..83292b7 100644
--- a/services/core/java/com/android/server/pm/InstallPackageHelper.java
+++ b/services/core/java/com/android/server/pm/InstallPackageHelper.java
@@ -148,6 +148,7 @@
 import android.util.ArraySet;
 import android.util.EventLog;
 import android.util.ExceptionUtils;
+import android.util.IntArray;
 import android.util.Log;
 import android.util.Pair;
 import android.util.Slog;
@@ -1014,13 +1015,17 @@
         final Map<String, Settings.VersionInfo> versionInfos = new ArrayMap<>(requests.size());
         try {
             CriticalEventLog.getInstance().logInstallPackagesStarted();
-
             if (prepareInstallPackages(requests)
                     && scanInstallPackages(requests, createdAppId, versionInfos)) {
                 List<ReconciledPackage> reconciledPackages =
                         reconcileInstallPackages(requests, versionInfos);
-                if (reconciledPackages != null
-                        && renameAndUpdatePaths(requests)
+                if (reconciledPackages == null) {
+                    return;
+                }
+                if (Flags.improveInstallFreeze()) {
+                    prepPerformDexoptIfNeeded(reconciledPackages);
+                }
+                if (renameAndUpdatePaths(requests)
                         && commitInstallPackages(reconciledPackages)) {
                     success = true;
                 }
@@ -1031,6 +1036,75 @@
         }
     }
 
+    private int[] getNewUsers(InstallRequest installRequest, int[] allUsers)
+            throws PackageManagerException {
+        final int userId = installRequest.getUserId();
+        if (userId != UserHandle.USER_ALL && userId != UserHandle.USER_CURRENT
+                && !mPm.mUserManager.exists(userId)) {
+            throw new PackageManagerException(PackageManagerException.INTERNAL_ERROR_MISSING_USER,
+                    "User " + userId + " doesn't exist or has been removed");
+        }
+
+        final IntArray newUserIds = new IntArray();
+        if (userId != UserHandle.USER_ALL) {
+            newUserIds.add(userId);
+        } else if (allUsers != null) {
+            final int[] installedForUsers = installRequest.getOriginUsers();
+            for (int currentUserId : allUsers) {
+                final boolean installedForCurrentUser = ArrayUtils.contains(
+                        installedForUsers, currentUserId);
+                final boolean restrictedByPolicy =
+                        mPm.isUserRestricted(currentUserId,
+                                UserManager.DISALLOW_INSTALL_APPS)
+                                || mPm.isUserRestricted(currentUserId,
+                                UserManager.DISALLOW_DEBUGGING_FEATURES);
+                if (installedForCurrentUser || !restrictedByPolicy) {
+                    newUserIds.add(currentUserId);
+                }
+            }
+        }
+
+        if (newUserIds.size() == 0) {
+            throw new PackageManagerException(PackageManagerException.INTERNAL_ERROR_MISSING_USER,
+                    "User " + userId + " doesn't exist or has been removed");
+        } else {
+            return newUserIds.toArray();
+        }
+    }
+
+    private void prepPerformDexoptIfNeeded(List<ReconciledPackage> reconciledPackages) {
+        for (ReconciledPackage reconciledPkg : reconciledPackages) {
+            final InstallRequest request = reconciledPkg.mInstallRequest;
+            // prepare profiles
+            final PackageSetting ps = request.getScannedPackageSetting();
+            final PackageSetting oldPkgSetting = request.getScanRequestOldPackageSetting();
+            final int[] allUsers = mPm.mUserManager.getUserIds();
+            if (reconciledPkg.mCollectedSharedLibraryInfos != null
+                    || (oldPkgSetting != null
+                    && !oldPkgSetting.getSharedLibraryDependencies().isEmpty())) {
+                // Reconcile if the new package or the old package uses shared libraries.
+                // It is possible that the old package uses shared libraries but the new
+                // one doesn't.
+                mSharedLibraries.executeSharedLibrariesUpdate(request.getParsedPackage(), ps,
+                        null, null, reconciledPkg.mCollectedSharedLibraryInfos, allUsers);
+            }
+            try (PackageManagerTracedLock installLock = mPm.mInstallLock.acquireLock()) {
+                final int[] newUsers = getNewUsers(request, allUsers);
+                // Hardcode previousAppId to 0 to disable any data migration (http://b/221088088)
+                mAppDataHelper.prepareAppDataPostCommitLIF(ps, 0, newUsers);
+                if (request.isClearCodeCache()) {
+                    mAppDataHelper.clearAppDataLIF(ps.getPkg(), UserHandle.USER_ALL,
+                            FLAG_STORAGE_DE | FLAG_STORAGE_CE | FLAG_STORAGE_EXTERNAL
+                                    | Installer.FLAG_CLEAR_CODE_CACHE_ONLY);
+                }
+            } catch (PackageManagerException e) {
+                request.setError(e.error, e.getMessage());
+                return;
+            }
+            DexOptHelper.performDexoptIfNeeded(request, mDexManager, mContext, null);
+        }
+    }
+
     private boolean renameAndUpdatePaths(List<InstallRequest> requests) {
         try (PackageManagerTracedLock installLock = mPm.mInstallLock.acquireLock()) {
             for (InstallRequest request : requests) {
@@ -2655,20 +2729,22 @@
                 incrementalStorages.add(storage);
             }
 
-            // Hardcode previousAppId to 0 to disable any data migration (http://b/221088088)
-            mAppDataHelper.prepareAppDataPostCommitLIF(ps, 0, installRequest.getNewUsers());
-            if (installRequest.isClearCodeCache()) {
-                mAppDataHelper.clearAppDataLIF(ps.getPkg(), UserHandle.USER_ALL,
-                        FLAG_STORAGE_DE | FLAG_STORAGE_CE | FLAG_STORAGE_EXTERNAL
-                                | Installer.FLAG_CLEAR_CODE_CACHE_ONLY);
-            }
             if (installRequest.isInstallReplace() && pkg != null) {
                 mDexManager.notifyPackageUpdated(packageName,
                         pkg.getBaseApkPath(), pkg.getSplitCodePaths());
             }
+            if (!Flags.improveInstallFreeze()) {
+                // Hardcode previousAppId to 0 to disable any data migration (http://b/221088088)
+                mAppDataHelper.prepareAppDataPostCommitLIF(ps, 0, installRequest.getNewUsers());
+                if (installRequest.isClearCodeCache()) {
+                    mAppDataHelper.clearAppDataLIF(ps.getPkg(), UserHandle.USER_ALL,
+                            FLAG_STORAGE_DE | FLAG_STORAGE_CE | FLAG_STORAGE_EXTERNAL
+                                    | Installer.FLAG_CLEAR_CODE_CACHE_ONLY);
+                }
 
-            DexOptHelper.performDexoptIfNeeded(installRequest, mDexManager, mContext,
-                    mPm.mInstallLock.getRawLock());
+                DexOptHelper.performDexoptIfNeeded(installRequest, mDexManager, mContext,
+                        mPm.mInstallLock.getRawLock());
+            }
         }
         PackageManagerServiceUtils.waitForNativeBinariesExtractionForIncremental(
                 incrementalStorages);
diff --git a/services/core/java/com/android/server/pm/LauncherAppsService.java b/services/core/java/com/android/server/pm/LauncherAppsService.java
index 8657de2..5653da0 100644
--- a/services/core/java/com/android/server/pm/LauncherAppsService.java
+++ b/services/core/java/com/android/server/pm/LauncherAppsService.java
@@ -716,7 +716,7 @@
                     visiblePackages.add(info.getActivityInfo().packageName);
                 }
                 final List<ApplicationInfo> installedPackages =
-                        mPackageManagerInternal.getInstalledApplicationsCrossUser(
+                        mPackageManagerInternal.getInstalledApplications(
                                 /* flags= */ 0, user.getIdentifier(), callingUid);
                 for (ApplicationInfo applicationInfo : installedPackages) {
                     if (!visiblePackages.contains(applicationInfo.packageName)) {
diff --git a/services/core/java/com/android/server/pm/PackageMonitorCallbackHelper.java b/services/core/java/com/android/server/pm/PackageMonitorCallbackHelper.java
index cf5de89..a28e3c1 100644
--- a/services/core/java/com/android/server/pm/PackageMonitorCallbackHelper.java
+++ b/services/core/java/com/android/server/pm/PackageMonitorCallbackHelper.java
@@ -244,7 +244,7 @@
                 return;
             }
             int registerUid = registerUser.getUid();
-            if (allowUids != null && registerUid != Process.SYSTEM_UID
+            if (allowUids != null && !UserHandle.isSameApp(registerUid, Process.SYSTEM_UID)
                     && !ArrayUtils.contains(allowUids, registerUid)) {
                 if (DEBUG) {
                     Slog.w(TAG, "Skip invoke PackageMonitorCallback for " + intent.getAction()
diff --git a/services/core/java/com/android/server/pm/TEST_MAPPING b/services/core/java/com/android/server/pm/TEST_MAPPING
index 21a6df2..94b49e5 100644
--- a/services/core/java/com/android/server/pm/TEST_MAPPING
+++ b/services/core/java/com/android/server/pm/TEST_MAPPING
@@ -101,6 +101,22 @@
       ]
     },
     {
+      "name": "CtsPackageInstallerCUJDeviceAdminTestCases",
+      "file_patterns": [
+        "core/java/.*Install.*",
+        "services/core/.*Install.*",
+        "services/core/java/com/android/server/pm/.*"
+      ],
+      "options":[
+          {
+              "exclude-annotation":"androidx.test.filters.FlakyTest"
+          },
+          {
+              "exclude-annotation":"org.junit.Ignore"
+          }
+      ]
+    },
+    {
       "name": "CtsPackageInstallerCUJInstallationTestCases",
       "file_patterns": [
         "core/java/.*Install.*",
@@ -117,6 +133,22 @@
       ]
     },
     {
+      "name": "CtsPackageInstallerCUJMultiUsersTestCases",
+      "file_patterns": [
+        "core/java/.*Install.*",
+        "services/core/.*Install.*",
+        "services/core/java/com/android/server/pm/.*"
+      ],
+      "options":[
+          {
+              "exclude-annotation":"androidx.test.filters.FlakyTest"
+          },
+          {
+              "exclude-annotation":"org.junit.Ignore"
+          }
+      ]
+    },
+    {
       "name": "CtsPackageInstallerCUJUninstallationTestCases",
       "file_patterns": [
         "core/java/.*Install.*",
diff --git a/services/core/java/com/android/server/pm/local/PackageManagerLocalImpl.java b/services/core/java/com/android/server/pm/local/PackageManagerLocalImpl.java
index 55afb17..c22e382 100644
--- a/services/core/java/com/android/server/pm/local/PackageManagerLocalImpl.java
+++ b/services/core/java/com/android/server/pm/local/PackageManagerLocalImpl.java
@@ -31,6 +31,7 @@
 import com.android.server.pm.PackageManagerLocal;
 import com.android.server.pm.PackageManagerService;
 import com.android.server.pm.pkg.PackageState;
+import com.android.server.pm.pkg.PackageStateInternal;
 import com.android.server.pm.pkg.SharedUserApi;
 import com.android.server.pm.snapshot.PackageDataSnapshot;
 
@@ -71,8 +72,26 @@
     @NonNull
     @Override
     public FilteredSnapshotImpl withFilteredSnapshot(int callingUid, @NonNull UserHandle user) {
+        return withFilteredSnapshot(callingUid, user, /* uncommittedPs= */ null);
+    }
+
+    /**
+     * Creates a {@link FilteredSnapshot} with a uncommitted {@link PackageState} that is used for
+     * dexopt in the art service to get the correct package state before the package is committed.
+     */
+    @NonNull
+    public static FilteredSnapshotImpl withFilteredSnapshot(PackageManagerLocal pm,
+            @NonNull PackageState uncommittedPs) {
+        return ((PackageManagerLocalImpl) pm).withFilteredSnapshot(Binder.getCallingUid(),
+                Binder.getCallingUserHandle(), uncommittedPs);
+    }
+
+    @NonNull
+    private FilteredSnapshotImpl withFilteredSnapshot(int callingUid, @NonNull UserHandle user,
+            @Nullable PackageState uncommittedPs) {
         return new FilteredSnapshotImpl(callingUid, user,
-                mService.snapshotComputer(false /*allowLiveComputer*/), null);
+                mService.snapshotComputer(/* allowLiveComputer= */ false),
+                /* parentSnapshot= */ null, uncommittedPs);
     }
 
     @Override
@@ -145,7 +164,8 @@
 
         @Override
         public FilteredSnapshot filtered(int callingUid, @NonNull UserHandle user) {
-            return new FilteredSnapshotImpl(callingUid, user, mSnapshot, this);
+            return new FilteredSnapshotImpl(callingUid, user, mSnapshot, this,
+                    /* uncommittedPs= */ null);
         }
 
         @SuppressWarnings("RedundantSuppression")
@@ -209,13 +229,18 @@
         @Nullable
         private final UnfilteredSnapshotImpl mParentSnapshot;
 
+        @Nullable
+        private final PackageState mUncommitPackageState;
+
         private FilteredSnapshotImpl(int callingUid, @NonNull UserHandle user,
                 @NonNull PackageDataSnapshot snapshot,
-                @Nullable UnfilteredSnapshotImpl parentSnapshot) {
+                @Nullable UnfilteredSnapshotImpl parentSnapshot,
+                @Nullable PackageState uncommittedPs) {
             super(snapshot);
             mCallingUid = callingUid;
             mUserId = user.getIdentifier();
             mParentSnapshot = parentSnapshot;
+            mUncommitPackageState = uncommittedPs;
         }
 
         @Override
@@ -237,6 +262,10 @@
         @Override
         public PackageState getPackageState(@NonNull String packageName) {
             checkClosed();
+            if (mUncommitPackageState != null
+                    && packageName.equals(mUncommitPackageState.getPackageName())) {
+                return mUncommitPackageState;
+            }
             return mSnapshot.getPackageStateFiltered(packageName, mCallingUid, mUserId);
         }
 
@@ -250,6 +279,11 @@
                 var filteredPackageStates = new ArrayMap<String, PackageState>();
                 for (int index = 0, size = packageStates.size(); index < size; index++) {
                     var packageState = packageStates.valueAt(index);
+                    if (mUncommitPackageState != null
+                            && packageState.getPackageName().equals(
+                            mUncommitPackageState.getPackageName())) {
+                        packageState = (PackageStateInternal) mUncommitPackageState;
+                    }
                     if (!mSnapshot.shouldFilterApplication(packageState, mCallingUid, mUserId)) {
                         filteredPackageStates.put(packageStates.keyAt(index), packageState);
                     }
diff --git a/services/core/java/com/android/server/pm/permission/DefaultPermissionGrantPolicy.java b/services/core/java/com/android/server/pm/permission/DefaultPermissionGrantPolicy.java
index 6e7a009..bc6a40a 100644
--- a/services/core/java/com/android/server/pm/permission/DefaultPermissionGrantPolicy.java
+++ b/services/core/java/com/android/server/pm/permission/DefaultPermissionGrantPolicy.java
@@ -884,10 +884,13 @@
             }
 
             // Allow voice search on wear
-            grantPermissionsToSystemPackage(pm,
-                    getDefaultSystemHandlerActivityPackage(pm,
-                            SearchManager.INTENT_ACTION_GLOBAL_SEARCH, userId),
-                    userId, PHONE_PERMISSIONS, CALENDAR_PERMISSIONS, NEARBY_DEVICES_PERMISSIONS);
+            String voiceSearchPackage = getDefaultSystemHandlerActivityPackage(pm,
+                    SearchManager.INTENT_ACTION_GLOBAL_SEARCH, userId);
+            grantPermissionsToSystemPackage(pm, voiceSearchPackage,
+                    userId, PHONE_PERMISSIONS, CALENDAR_PERMISSIONS, NEARBY_DEVICES_PERMISSIONS,
+                    COARSE_BACKGROUND_LOCATION_PERMISSIONS);
+            revokeRuntimePermissions(pm, voiceSearchPackage,
+                FINE_LOCATION_PERMISSIONS, false, userId);
         }
 
         // Print Spooler
diff --git a/services/core/java/com/android/server/policy/ModifierShortcutManager.java b/services/core/java/com/android/server/policy/ModifierShortcutManager.java
index 027e69c..66ec53e 100644
--- a/services/core/java/com/android/server/policy/ModifierShortcutManager.java
+++ b/services/core/java/com/android/server/policy/ModifierShortcutManager.java
@@ -667,9 +667,11 @@
     public KeyboardShortcutGroup getApplicationLaunchKeyboardShortcuts(int deviceId) {
         List<KeyboardShortcutInfo> shortcuts = new ArrayList();
         if (modifierShortcutManagerRefactor()) {
+            Context context = modifierShortcutManagerMultiuser()
+                    ? mContext.createContextAsUser(mCurrentUser, 0) : mContext;
             for (Bookmark b : mBookmarks.values()) {
                 KeyboardShortcutInfo info = shortcutInfoFromIntent(
-                        b.getShortcutChar(), b.getIntent(mContext), b.isShift());
+                        b.getShortcutChar(), b.getIntent(context), b.isShift());
                 if (info != null) {
                     shortcuts.add(info);
                 }
diff --git a/services/core/java/com/android/server/policy/PhoneWindowManager.java b/services/core/java/com/android/server/policy/PhoneWindowManager.java
index 2284050..e47b4c2 100644
--- a/services/core/java/com/android/server/policy/PhoneWindowManager.java
+++ b/services/core/java/com/android/server/policy/PhoneWindowManager.java
@@ -373,6 +373,7 @@
     //The config value can be overridden using Settings.Global.STEM_PRIMARY_BUTTON_DOUBLE_PRESS
     static final int DOUBLE_PRESS_PRIMARY_NOTHING = 0;
     static final int DOUBLE_PRESS_PRIMARY_SWITCH_RECENT_APP = 1;
+    static final int DOUBLE_PRESS_PRIMARY_LAUNCH_DEFAULT_FITNESS_APP = 2;
 
     // Must match: config_triplePressOnStemPrimaryBehavior in config.xml
     // The config value can be overridden using Settings.Global.STEM_PRIMARY_BUTTON_TRIPLE_PRESS
@@ -1596,6 +1597,12 @@
                     performStemPrimaryDoublePressSwitchToRecentTask();
                 }
                 break;
+            case DOUBLE_PRESS_PRIMARY_LAUNCH_DEFAULT_FITNESS_APP:
+                final int stemPrimaryKeyDeviceId = INVALID_INPUT_DEVICE_ID;
+                handleKeyGestureInKeyGestureController(
+                        KeyGestureEvent.KEY_GESTURE_TYPE_LAUNCH_DEFAULT_FITNESS,
+                        stemPrimaryKeyDeviceId, KEYCODE_STEM_PRIMARY, /* metaState= */ 0);
+                break;
         }
     }
 
@@ -3204,8 +3211,9 @@
             return ADD_OKAY;
         }
 
-        // Allow virtual device owners to add overlays on the displays they own.
+        // Allow virtual device owners to add overlays on the trusted displays they own.
         if (mWindowManagerFuncs.isCallerVirtualDeviceOwner(displayId, callingUid)
+                && mWindowManagerFuncs.isDisplayTrusted(displayId)
                 && mContext.checkCallingOrSelfPermission(CREATE_VIRTUAL_DEVICE)
                 == PERMISSION_GRANTED) {
             return ADD_OKAY;
@@ -7243,6 +7251,8 @@
                 return "DOUBLE_PRESS_PRIMARY_NOTHING";
             case DOUBLE_PRESS_PRIMARY_SWITCH_RECENT_APP:
                 return "DOUBLE_PRESS_PRIMARY_SWITCH_RECENT_APP";
+            case DOUBLE_PRESS_PRIMARY_LAUNCH_DEFAULT_FITNESS_APP:
+                return "DOUBLE_PRESS_PRIMARY_LAUNCH_DEFAULT_FITNESS_APP";
             default:
                 return Integer.toString(behavior);
         }
diff --git a/services/core/java/com/android/server/policy/WindowManagerPolicy.java b/services/core/java/com/android/server/policy/WindowManagerPolicy.java
index ad11657..cc31bb1 100644
--- a/services/core/java/com/android/server/policy/WindowManagerPolicy.java
+++ b/services/core/java/com/android/server/policy/WindowManagerPolicy.java
@@ -368,6 +368,11 @@
          * belongs to.
          */
         boolean isCallerVirtualDeviceOwner(int displayId, int callingUid);
+
+        /**
+         * Returns whether the display with the given ID is trusted.
+         */
+        boolean isDisplayTrusted(int displayId);
     }
 
     /**
diff --git a/services/core/java/com/android/server/power/Notifier.java b/services/core/java/com/android/server/power/Notifier.java
index 0cdf537..4fae798 100644
--- a/services/core/java/com/android/server/power/Notifier.java
+++ b/services/core/java/com/android/server/power/Notifier.java
@@ -164,6 +164,7 @@
     }
 
     private final SparseArray<Interactivity> mInteractivityByGroupId = new SparseArray<>();
+    private SparseBooleanArray mDisplayInteractivities = new SparseBooleanArray();
 
     // The current global interactive state.  This is set as soon as an interactive state
     // transition begins so as to capture the reason that it happened.  At some point
@@ -690,6 +691,42 @@
     }
 
     /**
+     * Update the interactivities of the displays in given DisplayGroup.
+     *
+     * @param groupId The group id of the DisplayGroup to update display interactivities for.
+     */
+    private void updateDisplayInteractivities(int groupId, boolean interactive) {
+        final int[] displayIds = mDisplayManagerInternal.getDisplayIdsForGroup(groupId);
+        for (int displayId : displayIds) {
+            mDisplayInteractivities.put(displayId, interactive);
+        }
+
+    }
+
+    private void resetDisplayInteractivities() {
+        final SparseArray<int[]> displaysByGroupId =
+                mDisplayManagerInternal.getDisplayIdsByGroupsIds();
+        SparseBooleanArray newDisplayInteractivities = new SparseBooleanArray();
+        for (int i = 0; i < displaysByGroupId.size(); i++) {
+            final int groupId = displaysByGroupId.keyAt(i);
+            for (int displayId : displaysByGroupId.get(i)) {
+                // If we already know display interactivity, use that
+                if (mDisplayInteractivities.indexOfKey(displayId) > 0) {
+                    newDisplayInteractivities.put(
+                            displayId, mDisplayInteractivities.get(displayId));
+                } else { // If display is new to Notifier, use the power group's interactive value
+                    final Interactivity groupInteractivity = mInteractivityByGroupId.get(groupId);
+                    // If group Interactivity hasn't been initialized, assume group is interactive
+                    final boolean groupInteractive =
+                            groupInteractivity == null || groupInteractivity.isInteractive;
+                    newDisplayInteractivities.put(displayId, groupInteractive);
+                }
+            }
+        }
+        mDisplayInteractivities = newDisplayInteractivities;
+    }
+
+    /**
      * Called when an individual PowerGroup changes wakefulness.
      */
     public void onGroupWakefulnessChangeStarted(int groupId, int wakefulness, int changeReason,
@@ -717,6 +754,12 @@
             handleEarlyInteractiveChange(groupId);
             mWakefulnessSessionObserver.onWakefulnessChangeStarted(groupId, wakefulness,
                     changeReason, eventTime);
+
+            // Update input on which displays are interactive
+            if (mFlags.isPerDisplayWakeByTouchEnabled()) {
+                updateDisplayInteractivities(groupId, isInteractive);
+                mInputManagerInternal.setDisplayInteractivities(mDisplayInteractivities);
+            }
         }
     }
 
@@ -731,6 +774,16 @@
     }
 
     /**
+     * Called when a PowerGroup has been changed.
+     */
+    public void onGroupChanged() {
+        if (mFlags.isPerDisplayWakeByTouchEnabled()) {
+            resetDisplayInteractivities();
+            mInputManagerInternal.setDisplayInteractivities(mDisplayInteractivities);
+        }
+    }
+
+    /**
      * Called when there has been user activity.
      */
     public void onUserActivity(int displayGroupId, @PowerManager.UserActivityEvent int event,
diff --git a/services/core/java/com/android/server/power/PowerManagerService.java b/services/core/java/com/android/server/power/PowerManagerService.java
index e053964..21ab781 100644
--- a/services/core/java/com/android/server/power/PowerManagerService.java
+++ b/services/core/java/com/android/server/power/PowerManagerService.java
@@ -125,7 +125,6 @@
 import com.android.internal.util.FrameworkStatsLog;
 import com.android.internal.util.LatencyTracker;
 import com.android.internal.util.Preconditions;
-import com.android.server.crashrecovery.CrashRecoveryHelper;
 import com.android.server.EventLogTags;
 import com.android.server.LockGuard;
 import com.android.server.ServiceThread;
@@ -133,6 +132,7 @@
 import com.android.server.UiThread;
 import com.android.server.Watchdog;
 import com.android.server.am.BatteryStatsService;
+import com.android.server.crashrecovery.CrashRecoveryHelper;
 import com.android.server.display.feature.DeviceConfigParameterProvider;
 import com.android.server.lights.LightsManager;
 import com.android.server.lights.LogicalLight;
@@ -2445,6 +2445,8 @@
                     mClock.uptimeMillis());
         } else if (event == DisplayGroupPowerChangeListener.DISPLAY_GROUP_REMOVED) {
             mNotifier.onGroupRemoved(groupId);
+        } else if (event == DisplayGroupPowerChangeListener.DISPLAY_GROUP_CHANGED) {
+            mNotifier.onGroupChanged();
         }
 
         if (oldWakefulness != newWakefulness) {
diff --git a/services/core/java/com/android/server/power/hint/HintManagerService.java b/services/core/java/com/android/server/power/hint/HintManagerService.java
index 1346a29..dc48242 100644
--- a/services/core/java/com/android/server/power/hint/HintManagerService.java
+++ b/services/core/java/com/android/server/power/hint/HintManagerService.java
@@ -1282,11 +1282,9 @@
         boolean updateHintAllowedByProcState(boolean allowed) {
             synchronized (this) {
                 if (allowed && !mUpdateAllowedByProcState && !mShouldForcePause) {
-                    Slogf.e(TAG, "ADPF IS GETTING RESUMED? UID: " + mUid + " TAG: " + mTag);
                     resume();
                 }
                 if (!allowed && mUpdateAllowedByProcState) {
-                    Slogf.e(TAG, "ADPF IS GETTING PAUSED? UID: " + mUid + " TAG: " + mTag);
                     pause();
                 }
                 mUpdateAllowedByProcState = allowed;
diff --git a/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java
index 539c415..d7aa987 100644
--- a/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java
+++ b/services/core/java/com/android/server/power/stats/BluetoothPowerStatsCollector.java
@@ -30,6 +30,7 @@
 import com.android.internal.os.PowerStats;
 import com.android.server.power.stats.format.BluetoothPowerStatsLayout;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
@@ -142,6 +143,7 @@
             return null;
         }
 
+        Arrays.fill(mDeviceStats, 0);
         mPowerStats.uidStats.clear();
 
         collectBluetoothActivityInfo();
diff --git a/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java
index 128f14a..dd6484d 100644
--- a/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java
+++ b/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java
@@ -34,6 +34,7 @@
 import com.android.server.power.stats.format.CpuPowerStatsLayout;
 
 import java.io.PrintWriter;
+import java.util.Arrays;
 import java.util.Locale;
 
 /**
@@ -330,7 +331,9 @@
             return null;
         }
 
+        Arrays.fill(mCpuPowerStats.stats, 0);
         mCpuPowerStats.uidStats.clear();
+
         // TODO(b/305120724): additionally retrieve time-in-cluster for each CPU cluster
         long newTimestampNanos = mKernelCpuStatsReader.readCpuStats(this::processUidStats,
                 mLayout.getScalingStepToPowerBracketMap(), mLastUpdateTimestampNanos,
diff --git a/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java
index 1d2e388..079fc3c 100644
--- a/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java
+++ b/services/core/java/com/android/server/power/stats/EnergyConsumerPowerStatsCollector.java
@@ -24,6 +24,8 @@
 import com.android.internal.os.PowerStats;
 import com.android.server.power.stats.format.EnergyConsumerPowerStatsLayout;
 
+import java.util.Arrays;
+
 public class EnergyConsumerPowerStatsCollector extends PowerStatsCollector {
 
     public interface Injector {
@@ -105,6 +107,7 @@
             return null;
         }
 
+        Arrays.fill(mPowerStats.stats, 0);
         mPowerStats.uidStats.clear();
 
         if (!mConsumedEnergyHelper.collectConsumedEnergy(mPowerStats, mLayout)) {
diff --git a/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java
index 8371e66..c38904f 100644
--- a/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java
+++ b/services/core/java/com/android/server/power/stats/ScreenPowerStatsCollector.java
@@ -27,6 +27,8 @@
 import com.android.internal.os.PowerStats;
 import com.android.server.power.stats.format.ScreenPowerStatsLayout;
 
+import java.util.Arrays;
+
 public class ScreenPowerStatsCollector extends PowerStatsCollector {
     private static final String TAG = "ScreenPowerStatsCollector";
 
@@ -115,6 +117,9 @@
             return null;
         }
 
+        Arrays.fill(mPowerStats.stats, 0);
+        mPowerStats.uidStats.clear();
+
         mConsumedEnergyHelper.collectConsumedEnergy(mPowerStats, mLayout);
 
         for (int display = 0; display < mDisplayCount; display++) {
@@ -142,8 +147,6 @@
             mLastDozeTime[display] = screenDozeTimeMs;
         }
 
-        mPowerStats.uidStats.clear();
-
         mScreenUsageTimeRetriever.retrieveTopActivityTimes((uid, topActivityTimeMs) -> {
             long topActivityDuration = topActivityTimeMs - mLastTopActivityTime.get(uid);
             if (topActivityDuration == 0) {
diff --git a/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java
index 9e4a391..e36c994 100644
--- a/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java
+++ b/services/core/java/com/android/server/power/stats/WakelockPowerStatsCollector.java
@@ -25,6 +25,8 @@
 import com.android.internal.os.PowerStats;
 import com.android.server.power.stats.format.WakelockPowerStatsLayout;
 
+import java.util.Arrays;
+
 class WakelockPowerStatsCollector extends PowerStatsCollector {
 
     public interface WakelockDurationRetriever {
@@ -89,6 +91,9 @@
             return null;
         }
 
+        Arrays.fill(mPowerStats.stats, 0);
+        mPowerStats.uidStats.clear();
+
         long elapsedRealtime = mClock.elapsedRealtime();
         mPowerStats.durationMs = elapsedRealtime - mLastCollectionTime;
 
@@ -101,7 +106,6 @@
 
         mLastWakelockDurationMs = wakelockDurationMillis;
 
-        mPowerStats.uidStats.clear();
         mWakelockDurationRetriever.retrieveUidWakelockDuration((uid, durationMs) -> {
             if (!mFirstCollection) {
                 long[] uidStats = mPowerStats.uidStats.get(uid);
diff --git a/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java
index 7a84b05..1fdeac9 100644
--- a/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java
+++ b/services/core/java/com/android/server/power/stats/WifiPowerStatsCollector.java
@@ -30,6 +30,7 @@
 import com.android.internal.os.PowerStats;
 import com.android.server.power.stats.format.WifiPowerStatsLayout;
 
+import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
@@ -151,6 +152,9 @@
             return null;
         }
 
+        Arrays.fill(mDeviceStats, 0);
+        mPowerStats.uidStats.clear();
+
         WifiActivityEnergyInfo activityInfo = null;
         if (mPowerReportingSupported) {
             activityInfo = collectWifiActivityInfo();
@@ -224,8 +228,6 @@
     }
 
     private List<BatteryStatsImpl.NetworkStatsDelta> collectNetworkStats() {
-        mPowerStats.uidStats.clear();
-
         NetworkStats networkStats = mNetworkStatsSupplier.get();
         if (networkStats == null) {
             return null;
diff --git a/services/core/java/com/android/server/wm/ActivityRecord.java b/services/core/java/com/android/server/wm/ActivityRecord.java
index 5372abe..2972771 100644
--- a/services/core/java/com/android/server/wm/ActivityRecord.java
+++ b/services/core/java/com/android/server/wm/ActivityRecord.java
@@ -230,6 +230,7 @@
 import static com.android.server.wm.IdentifierProto.TITLE;
 import static com.android.server.wm.IdentifierProto.USER_ID;
 import static com.android.server.wm.StartingData.AFTER_TRANSACTION_COPY_TO_CLIENT;
+import static com.android.server.wm.StartingData.AFTER_TRANSACTION_IDLE;
 import static com.android.server.wm.StartingData.AFTER_TRANSACTION_REMOVE_DIRECTLY;
 import static com.android.server.wm.SurfaceAnimator.ANIMATION_TYPE_APP_TRANSITION;
 import static com.android.server.wm.SurfaceAnimator.ANIMATION_TYPE_PREDICT_BACK;
@@ -286,6 +287,9 @@
 import android.app.servertransaction.TopResumedActivityChangeItem;
 import android.app.servertransaction.TransferSplashScreenViewStateItem;
 import android.app.usage.UsageEvents.Event;
+import android.compat.annotation.ChangeId;
+import android.compat.annotation.EnabledAfter;
+import android.compat.annotation.Overridable;
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
@@ -465,6 +469,11 @@
     // finished destroying itself.
     private static final int DESTROY_TIMEOUT = 10 * 1000;
 
+    @ChangeId
+    @Overridable
+    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    static final long UNIVERSAL_RESIZABLE_BY_DEFAULT = 357141415;
+
     final ActivityTaskManagerService mAtmService;
     final ActivityCallerState mCallerState;
     @NonNull
@@ -2635,8 +2644,10 @@
         if (finishing || !mHandleExitSplashScreen || mStartingSurface == null
                 || mStartingWindow == null
                 || mTransferringSplashScreenState == TRANSFER_SPLASH_SCREEN_FINISH
-                // skip copy splash screen to client if it was resized
-                || (mStartingData != null && mStartingData.mResizedFromTransfer)
+                // Skip copy splash screen to client if it was resized, or the starting data already
+                // requested to be removed after transaction commit.
+                || (mStartingData != null && (mStartingData.mResizedFromTransfer
+                        || mStartingData.mRemoveAfterTransaction != AFTER_TRANSACTION_IDLE))
                 || isRelaunching()) {
             return false;
         }
@@ -3179,11 +3190,20 @@
      * will be ignored.
      */
     boolean isUniversalResizeable() {
-        return mWmService.mConstants.mIgnoreActivityOrientationRequest
-                && info.applicationInfo.category != ApplicationInfo.CATEGORY_GAME
-                // If the user preference respects aspect ratio, then it becomes non-resizable.
-                && !mAppCompatController.getAppCompatOverrides().getAppCompatAspectRatioOverrides()
-                        .shouldApplyUserMinAspectRatioOverride();
+        if (info.applicationInfo.category == ApplicationInfo.CATEGORY_GAME) {
+            return false;
+        }
+        final boolean compatEnabled = Flags.universalResizableByDefault()
+                && mDisplayContent != null && mDisplayContent.getConfiguration()
+                    .smallestScreenWidthDp >= WindowManager.LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP
+                && mDisplayContent.getIgnoreOrientationRequest()
+                && info.isChangeEnabled(UNIVERSAL_RESIZABLE_BY_DEFAULT);
+        if (!compatEnabled && !mWmService.mConstants.mIgnoreActivityOrientationRequest) {
+            return false;
+        }
+        // If the user preference respects aspect ratio, then it becomes non-resizable.
+        return !mAppCompatController.getAppCompatOverrides().getAppCompatAspectRatioOverrides()
+                .shouldApplyUserMinAspectRatioOverride();
     }
 
     boolean isResizeable() {
@@ -8179,7 +8199,7 @@
     @ActivityInfo.ScreenOrientation
     protected int getOverrideOrientation() {
         int candidateOrientation = super.getOverrideOrientation();
-        if (isUniversalResizeable() && ActivityInfo.isFixedOrientation(candidateOrientation)) {
+        if (ActivityInfo.isFixedOrientation(candidateOrientation) && isUniversalResizeable()) {
             candidateOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
         }
         return mAppCompatController.getOrientationPolicy()
diff --git a/services/core/java/com/android/server/wm/ActivityStarter.java b/services/core/java/com/android/server/wm/ActivityStarter.java
index 49380d4..87fa62a 100644
--- a/services/core/java/com/android/server/wm/ActivityStarter.java
+++ b/services/core/java/com/android/server/wm/ActivityStarter.java
@@ -2759,10 +2759,7 @@
             mInTask = null;
             // Launch ResolverActivity in the source task, so that it stays in the task bounds
             // when in freeform workspace.
-            // Also put noDisplay activities in the source task. These by itself can be placed
-            // in any task/root-task, however it could launch other activities like
-            // ResolverActivity, and we want those to stay in the original task.
-            if ((mStartActivity.isResolverOrDelegateActivity() || mStartActivity.noDisplay)
+            if (mStartActivity.isResolverOrDelegateActivity()
                     && mSourceRecord != null && mSourceRecord.inFreeformWindowingMode()) {
                 mAddingToTask = true;
             }
diff --git a/services/core/java/com/android/server/wm/Dimmer.java b/services/core/java/com/android/server/wm/Dimmer.java
index a74b006..4824c16 100644
--- a/services/core/java/com/android/server/wm/Dimmer.java
+++ b/services/core/java/com/android/server/wm/Dimmer.java
@@ -21,6 +21,7 @@
 import static com.android.server.wm.WindowManagerDebugConfig.TAG_WM;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.graphics.Rect;
 import android.util.Log;
 import android.view.Surface;
@@ -128,7 +129,7 @@
         /**
          * Set the parameters to prepare the dim to be relative parented to the dimming container
          */
-        void prepareReparent(@NonNull WindowContainer<?> geometryParent,
+        void prepareReparent(@Nullable WindowContainer<?> geometryParent,
                 @NonNull WindowState relativeParent) {
             mAnimationHelper.setRequestedRelativeParent(relativeParent);
             mAnimationHelper.setRequestedGeometryParent(geometryParent);
@@ -221,7 +222,7 @@
      * @param dimmingContainer      The container that is dimming. The dim layer will be rel-z
      *                              parented below it
      */
-    public void adjustPosition(@NonNull WindowContainer<?> geometryParent,
+    public void adjustPosition(@Nullable WindowContainer<?> geometryParent,
                                     @NonNull WindowState dimmingContainer) {
         if (mDimState != null) {
             mDimState.prepareReparent(geometryParent, dimmingContainer);
diff --git a/services/core/java/com/android/server/wm/DimmerAnimationHelper.java b/services/core/java/com/android/server/wm/DimmerAnimationHelper.java
index 298edae..3999e03 100644
--- a/services/core/java/com/android/server/wm/DimmerAnimationHelper.java
+++ b/services/core/java/com/android/server/wm/DimmerAnimationHelper.java
@@ -108,7 +108,7 @@
     }
 
     // Sets the requested layer to reparent the dim to without applying it immediately
-    void setRequestedGeometryParent(WindowContainer<?> geometryParent) {
+    void setRequestedGeometryParent(@Nullable WindowContainer<?> geometryParent) {
         if (geometryParent != null) {
             mRequestedProperties.mGeometryParent = geometryParent;
         }
diff --git a/services/core/java/com/android/server/wm/DisplayPolicy.java b/services/core/java/com/android/server/wm/DisplayPolicy.java
index 52e8285..c062f5a 100644
--- a/services/core/java/com/android/server/wm/DisplayPolicy.java
+++ b/services/core/java/com/android/server/wm/DisplayPolicy.java
@@ -1073,7 +1073,8 @@
 
         final String systemUiPermission =
                 mService.isCallerVirtualDeviceOwner(mDisplayContent.getDisplayId(), callingUid)
-                        // Allow virtual device owners to add system windows on their displays.
+                        && mDisplayContent.isTrusted()
+                        // Virtual device owners can add system windows on their trusted displays.
                         ? android.Manifest.permission.CREATE_VIRTUAL_DEVICE
                         : android.Manifest.permission.STATUS_BAR_SERVICE;
 
diff --git a/services/core/java/com/android/server/wm/Transition.java b/services/core/java/com/android/server/wm/Transition.java
index c48590f..188b368 100644
--- a/services/core/java/com/android/server/wm/Transition.java
+++ b/services/core/java/com/android/server/wm/Transition.java
@@ -1947,7 +1947,8 @@
         mCleanupTransaction = mController.mAtm.mWindowManager.mTransactionFactory.get();
         buildCleanupTransaction(mCleanupTransaction, info);
         if (mController.getTransitionPlayer() != null && mIsPlayerEnabled) {
-            mController.dispatchLegacyAppTransitionStarting(info, mStatusBarTransitionDelay);
+            mController.dispatchLegacyAppTransitionStarting(participantDisplays,
+                    mStatusBarTransitionDelay);
             try {
                 ProtoLog.v(WmProtoLogGroups.WM_DEBUG_WINDOW_TRANSITIONS,
                         "Calling onTransitionReady: %s", info);
@@ -2188,32 +2189,34 @@
             for (int i = onTopTasksEnd.size() - 1; i >= 0; --i) {
                 final Task task = onTopTasksEnd.get(i);
                 if (task.getDisplayId() != displayId) continue;
-                if (!enableDisplayFocusInShellTransitions()
-                        || mOnTopDisplayStart == onTopDisplayEnd
-                        || displayId != onTopDisplayEnd.mDisplayId) {
-                    // If it didn't change since last report, don't report
-                    if (reportedOnTop == null) {
-                        if (mOnTopTasksStart.contains(task)) continue;
-                    } else if (reportedOnTop.contains(task)) {
-                        continue;
-                    }
+                if (reportedOnTop == null) {
+                    if (mOnTopTasksStart.contains(task)) continue;
+                } else if (reportedOnTop.contains(task)) {
+                    continue;
                 }
-                // Need to report it.
-                mParticipants.add(task);
-                int changeIdx = mChanges.indexOfKey(task);
-                if (changeIdx < 0) {
-                    mChanges.put(task, new ChangeInfo(task));
-                    changeIdx = mChanges.indexOfKey(task);
-                }
-                mChanges.valueAt(changeIdx).mFlags |= ChangeInfo.FLAG_CHANGE_MOVED_TO_TOP;
+                addToTopChange(task);
             }
             // Swap in the latest on-top tasks.
             mController.mLatestOnTopTasksReported.put(displayId, onTopTasksEnd);
             onTopTasksEnd = reportedOnTop != null ? reportedOnTop : new ArrayList<>();
             onTopTasksEnd.clear();
+
+            if (enableDisplayFocusInShellTransitions()
+                    && mOnTopDisplayStart != onTopDisplayEnd
+                    && displayId == onTopDisplayEnd.mDisplayId) {
+                addToTopChange(onTopDisplayEnd);
+            }
         }
     }
 
+    private void addToTopChange(@NonNull WindowContainer wc) {
+        mParticipants.add(wc);
+        if (!mChanges.containsKey(wc)) {
+            mChanges.put(wc, new ChangeInfo(wc));
+        }
+        mChanges.get(wc).mFlags |= ChangeInfo.FLAG_CHANGE_MOVED_TO_TOP;
+    }
+
     private void postCleanupOnFailure() {
         mController.mAtm.mH.post(() -> {
             synchronized (mController.mAtm.mGlobalLock) {
diff --git a/services/core/java/com/android/server/wm/TransitionController.java b/services/core/java/com/android/server/wm/TransitionController.java
index 37c3ce80..b7fe327 100644
--- a/services/core/java/com/android/server/wm/TransitionController.java
+++ b/services/core/java/com/android/server/wm/TransitionController.java
@@ -1356,12 +1356,13 @@
         }
     }
 
-    void dispatchLegacyAppTransitionStarting(TransitionInfo info, long statusBarTransitionDelay) {
+    void dispatchLegacyAppTransitionStarting(DisplayContent[] participantDisplays,
+            long statusBarTransitionDelay) {
         final long now = SystemClock.uptimeMillis();
         for (int i = 0; i < mLegacyListeners.size(); ++i) {
             final WindowManagerInternal.AppTransitionListener listener = mLegacyListeners.get(i);
-            for (int j = 0; j < info.getRootCount(); ++j) {
-                final int displayId = info.getRoot(j).getDisplayId();
+            for (int j = 0; j < participantDisplays.length; ++j) {
+                final int displayId = participantDisplays[j].mDisplayId;
                 if (shouldDispatchLegacyListener(listener, displayId)) {
                     listener.onAppTransitionStartingLocked(
                             now + statusBarTransitionDelay,
diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java
index 7925220..e1e4714 100644
--- a/services/core/java/com/android/server/wm/WindowManagerService.java
+++ b/services/core/java/com/android/server/wm/WindowManagerService.java
@@ -10172,6 +10172,22 @@
         }
     }
 
+    /**
+     * Returns whether the display with the given ID is trusted.
+     */
+    @Override
+    public boolean isDisplayTrusted(int displayId) {
+        final long identity = Binder.clearCallingIdentity();
+        try {
+            synchronized (mGlobalLock) {
+                DisplayContent dc = mRoot.getDisplayContent(displayId);
+                return dc != null && dc.isTrusted();
+            }
+        } finally {
+            Binder.restoreCallingIdentity(identity);
+        }
+    }
+
     @RequiresPermission(ACCESS_SURFACE_FLINGER)
     @Override
     public boolean replaceContentOnDisplay(int displayId, SurfaceControl sc) {
diff --git a/services/core/java/com/android/server/wm/WindowManagerShellCommand.java b/services/core/java/com/android/server/wm/WindowManagerShellCommand.java
index 6d7396f..21ed8d7 100644
--- a/services/core/java/com/android/server/wm/WindowManagerShellCommand.java
+++ b/services/core/java/com/android/server/wm/WindowManagerShellCommand.java
@@ -16,6 +16,9 @@
 
 package com.android.server.wm;
 
+import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM;
+import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
+import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED;
 import static android.os.Build.IS_USER;
 import static android.view.CrossWindowBlurListeners.CROSS_WINDOW_BLUR_SUPPORTED;
 
@@ -30,6 +33,7 @@
 import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_VERTICAL_REACHABILITY_POSITION_CENTER;
 import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_VERTICAL_REACHABILITY_POSITION_TOP;
 
+import android.app.WindowConfiguration;
 import android.content.res.Resources.NotFoundException;
 import android.graphics.Color;
 import android.graphics.Point;
@@ -157,6 +161,10 @@
                     return runReset(pw);
                 case "disable-blur":
                     return runSetBlurDisabled(pw);
+                case "set-display-windowing-mode":
+                    return runSetDisplayWindowingMode(pw);
+                case "get-display-windowing-mode":
+                    return runGetDisplayWindowingMode(pw);
                 case "shell":
                     return runWmShellCommand(pw);
                 default:
@@ -1434,6 +1442,35 @@
         return 0;
     }
 
+    private int runSetDisplayWindowingMode(PrintWriter pw) throws RemoteException {
+        int displayId = Display.DEFAULT_DISPLAY;
+        String arg = getNextArgRequired();
+        if ("-d".equals(arg)) {
+            displayId = Integer.parseInt(getNextArgRequired());
+            arg = getNextArgRequired();
+        }
+
+        final int windowingMode = Integer.parseInt(arg);
+        mInterface.setWindowingMode(displayId, windowingMode);
+
+        return 0;
+    }
+
+    private int runGetDisplayWindowingMode(PrintWriter pw) throws RemoteException {
+        int displayId = Display.DEFAULT_DISPLAY;
+        final String arg = getNextArg();
+        if ("-d".equals(arg)) {
+            displayId = Integer.parseInt(getNextArgRequired());
+        }
+
+        final int windowingMode = mInterface.getWindowingMode(displayId);
+        pw.println("display windowing mode="
+                + WindowConfiguration.windowingModeToString(windowingMode) + " for displayId="
+                + displayId);
+
+        return 0;
+    }
+
     private int runWmShellCommand(PrintWriter pw) {
         String arg = getNextArg();
 
@@ -1512,6 +1549,9 @@
         // set-multi-window-config
         runResetMultiWindowConfig();
 
+        // set-display-windowing-mode
+        mInternal.setWindowingMode(displayId, WINDOWING_MODE_UNDEFINED);
+
         pw.println("Reset all settings for displayId=" + displayId);
         return 0;
     }
@@ -1552,6 +1592,12 @@
         printLetterboxHelp(pw);
         printMultiWindowConfigHelp(pw);
 
+        pw.println("  set-display-windowing-mode [-d DISPLAY_ID] [mode_id]");
+        pw.println("    As mode_id, use " + WINDOWING_MODE_UNDEFINED + " for undefined, "
+                + WINDOWING_MODE_FREEFORM + " for freeform, " + WINDOWING_MODE_FULLSCREEN + " for"
+                + " fullscreen");
+        pw.println("  get-display-windowing-mode [-d DISPLAY_ID]");
+
         pw.println("  reset [-d DISPLAY_ID]");
         pw.println("    Reset all override settings.");
         if (!IS_USER) {
diff --git a/services/core/java/com/android/server/wm/WindowState.java b/services/core/java/com/android/server/wm/WindowState.java
index 0878912..66f9230 100644
--- a/services/core/java/com/android/server/wm/WindowState.java
+++ b/services/core/java/com/android/server/wm/WindowState.java
@@ -5215,7 +5215,7 @@
             // but not window manager visible (!isVisibleNow()), it can still be the parent of the
             // dim, but can not create a new surface or continue a dim alone.
             Dimmer dimmer;
-            WindowContainer<?> geometryParent = task;
+            WindowContainer<?> geometryParent = null;
             if (Flags.useTasksDimOnly()) {
                 geometryParent = getDimParent();
                 dimmer = getDimController();
diff --git a/services/profcollect/src/com/android/server/profcollect/Utils.java b/services/profcollect/src/com/android/server/profcollect/Utils.java
index b4e2544..a8016a0 100644
--- a/services/profcollect/src/com/android/server/profcollect/Utils.java
+++ b/services/profcollect/src/com/android/server/profcollect/Utils.java
@@ -25,10 +25,14 @@
 
 import com.android.internal.os.BackgroundThread;
 
+import java.time.Instant;
 import java.util.concurrent.ThreadLocalRandom;
 
 public final class Utils {
 
+    private static Instant lastTraceTime = Instant.EPOCH;
+    private static final int TRACE_COOLDOWN_SECONDS = 30;
+
     public static boolean withFrequency(String configName, int defaultFrequency) {
         int threshold = DeviceConfig.getInt(
                 DeviceConfig.NAMESPACE_PROFCOLLECT_NATIVE_BOOT, configName, defaultFrequency);
@@ -40,6 +44,9 @@
         if (mIProfcollect == null) {
             return false;
         }
+        if (isInCooldownOrReset()) {
+            return false;
+        }
         BackgroundThread.get().getThreadHandler().post(() -> {
             try {
                 mIProfcollect.trace_system(eventName);
@@ -54,6 +61,9 @@
         if (mIProfcollect == null) {
             return false;
         }
+        if (isInCooldownOrReset()) {
+            return false;
+        }
         BackgroundThread.get().getThreadHandler().postDelayed(() -> {
             try {
                 mIProfcollect.trace_system(eventName);
@@ -69,6 +79,9 @@
         if (mIProfcollect == null) {
             return false;
         }
+        if (isInCooldownOrReset()) {
+            return false;
+        }
         BackgroundThread.get().getThreadHandler().post(() -> {
             try {
                 mIProfcollect.trace_process(eventName,
@@ -80,4 +93,16 @@
         });
         return true;
     }
+
+    /**
+     * Returns true if the last trace is within the cooldown period. If the last trace is outside
+     * the cooldown period, the last trace time is reset to the current time.
+     */
+    private static boolean isInCooldownOrReset() {
+        if (!Instant.now().isBefore(lastTraceTime.plusSeconds(TRACE_COOLDOWN_SECONDS))) {
+            lastTraceTime = Instant.now();
+            return false;
+        }
+        return true;
+    }
 }
diff --git a/services/tests/PackageManagerComponentOverrideTests/src/com/android/server/pm/test/override/PackageManagerComponentLabelIconOverrideTest.kt b/services/tests/PackageManagerComponentOverrideTests/src/com/android/server/pm/test/override/PackageManagerComponentLabelIconOverrideTest.kt
index 7d5532f..5c4716d 100644
--- a/services/tests/PackageManagerComponentOverrideTests/src/com/android/server/pm/test/override/PackageManagerComponentLabelIconOverrideTest.kt
+++ b/services/tests/PackageManagerComponentOverrideTests/src/com/android/server/pm/test/override/PackageManagerComponentLabelIconOverrideTest.kt
@@ -57,7 +57,6 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
-import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.any
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.doReturn
@@ -384,10 +383,6 @@
                     android.Manifest.permission.INTERACT_ACROSS_USERS_FULL)) {
                 PackageManager.PERMISSION_GRANTED
             }
-            whenever(this.checkPermission(
-                eq(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL), anyInt(), anyInt())) {
-                PackageManager.PERMISSION_GRANTED
-            }
         }
         val mockSharedLibrariesImpl: SharedLibrariesImpl = mock {
             whenever(this.snapshot()) { this@mock }
diff --git a/services/tests/displayservicetests/src/com/android/server/display/DisplayManagerServiceTest.java b/services/tests/displayservicetests/src/com/android/server/display/DisplayManagerServiceTest.java
index 255dcb0..6093a67 100644
--- a/services/tests/displayservicetests/src/com/android/server/display/DisplayManagerServiceTest.java
+++ b/services/tests/displayservicetests/src/com/android/server/display/DisplayManagerServiceTest.java
@@ -37,6 +37,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
@@ -1308,6 +1309,38 @@
     }
 
     /**
+     * Tests that it's not allowed to create an auto-mirror virtual display without
+     * CAPTURE_VIDEO_OUTPUT permission or a virtual device that can mirror displays
+     */
+    @Test
+    public void createAutoMirrorDisplay_withoutPermissionOrAllowedVirtualDevice_throwsException()
+            throws Exception {
+        DisplayManagerService displayManager = new DisplayManagerService(mContext, mBasicInjector);
+        DisplayManagerInternal localService = displayManager.new LocalService();
+        registerDefaultDisplays(displayManager);
+        when(mMockAppToken.asBinder()).thenReturn(mMockAppToken);
+        IVirtualDevice virtualDevice = mock(IVirtualDevice.class);
+        when(virtualDevice.getDeviceId()).thenReturn(1);
+        when(virtualDevice.canCreateMirrorDisplays()).thenReturn(false);
+        when(mIVirtualDeviceManager.isValidVirtualDeviceId(1)).thenReturn(true);
+        when(mContext.checkCallingPermission(CAPTURE_VIDEO_OUTPUT)).thenReturn(
+                PackageManager.PERMISSION_DENIED);
+
+        final VirtualDisplayConfig.Builder builder =
+                new VirtualDisplayConfig.Builder(VIRTUAL_DISPLAY_NAME, 600, 800, 320)
+                        .setFlags(VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR)
+                        .setUniqueId("uniqueId --- mirror display");
+        assertThrows(SecurityException.class, () -> {
+            localService.createVirtualDisplay(
+                    builder.build(),
+                    mMockAppToken /* callback */,
+                    virtualDevice /* virtualDeviceToken */,
+                    mock(DisplayWindowPolicyController.class),
+                    PACKAGE_NAME);
+        });
+    }
+
+    /**
      * Tests that the virtual display is added to the default display group when created with
      * VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR using a virtual device.
      */
@@ -1319,6 +1352,7 @@
         when(mMockAppToken.asBinder()).thenReturn(mMockAppToken);
         IVirtualDevice virtualDevice = mock(IVirtualDevice.class);
         when(virtualDevice.getDeviceId()).thenReturn(1);
+        when(virtualDevice.canCreateMirrorDisplays()).thenReturn(true);
         when(mIVirtualDeviceManager.isValidVirtualDeviceId(1)).thenReturn(true);
 
         // Create an auto-mirror virtual display using a virtual device.
@@ -1351,6 +1385,7 @@
         when(mMockAppToken.asBinder()).thenReturn(mMockAppToken);
         IVirtualDevice virtualDevice = mock(IVirtualDevice.class);
         when(virtualDevice.getDeviceId()).thenReturn(1);
+        when(virtualDevice.canCreateMirrorDisplays()).thenReturn(true);
         when(mIVirtualDeviceManager.isValidVirtualDeviceId(1)).thenReturn(true);
 
         // Create an auto-mirror virtual display using a virtual device.
@@ -1417,6 +1452,7 @@
         when(mMockAppToken.asBinder()).thenReturn(mMockAppToken);
         IVirtualDevice virtualDevice = mock(IVirtualDevice.class);
         when(virtualDevice.getDeviceId()).thenReturn(1);
+        when(virtualDevice.canCreateMirrorDisplays()).thenReturn(true);
         when(mIVirtualDeviceManager.isValidVirtualDeviceId(1)).thenReturn(true);
         when(mContext.checkCallingPermission(ADD_ALWAYS_UNLOCKED_DISPLAY))
                 .thenReturn(PackageManager.PERMISSION_GRANTED);
@@ -1452,6 +1488,7 @@
         when(mMockAppToken.asBinder()).thenReturn(mMockAppToken);
         IVirtualDevice virtualDevice = mock(IVirtualDevice.class);
         when(virtualDevice.getDeviceId()).thenReturn(1);
+        when(virtualDevice.canCreateMirrorDisplays()).thenReturn(true);
         when(mIVirtualDeviceManager.isValidVirtualDeviceId(1)).thenReturn(true);
 
         // Create an auto-mirror virtual display using a virtual device.
@@ -1524,6 +1561,47 @@
     }
 
     @Test
+    public void testGetDisplayIdsForGroup() throws Exception {
+        DisplayManagerService displayManager = new DisplayManagerService(mContext, mBasicInjector);
+        displayManager.onBootPhase(SystemService.PHASE_BOOT_COMPLETED);
+        DisplayManagerInternal localService = displayManager.new LocalService();
+        LogicalDisplayMapper logicalDisplayMapper = displayManager.getLogicalDisplayMapper();
+        // Create display 1
+        FakeDisplayDevice displayDevice1 =
+                createFakeDisplayDevice(displayManager, new float[]{60f}, Display.TYPE_INTERNAL);
+        LogicalDisplay display1 = logicalDisplayMapper.getDisplayLocked(displayDevice1);
+        final int groupId1 = display1.getDisplayInfoLocked().displayGroupId;
+        // Create display 2
+        FakeDisplayDevice displayDevice2 =
+                createFakeDisplayDevice(displayManager, new float[]{60f}, Display.TYPE_INTERNAL);
+        LogicalDisplay display2 = logicalDisplayMapper.getDisplayLocked(displayDevice2);
+        final int groupId2 = display2.getDisplayInfoLocked().displayGroupId;
+        // Both displays should be in the same display group
+        assertEquals(groupId1, groupId2);
+        final int[] expectedDisplayIds = new int[]{
+                display1.getDisplayIdLocked(), display2.getDisplayIdLocked()};
+
+        final int[] displayIds = localService.getDisplayIdsForGroup(groupId1);
+
+        assertArrayEquals(expectedDisplayIds, displayIds);
+    }
+
+    @Test
+    public void testGetDisplayIdsForUnknownGroup() throws Exception {
+        final int unknownDisplayGroupId = 999;
+        DisplayManagerService displayManager = new DisplayManagerService(mContext, mBasicInjector);
+        displayManager.onBootPhase(SystemService.PHASE_BOOT_COMPLETED);
+        DisplayManagerInternal localService = displayManager.new LocalService();
+        LogicalDisplayMapper logicalDisplayMapper = displayManager.getLogicalDisplayMapper();
+        // Verify that display manager does not have display group
+        assertNull(logicalDisplayMapper.getDisplayGroupLocked(unknownDisplayGroupId));
+
+        final int[] displayIds = localService.getDisplayIdsForGroup(unknownDisplayGroupId);
+
+        assertEquals(0, displayIds.length);
+    }
+
+    @Test
     public void testCreateVirtualDisplay_isValidProjection_notValid()
             throws RemoteException {
         when(mMockAppToken.asBinder()).thenReturn(mMockAppToken);
diff --git a/services/tests/mockingservicestests/src/com/android/server/am/ApplicationStartInfoTest.java b/services/tests/mockingservicestests/src/com/android/server/am/ApplicationStartInfoTest.java
index e863f15..e678acc 100644
--- a/services/tests/mockingservicestests/src/com/android/server/am/ApplicationStartInfoTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/am/ApplicationStartInfoTest.java
@@ -39,6 +39,7 @@
 import android.os.FileUtils;
 import android.os.Handler;
 import android.os.HandlerThread;
+import android.os.Parcel;
 import android.os.Process;
 import android.platform.test.annotations.EnableFlags;
 import android.platform.test.annotations.Presubmit;
@@ -580,6 +581,50 @@
         assertTrue(mAppStartInfoTracker.mMonotonicClock.monotonicTime() >= originalMonotonicTime);
     }
 
+    /**
+     * Test to confirm that parcel read and write implementations match, correctly loading records
+     * with the same values and leaving no data unread.
+     */
+    @Test
+    public void testParcelReadWriteMatch() throws Exception {
+        // Create a start info records with all fields set.
+        ApplicationStartInfo startInfo = new ApplicationStartInfo(1234L);
+        startInfo.setPid(123);
+        startInfo.setRealUid(987);
+        startInfo.setPackageUid(654);
+        startInfo.setDefiningUid(321);
+        startInfo.setReason(ApplicationStartInfo.START_REASON_LAUNCHER);
+        startInfo.setStartupState(ApplicationStartInfo.STARTUP_STATE_FIRST_FRAME_DRAWN);
+        startInfo.setStartType(ApplicationStartInfo.START_TYPE_WARM);
+        startInfo.setLaunchMode(ApplicationStartInfo.LAUNCH_MODE_SINGLE_TOP);
+        startInfo.setPackageName(APP_1_PACKAGE_NAME);
+        startInfo.setProcessName(APP_1_PROCESS_NAME);
+        startInfo.addStartupTimestamp(ApplicationStartInfo.START_TIMESTAMP_LAUNCH, 999L);
+        startInfo.addStartupTimestamp(ApplicationStartInfo.START_TIMESTAMP_FIRST_FRAME, 888L);
+        startInfo.setForceStopped(true);
+        startInfo.setStartComponent(ApplicationStartInfo.START_COMPONENT_OTHER);
+        startInfo.setIntent(buildIntent(COMPONENT));
+
+        // Write the start info to a parcel.
+        Parcel parcel = Parcel.obtain();
+        startInfo.writeToParcel(parcel, 0 /* flags */);
+
+        // Set the data position back to 0 so it's ready to be read.
+        parcel.setDataPosition(0);
+
+        // Now load the record from the parcel.
+        ApplicationStartInfo startInfoFromParcel = new ApplicationStartInfo(parcel);
+
+        // Make sure there is no unread data remaining in the parcel, and confirm that the loaded
+        // start info object is equal to the one it was written from. Check dataAvail first as if
+        // that check fails then the next check will fail too, but knowing the status of this check
+        // will tell us that we're missing a read or write. Check the objects are equals second as
+        // if the avail check passes and equals fails, then we know we're reading all the data just
+        // not to the correct fields.
+        assertEquals(0, parcel.dataAvail());
+        assertTrue(startInfo.equals(startInfoFromParcel));
+    }
+
     private static <T> void setFieldValue(Class clazz, Object obj, String fieldName, T val) {
         try {
             Field field = clazz.getDeclaredField(fieldName);
diff --git a/services/tests/mockingservicestests/src/com/android/server/pm/PackageMonitorCallbackHelperTest.java b/services/tests/mockingservicestests/src/com/android/server/pm/PackageMonitorCallbackHelperTest.java
index 24e7242..54ee2a3 100644
--- a/services/tests/mockingservicestests/src/com/android/server/pm/PackageMonitorCallbackHelperTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/pm/PackageMonitorCallbackHelperTest.java
@@ -33,6 +33,7 @@
 import android.os.IRemoteCallback;
 import android.os.Looper;
 import android.os.Process;
+import android.os.UserHandle;
 import android.util.SparseArray;
 
 import org.junit.After;
@@ -340,6 +341,24 @@
         verify(callback, after(WAIT_CALLBACK_CALLED_IN_MS).times(1)).sendResult(any());
     }
 
+    @Test
+    public void registerPackageMonitor_callbackNotInAllowListSystemUidSecondUser_callbackIsCalled()
+            throws Exception {
+        IRemoteCallback callback = createMockPackageMonitorCallback();
+        int userId = 10;
+        int fakeAppId = 12345;
+        SparseArray<int[]> broadcastAllowList = new SparseArray<>();
+        broadcastAllowList.put(userId, new int[]{UserHandle.getUid(userId, fakeAppId)});
+
+        mPackageMonitorCallbackHelper.registerPackageMonitorCallback(callback, userId,
+                UserHandle.getUid(userId, Process.SYSTEM_UID));
+        mPackageMonitorCallbackHelper.notifyPackageMonitor(Intent.ACTION_PACKAGE_ADDED,
+                FAKE_PACKAGE_NAME, createFakeBundle(), new int[]{userId},
+                null /* instantUserIds */, broadcastAllowList, mHandler, null /* filterExtras */);
+
+        verify(callback, after(WAIT_CALLBACK_CALLED_IN_MS).times(1)).sendResult(any());
+    }
+
     private IRemoteCallback createMockPackageMonitorCallback() {
         return spy(new IRemoteCallback.Stub() {
             @Override
diff --git a/services/tests/powerservicetests/src/com/android/server/power/NotifierTest.java b/services/tests/powerservicetests/src/com/android/server/power/NotifierTest.java
index 0702926..a1db182 100644
--- a/services/tests/powerservicetests/src/com/android/server/power/NotifierTest.java
+++ b/services/tests/powerservicetests/src/com/android/server/power/NotifierTest.java
@@ -268,7 +268,7 @@
     }
 
     @Test
-    public void testOnGlobalWakefulnessChangeStarted() throws Exception {
+    public void testOnGlobalWakefulnessChangeStarted() {
         createNotifier();
         // GIVEN system is currently non-interactive
         when(mPowerManagerFlags.isPerDisplayWakeByTouchEnabled()).thenReturn(false);
@@ -294,6 +294,96 @@
     }
 
     @Test
+    public void testOnGroupWakefulnessChangeStarted_newPowerGroup_perDisplayWakeDisabled() {
+        createNotifier();
+        // GIVEN power group is not yet known to Notifier and per-display wake by touch is disabled
+        final int groupId = 123;
+        final int changeReason = PowerManager.WAKE_REASON_TAP;
+        when(mPowerManagerFlags.isPerDisplayWakeByTouchEnabled()).thenReturn(false);
+
+        // WHEN a power group wakefulness change starts
+        mNotifier.onGroupWakefulnessChangeStarted(
+                groupId, WAKEFULNESS_AWAKE, changeReason, /* eventTime= */ 999);
+        mTestLooper.dispatchAll();
+
+        // THEN window manager policy is informed that device has started waking up
+        verify(mPolicy).startedWakingUp(groupId, changeReason);
+        verify(mDisplayManagerInternal, never()).getDisplayIds();
+        verify(mInputManagerInternal, never()).setDisplayInteractivities(any());
+    }
+
+    @Test
+    public void testOnGroupWakefulnessChangeStarted_interactivityNoChange_perDisplayWakeDisabled() {
+        createNotifier();
+        // GIVEN power group is not interactive and per-display wake by touch is disabled
+        final int groupId = 234;
+        final int changeReason = PowerManager.GO_TO_SLEEP_REASON_TIMEOUT;
+        when(mPowerManagerFlags.isPerDisplayWakeByTouchEnabled()).thenReturn(false);
+        mNotifier.onGroupWakefulnessChangeStarted(
+                groupId, WAKEFULNESS_ASLEEP, changeReason, /* eventTime= */ 999);
+        mTestLooper.dispatchAll();
+        verify(mPolicy, times(1)).startedGoingToSleep(groupId, changeReason);
+
+        // WHEN a power wakefulness change to not interactive starts
+        mNotifier.onGroupWakefulnessChangeStarted(
+                groupId, WAKEFULNESS_ASLEEP, changeReason, /* eventTime= */ 999);
+        mTestLooper.dispatchAll();
+
+        // THEN policy is only informed once of non-interactive wakefulness change
+        verify(mPolicy, times(1)).startedGoingToSleep(groupId, changeReason);
+        verify(mDisplayManagerInternal, never()).getDisplayIds();
+        verify(mInputManagerInternal, never()).setDisplayInteractivities(any());
+    }
+
+    @Test
+    public void testOnGroupWakefulnessChangeStarted_interactivityChange_perDisplayWakeDisabled() {
+        createNotifier();
+        // GIVEN power group is not interactive and per-display wake by touch is disabled
+        final int groupId = 345;
+        final int firstChangeReason = PowerManager.GO_TO_SLEEP_REASON_TIMEOUT;
+        when(mPowerManagerFlags.isPerDisplayWakeByTouchEnabled()).thenReturn(false);
+        mNotifier.onGroupWakefulnessChangeStarted(
+                groupId, WAKEFULNESS_ASLEEP, firstChangeReason, /* eventTime= */ 999);
+        mTestLooper.dispatchAll();
+
+        // WHEN a power wakefulness change to interactive starts
+        final int secondChangeReason = PowerManager.WAKE_REASON_TAP;
+        mNotifier.onGroupWakefulnessChangeStarted(
+                groupId, WAKEFULNESS_AWAKE, secondChangeReason, /* eventTime= */ 999);
+        mTestLooper.dispatchAll();
+
+        // THEN policy is informed of the change
+        verify(mPolicy).startedWakingUp(groupId, secondChangeReason);
+        verify(mDisplayManagerInternal, never()).getDisplayIds();
+        verify(mInputManagerInternal, never()).setDisplayInteractivities(any());
+    }
+
+    @Test
+    public void testOnGroupWakefulnessChangeStarted_perDisplayWakeByTouchEnabled() {
+        createNotifier();
+        // GIVEN per-display wake by touch flag is enabled
+        when(mPowerManagerFlags.isPerDisplayWakeByTouchEnabled()).thenReturn(true);
+        final int groupId = 456;
+        final int displayId1 = 1001;
+        final int displayId2 = 1002;
+        final int[] displays = new int[]{displayId1, displayId2};
+        when(mDisplayManagerInternal.getDisplayIds()).thenReturn(IntArray.wrap(displays));
+        when(mDisplayManagerInternal.getDisplayIdsForGroup(groupId)).thenReturn(displays);
+        final int changeReason = PowerManager.WAKE_REASON_TAP;
+
+        // WHEN power group wakefulness change started
+        mNotifier.onGroupWakefulnessChangeStarted(
+                groupId, WAKEFULNESS_AWAKE, changeReason, /* eventTime= */ 999);
+        mTestLooper.dispatchAll();
+
+        // THEN native input manager is updated that the displays are interactive
+        final SparseBooleanArray expectedDisplayInteractivities = new SparseBooleanArray();
+        expectedDisplayInteractivities.put(displayId1, true);
+        expectedDisplayInteractivities.put(displayId2, true);
+        verify(mInputManagerInternal).setDisplayInteractivities(expectedDisplayInteractivities);
+    }
+
+    @Test
     public void testOnWakeLockListener_RemoteException_NoRethrow() throws RemoteException {
         when(mPowerManagerFlags.improveWakelockLatency()).thenReturn(true);
         createNotifier();
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index cbe6700..6ede334 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -230,6 +230,20 @@
 }
 
 java_library {
+    name: "servicestests-utils-ravenwood",
+    srcs: [
+        "utils/**/*.java",
+        "utils/**/*.kt",
+        "utils-mockito/**/*.kt",
+    ],
+    libs: [
+        "android.test.runner.stubs.system",
+        "junit",
+        "mockito-ravenwood-prebuilt",
+    ],
+}
+
+java_library {
     name: "mockito-test-utils",
     srcs: [
         "utils-mockito/**/*.kt",
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/magnification/FullScreenMagnificationControllerTest.java b/services/tests/servicestests/src/com/android/server/accessibility/magnification/FullScreenMagnificationControllerTest.java
index 1426d5d..c4b4afd 100644
--- a/services/tests/servicestests/src/com/android/server/accessibility/magnification/FullScreenMagnificationControllerTest.java
+++ b/services/tests/servicestests/src/com/android/server/accessibility/magnification/FullScreenMagnificationControllerTest.java
@@ -69,6 +69,7 @@
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.internal.R;
+import com.android.internal.os.BackgroundThread;
 import com.android.internal.util.ConcurrentUtils;
 import com.android.internal.util.test.FakeSettingsProvider;
 import com.android.server.LocalServices;
@@ -92,6 +93,8 @@
 import org.testng.Assert;
 
 import java.util.Locale;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
 
 @RunWith(AndroidJUnit4.class)
 public class FullScreenMagnificationControllerTest {
@@ -1440,19 +1443,42 @@
 
     @Test
     public void persistScale_setValueWhenScaleIsOne_nothingChanged() {
+        register(TEST_DISPLAY);
         final float persistedScale =
                 mFullScreenMagnificationController.getPersistedScale(TEST_DISPLAY);
 
         PointF pivotPoint = INITIAL_BOUNDS_LOWER_RIGHT_2X_CENTER;
-        mFullScreenMagnificationController.setScale(DISPLAY_0, 1.0f, pivotPoint.x, pivotPoint.y,
+        mFullScreenMagnificationController.setScale(TEST_DISPLAY, 1.0f, pivotPoint.x, pivotPoint.y,
                 false, SERVICE_ID_1);
         mFullScreenMagnificationController.persistScale(TEST_DISPLAY);
 
+        // persistScale may post a task to a background thread. Let's wait for it completes.
+        waitForBackgroundThread();
         Assert.assertEquals(mFullScreenMagnificationController.getPersistedScale(TEST_DISPLAY),
                 persistedScale);
     }
 
     @Test
+    public void persistScale_setValuesOnMultipleDisplays() {
+        register(DISPLAY_0);
+        register(DISPLAY_1);
+        final PointF pivotPoint = INITIAL_BOUNDS_LOWER_RIGHT_2X_CENTER;
+        mFullScreenMagnificationController.setScale(DISPLAY_0, 3.0f, pivotPoint.x, pivotPoint.y,
+                false, SERVICE_ID_1);
+        mFullScreenMagnificationController.persistScale(DISPLAY_0);
+        mFullScreenMagnificationController.setScale(DISPLAY_1, 4.0f, pivotPoint.x, pivotPoint.y,
+                false, SERVICE_ID_1);
+        mFullScreenMagnificationController.persistScale(DISPLAY_1);
+
+        // persistScale may post a task to a background thread. Let's wait for it completes.
+        waitForBackgroundThread();
+        Assert.assertEquals(mFullScreenMagnificationController.getPersistedScale(DISPLAY_0),
+                3.0f);
+        Assert.assertEquals(mFullScreenMagnificationController.getPersistedScale(DISPLAY_1),
+                4.0f);
+    }
+
+    @Test
     public void testOnContextChanged_alwaysOnFeatureDisabled_resetMagnification() {
         setScaleToMagnifying();
 
@@ -1494,6 +1520,15 @@
         );
     }
 
+    private static void waitForBackgroundThread() {
+        final CompletableFuture<Void> future = new CompletableFuture<>();
+        BackgroundThread.getHandler().post(() -> future.complete(null));
+        try {
+            future.get();
+        } catch (InterruptedException | ExecutionException ignore) {
+        }
+    }
+
     private void setScaleToMagnifying() {
         register(DISPLAY_0);
         float scale = 2.0f;
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/magnification/MagnificationConnectionManagerTest.java b/services/tests/servicestests/src/com/android/server/accessibility/magnification/MagnificationConnectionManagerTest.java
index 87fe6cf..6aa8a32 100644
--- a/services/tests/servicestests/src/com/android/server/accessibility/magnification/MagnificationConnectionManagerTest.java
+++ b/services/tests/servicestests/src/com/android/server/accessibility/magnification/MagnificationConnectionManagerTest.java
@@ -300,7 +300,8 @@
         mMagnificationConnectionManager.setConnection(mMockConnection.getConnection());
         mMagnificationConnectionManager.enableWindowMagnification(TEST_DISPLAY, 2.5f, NaN, NaN);
 
-        mMagnificationConnectionManager.setScale(TEST_DISPLAY, 10.0f);
+        mMagnificationConnectionManager.setScale(TEST_DISPLAY,
+                MagnificationScaleProvider.MAX_SCALE * 2.f);
 
         assertEquals(mMagnificationConnectionManager.getScale(TEST_DISPLAY),
                 MagnificationScaleProvider.MAX_SCALE);
diff --git a/services/tests/servicestests/src/com/android/server/biometrics/UtilsTest.java b/services/tests/servicestests/src/com/android/server/biometrics/UtilsTest.java
index efc2d97..1bea371 100644
--- a/services/tests/servicestests/src/com/android/server/biometrics/UtilsTest.java
+++ b/services/tests/servicestests/src/com/android/server/biometrics/UtilsTest.java
@@ -217,7 +217,13 @@
         doNothing().when(mContext).enforceCallingOrSelfPermission(
                 eq(SET_BIOMETRIC_DIALOG_ADVANCED), any());
 
-        assertTrue(Utils.isValidAuthenticatorConfig(mContext, Authenticators.MANDATORY_BIOMETRICS));
+        if (Flags.mandatoryBiometrics()) {
+            assertTrue(Utils.isValidAuthenticatorConfig(mContext,
+                    Authenticators.MANDATORY_BIOMETRICS));
+        } else {
+            assertFalse(Utils.isValidAuthenticatorConfig(mContext,
+                    Authenticators.MANDATORY_BIOMETRICS));
+        }
 
         // The rest of the bits are not allowed to integrate with the public APIs
         for (int i = 8; i < 32; i++) {
diff --git a/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java b/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java
index ee63d5d..425bb15 100644
--- a/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java
+++ b/services/tests/servicestests/src/com/android/server/media/projection/MediaProjectionManagerServiceTest.java
@@ -33,6 +33,7 @@
 import static android.view.Display.INVALID_DISPLAY;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -51,11 +52,15 @@
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertThrows;
 
+import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.ActivityManagerInternal;
 import android.app.ActivityOptions.LaunchCookie;
 import android.app.AppOpsManager;
+import android.app.Instrumentation;
 import android.app.KeyguardManager;
+import android.app.role.RoleManager;
+import android.companion.AssociationRequest;
 import android.content.Context;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
@@ -68,6 +73,7 @@
 import android.os.Binder;
 import android.os.IBinder;
 import android.os.Looper;
+import android.os.Process;
 import android.os.RemoteException;
 import android.os.UserHandle;
 import android.os.test.TestLooper;
@@ -88,6 +94,7 @@
 import com.android.server.wm.WindowManagerInternal;
 
 import org.junit.After;
+import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -98,6 +105,7 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.util.Objects;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
@@ -312,7 +320,6 @@
         assertThat(mService.getActiveProjectionInfo()).isNotNull();
     }
 
-    @SuppressLint("MissingPermission")
     @EnableFlags(android.companion.virtualdevice.flags
             .Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS)
     @Test
@@ -335,6 +342,36 @@
         assertThat(mService.getActiveProjectionInfo()).isNotNull();
     }
 
+    @EnableFlags(android.companion.virtualdevice.flags
+            .Flags.FLAG_MEDIA_PROJECTION_KEYGUARD_RESTRICTIONS)
+    @Test
+    public void testCreateProjection_keyguardLocked_RoleHeld() {
+        runWithRole(AssociationRequest.DEVICE_PROFILE_APP_STREAMING, () -> {
+            try {
+                mAppInfo.privateFlags |= PRIVATE_FLAG_PRIVILEGED;
+                doReturn(mAppInfo).when(mPackageManager).getApplicationInfoAsUser(anyString(),
+                        any(ApplicationInfoFlags.class), any(UserHandle.class));
+                MediaProjectionManagerService.MediaProjection projection =
+                        mService.createProjectionInternal(Process.myUid(),
+                                mContext.getPackageName(),
+                                TYPE_MIRRORING, /* isPermanentGrant= */ false, UserHandle.CURRENT);
+                doReturn(true).when(mKeyguardManager).isKeyguardLocked();
+                doReturn(PackageManager.PERMISSION_DENIED).when(
+                        mPackageManager).checkPermission(
+                        RECORD_SENSITIVE_CONTENT, projection.packageName);
+
+                projection.start(mIMediaProjectionCallback);
+                projection.notifyVirtualDisplayCreated(10);
+
+                // The projection was started because it was allowed to capture the keyguard.
+                assertWithMessage("Failed to run projection")
+                        .that(mService.getActiveProjectionInfo()).isNotNull();
+            } catch (NameNotFoundException e) {
+                throw new RuntimeException(e);
+            }
+        });
+    }
+
     @Test
     public void testCreateProjection_attemptReuse_noPriorProjectionGrant()
             throws NameNotFoundException {
@@ -1202,6 +1239,47 @@
         return mService.getProjectionInternal(UID, PACKAGE_NAME);
     }
 
+    /**
+     * Run the provided block giving the current context's package the provided role.
+     */
+    @SuppressWarnings("SameParameterValue")
+    private void runWithRole(String role, Runnable block) {
+        Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
+        String packageName = mContext.getPackageName();
+        UserHandle user = instrumentation.getTargetContext().getUser();
+        RoleManager roleManager = Objects.requireNonNull(
+                mContext.getSystemService(RoleManager.class));
+        try {
+            CountDownLatch latch = new CountDownLatch(1);
+            instrumentation.getUiAutomation().adoptShellPermissionIdentity(
+                    Manifest.permission.MANAGE_ROLE_HOLDERS,
+                    Manifest.permission.BYPASS_ROLE_QUALIFICATION);
+
+            roleManager.setBypassingRoleQualification(true);
+            roleManager.addRoleHolderAsUser(role, packageName, /*  flags = */ 0, user,
+                    mContext.getMainExecutor(), success -> {
+                        if (success) {
+                            latch.countDown();
+                        } else {
+                            Assert.fail("Couldn't set role for test (failure) " + role);
+                        }
+                    });
+            assertWithMessage("Couldn't set role for test (timeout) : " + role)
+                    .that(latch.await(1, TimeUnit.SECONDS)).isTrue();
+            block.run();
+
+        } catch (InterruptedException e) {
+            throw new RuntimeException(e);
+        } finally {
+            roleManager.removeRoleHolderAsUser(role, packageName, 0, user,
+                    mContext.getMainExecutor(), (aBool) -> {
+                    });
+            roleManager.setBypassingRoleQualification(false);
+            instrumentation.getUiAutomation()
+                    .dropShellPermissionIdentity();
+        }
+    }
+
     private static class FakeIMediaProjectionCallback extends IMediaProjectionCallback.Stub {
         CountDownLatch mLatch = new CountDownLatch(1);
         @Override
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
index 585df84..22a4f85 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/GroupHelperTest.java
@@ -2662,6 +2662,17 @@
         when(n.isColorized()).thenReturn(true);
         when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
         assertThat(GroupHelper.getSection(notification_colorFg)).isNull();
+
+        NotificationRecord notification_media = spy(getNotificationRecord(mPkg, 0, "", mUser,
+                "", false, IMPORTANCE_LOW));
+        n = mock(Notification.class);
+        sbn = spy(getSbn("package", 0, "0", UserHandle.SYSTEM));
+        when(notification_media.isConversation()).thenReturn(false);
+        when(notification_media.getNotification()).thenReturn(n);
+        when(notification_media.getSbn()).thenReturn(sbn);
+        when(sbn.getNotification()).thenReturn(n);
+        when(n.isMediaNotification()).thenReturn(true);
+        assertThat(GroupHelper.getSection(notification_media)).isNull();
     }
 
     @Test
@@ -2756,7 +2767,7 @@
     @Test
     @EnableFlags({FLAG_NOTIFICATION_FORCE_GROUPING, FLAG_NOTIFICATION_FORCE_GROUP_CONVERSATIONS})
     public void testNonGroupableNotifications_forceGroupConversations() {
-        // Check that there is no valid section for: calls, foreground services
+        // Check that there is no valid section for: calls, foreground services, media notifications
         NotificationRecord notification_call = spy(getNotificationRecord(mPkg, 0, "", mUser,
                 "", false, IMPORTANCE_LOW));
         Notification n = mock(Notification.class);
@@ -2780,6 +2791,17 @@
         when(n.isColorized()).thenReturn(true);
         when(n.isStyle(Notification.CallStyle.class)).thenReturn(false);
         assertThat(GroupHelper.getSection(notification_colorFg)).isNull();
+
+        NotificationRecord notification_media = spy(getNotificationRecord(mPkg, 0, "", mUser,
+                "", false, IMPORTANCE_LOW));
+        n = mock(Notification.class);
+        sbn = spy(getSbn("package", 0, "0", UserHandle.SYSTEM));
+        when(notification_media.isConversation()).thenReturn(false);
+        when(notification_media.getNotification()).thenReturn(n);
+        when(notification_media.getSbn()).thenReturn(sbn);
+        when(sbn.getNotification()).thenReturn(n);
+        when(n.isMediaNotification()).thenReturn(true);
+        assertThat(GroupHelper.getSection(notification_media)).isNull();
     }
 
     @Test
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationAssistantsTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationAssistantsTest.java
index a45b102..797b95b5 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationAssistantsTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationAssistantsTest.java
@@ -15,12 +15,15 @@
  */
 package com.android.server.notification;
 
+import static android.os.UserHandle.USER_ALL;
+
+import static com.google.common.truth.Truth.assertThat;
+
 import static junit.framework.Assert.assertEquals;
 import static junit.framework.Assert.assertFalse;
 import static junit.framework.Assert.assertNotNull;
 import static junit.framework.Assert.assertTrue;
 
-import static junit.framework.Assert.fail;
 import static org.junit.Assert.assertNull;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
@@ -33,6 +36,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.Manifest;
 import android.app.ActivityManager;
 import android.app.INotificationManager;
 import android.content.ComponentName;
@@ -42,24 +46,28 @@
 import android.content.pm.ResolveInfo;
 import android.content.pm.ServiceInfo;
 import android.content.pm.UserInfo;
-import android.os.UserHandle;
 import android.os.UserManager;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.service.notification.Adjustment;
 import android.testing.TestableContext;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.IntArray;
 import android.util.Xml;
-import android.Manifest;
+
+import androidx.test.runner.AndroidJUnit4;
 
 import com.android.internal.util.CollectionUtils;
-import com.android.internal.util.function.TriPredicate;
 import com.android.modules.utils.TypedXmlPullParser;
 import com.android.modules.utils.TypedXmlSerializer;
 import com.android.server.UiServiceTestCase;
 import com.android.server.notification.NotificationManagerService.NotificationAssistants;
 
 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;
 
@@ -71,8 +79,12 @@
 import java.util.Arrays;
 import java.util.List;
 
+@RunWith(AndroidJUnit4.class)
 public class NotificationAssistantsTest extends UiServiceTestCase {
 
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
     @Mock
     private PackageManager mPm;
     @Mock
@@ -98,6 +110,35 @@
 
     ComponentName mCn = new ComponentName("a", "b");
 
+
+    // Helper function to hold mApproved lock, avoid GuardedBy lint errors
+    private boolean isUserSetServicesEmpty(NotificationAssistants assistant, int userId) {
+        synchronized (assistant.mApproved) {
+            return assistant.mUserSetServices.get(userId).isEmpty();
+        }
+    }
+
+    private void writeXmlAndReload(int userId) throws Exception {
+        TypedXmlSerializer serializer = Xml.newFastSerializer();
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        serializer.setOutput(new BufferedOutputStream(baos), "utf-8");
+        serializer.startDocument(null, true);
+        mAssistants.writeXml(serializer, false, userId);
+        serializer.endDocument();
+        serializer.flush();
+
+        //fail(baos.toString("UTF-8"));
+
+        final TypedXmlPullParser parser = Xml.newFastPullParser();
+        parser.setInput(new BufferedInputStream(
+                new ByteArrayInputStream(baos.toByteArray())), null);
+
+        parser.nextTag();
+        mAssistants = spy(mNm.new NotificationAssistants(mContext, mLock, mUserProfiles, miPm));
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
+    }
+
+
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
@@ -164,25 +205,7 @@
         mAssistants.setPackageOrComponentEnabled(current.flattenToString(), userId, true, false,
                 true);
 
-        TypedXmlSerializer serializer = Xml.newFastSerializer();
-        ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        serializer.setOutput(new BufferedOutputStream(baos), "utf-8");
-        serializer.startDocument(null, true);
-        mAssistants.writeXml(serializer, true, userId);
-        serializer.endDocument();
-        serializer.flush();
-
-        //fail(baos.toString("UTF-8"));
-
-        final TypedXmlPullParser parser = Xml.newFastPullParser();
-        parser.setInput(new BufferedInputStream(
-                new ByteArrayInputStream(baos.toByteArray())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
-
-        parser.nextTag();
-        mAssistants = spy(mNm.new NotificationAssistants(mContext, mLock, mUserProfiles, miPm));
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        writeXmlAndReload(USER_ALL);
 
         ArrayMap<Boolean, ArraySet<String>> approved = mAssistants.mApproved.get(0);
         // approved should not be null
@@ -203,11 +226,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
 
         ArrayMap<Boolean, ArraySet<String>> approved = mAssistants.mApproved.get(0);
 
@@ -226,11 +247,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, true,
+        mAssistants.readXml(parser, mNm::canUseManagedServices, true,
                 ActivityManager.getCurrentUser());
 
         ArrayMap<Boolean, ArraySet<String>> approved = mAssistants.mApproved.get(0);
@@ -253,11 +272,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
 
         verify(mAssistants, times(1)).upgradeUserSet();
         assertTrue(mAssistants.mIsUserChanged.get(0));
@@ -273,11 +290,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
 
         verify(mAssistants, times(0)).upgradeUserSet();
         assertTrue(isUserSetServicesEmpty(mAssistants, 0));
@@ -294,11 +309,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
 
         verify(mAssistants, times(0)).upgradeUserSet();
         assertTrue(isUserSetServicesEmpty(mAssistants, 0));
@@ -314,11 +327,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
 
         verify(mAssistants, times(1)).upgradeUserSet();
         assertTrue(isUserSetServicesEmpty(mAssistants, 0));
@@ -334,11 +345,9 @@
         final TypedXmlPullParser parser = Xml.newFastPullParser();
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
-        TriPredicate<String, Integer, String> allowedManagedServicePackages =
-                mNm::canUseManagedServices;
 
         parser.nextTag();
-        mAssistants.readXml(parser, allowedManagedServicePackages, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, mNm::canUseManagedServices, false, USER_ALL);
 
         verify(mAssistants, times(1)).upgradeUserSet();
         assertTrue(isUserSetServicesEmpty(mAssistants, 0));
@@ -361,7 +370,7 @@
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
 
         parser.nextTag();
-        mAssistants.readXml(parser, null, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, null, false, USER_ALL);
 
         assertEquals(1, mAssistants.getAllowedComponents(0).size());
         assertEquals(new ArrayList(Arrays.asList(new ComponentName("a", "a"))),
@@ -378,7 +387,7 @@
         parser.setInput(new BufferedInputStream(
                 new ByteArrayInputStream(xml.toString().getBytes())), null);
         parser.nextTag();
-        mAssistants.readXml(parser, null, false, UserHandle.USER_ALL);
+        mAssistants.readXml(parser, null, false, USER_ALL);
 
         verify(mNm, never()).setDefaultAssistantForUser(anyInt());
         verify(mAssistants, times(1)).addApprovedList(
@@ -529,10 +538,66 @@
         assertEquals(new ArraySet<>(), mAssistants.getDefaultComponents());
     }
 
-    // Helper function to hold mApproved lock, avoid GuardedBy lint errors
-    private boolean isUserSetServicesEmpty(NotificationAssistants assistant, int userId) {
-        synchronized (assistant.mApproved) {
-            return assistant.mUserSetServices.get(userId).isEmpty();
-        }
+    @Test
+    @EnableFlags(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+    public void testSetAdjustmentTypeSupportedState() throws Exception {
+        int userId = ActivityManager.getCurrentUser();
+
+        mAssistants.loadDefaultsFromConfig(true);
+        mAssistants.setPackageOrComponentEnabled(mCn.flattenToString(), userId, true,
+                true, true);
+        ComponentName current = CollectionUtils.firstOrNull(
+                mAssistants.getAllowedComponents(userId));
+        assertNotNull(current);
+
+        assertThat(mAssistants.getUnsupportedAdjustments(userId).size()).isEqualTo(0);
+
+        ManagedServices.ManagedServiceInfo info =
+                mAssistants.new ManagedServiceInfo(null, mCn, userId, false, null, 35, 2345256);
+        mAssistants.setAdjustmentTypeSupportedState(info, Adjustment.KEY_NOT_CONVERSATION, false);
+
+        assertThat(mAssistants.getUnsupportedAdjustments(userId)).contains(
+                Adjustment.KEY_NOT_CONVERSATION);
+        assertThat(mAssistants.getUnsupportedAdjustments(userId).size()).isEqualTo(1);
     }
-}
+
+    @Test
+    @EnableFlags(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+    public void testSetAdjustmentTypeSupportedState_readWriteXml_entries() throws Exception {
+        int userId = ActivityManager.getCurrentUser();
+
+        mAssistants.loadDefaultsFromConfig(true);
+        mAssistants.setPackageOrComponentEnabled(mCn.flattenToString(), userId, true,
+                true, true);
+        ComponentName current = CollectionUtils.firstOrNull(
+                mAssistants.getAllowedComponents(userId));
+        assertNotNull(current);
+
+        ManagedServices.ManagedServiceInfo info =
+                mAssistants.new ManagedServiceInfo(null, mCn, userId, false, null, 35, 2345256);
+        mAssistants.setAdjustmentTypeSupportedState(info, Adjustment.KEY_NOT_CONVERSATION, false);
+
+        writeXmlAndReload(USER_ALL);
+
+        assertThat(mAssistants.getUnsupportedAdjustments(userId)).contains(
+                Adjustment.KEY_NOT_CONVERSATION);
+        assertThat(mAssistants.getUnsupportedAdjustments(userId).size()).isEqualTo(1);
+    }
+
+    @Test
+    @EnableFlags(android.service.notification.Flags.FLAG_NOTIFICATION_CLASSIFICATION)
+    public void testSetAdjustmentTypeSupportedState_readWriteXml_empty() throws Exception {
+        int userId = ActivityManager.getCurrentUser();
+
+        mAssistants.loadDefaultsFromConfig(true);
+        mAssistants.setPackageOrComponentEnabled(mCn.flattenToString(), userId, true,
+                true, true);
+        ComponentName current = CollectionUtils.firstOrNull(
+                mAssistants.getAllowedComponents(userId));
+        assertNotNull(current);
+
+        writeXmlAndReload(USER_ALL);
+
+        assertThat(mAssistants.getUnsupportedAdjustments(userId).size()).isEqualTo(0);
+    }
+}
\ No newline at end of file
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
index bbf2cbd..3c120e1 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
@@ -33,6 +33,7 @@
 import static android.app.Notification.FLAG_BUBBLE;
 import static android.app.Notification.FLAG_CAN_COLORIZE;
 import static android.app.Notification.FLAG_FOREGROUND_SERVICE;
+import static android.app.Notification.FLAG_GROUP_SUMMARY;
 import static android.app.Notification.FLAG_LIFETIME_EXTENDED_BY_DIRECT_REPLY;
 import static android.app.Notification.FLAG_NO_CLEAR;
 import static android.app.Notification.FLAG_NO_DISMISS;
@@ -2872,6 +2873,131 @@
     }
 
     @Test
+    @EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
+    public void testEnqueueNotification_forceGrouped_clearsSummaryFlag() throws Exception {
+        final String originalGroupName = "originalGroup";
+        final String aggregateGroupName = "Aggregate_Test";
+
+        // Old record was a summary and it was auto-grouped
+        final NotificationRecord r =
+                generateNotificationRecord(mTestNotificationChannel, 0, originalGroupName, true);
+        mService.addNotification(r);
+        mService.convertSummaryToNotificationLocked(r.getKey());
+        mService.addAutogroupKeyLocked(r.getKey(), aggregateGroupName, true);
+
+        assertThat(mService.mNotificationList).hasSize(1);
+
+        // Update record is a summary
+        final Notification updatedNotification = generateNotificationRecord(
+                mTestNotificationChannel, 0, originalGroupName, true).getNotification();
+        assertThat(updatedNotification.flags & FLAG_GROUP_SUMMARY).isEqualTo(FLAG_GROUP_SUMMARY);
+
+        mBinderService.enqueueNotificationWithTag(mPkg, mPkg, r.getSbn().getTag(),
+                r.getSbn().getId(), updatedNotification, r.getSbn().getUserId());
+        waitForIdle();
+
+        // Check that FLAG_GROUP_SUMMARY was removed
+        assertThat(mService.mNotificationList).hasSize(1);
+        assertThat(mService.mNotificationList.get(0).getFlags() & FLAG_GROUP_SUMMARY).isEqualTo(0);
+    }
+
+    @Test
+    @EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
+    public void testEnqueueNotification_forceGroupedRegular_updatedAsSummary_clearsSummaryFlag()
+            throws Exception {
+        final String originalGroupName = "originalGroup";
+        final String aggregateGroupName = "Aggregate_Test";
+
+        // Old record was not summary and it was auto-grouped
+        final NotificationRecord r =
+                generateNotificationRecord(mTestNotificationChannel, 0, originalGroupName, false);
+        mService.addNotification(r);
+        mService.addAutogroupKeyLocked(r.getKey(), aggregateGroupName, true);
+        assertThat(mService.mNotificationList).hasSize(1);
+
+        // Update record is a summary
+        final Notification updatedNotification = generateNotificationRecord(
+                mTestNotificationChannel, 0, originalGroupName, true).getNotification();
+        assertThat(updatedNotification.flags & FLAG_GROUP_SUMMARY).isEqualTo(FLAG_GROUP_SUMMARY);
+
+        mBinderService.enqueueNotificationWithTag(mPkg, mPkg, r.getSbn().getTag(),
+                r.getSbn().getId(), updatedNotification, r.getSbn().getUserId());
+        waitForIdle();
+
+        // Check that FLAG_GROUP_SUMMARY was removed
+        assertThat(mService.mNotificationList).hasSize(1);
+        assertThat(mService.mNotificationList.get(0).getFlags() & FLAG_GROUP_SUMMARY).isEqualTo(0);
+    }
+
+    @Test
+    @EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
+    public void testEnqueueNotification_notForceGrouped_dontClearSummaryFlag()
+            throws Exception {
+        final String originalGroupName = "originalGroup";
+
+        // Old record was a summary and it was not auto-grouped
+        final NotificationRecord r =
+                generateNotificationRecord(mTestNotificationChannel, 0, originalGroupName, true);
+        mService.addNotification(r);
+        assertThat(mService.mNotificationList).hasSize(1);
+
+        // Update record is a summary
+        final Notification updatedNotification = generateNotificationRecord(
+                mTestNotificationChannel, 0, originalGroupName, true).getNotification();
+        assertThat(updatedNotification.flags & FLAG_GROUP_SUMMARY).isEqualTo(FLAG_GROUP_SUMMARY);
+
+        mBinderService.enqueueNotificationWithTag(mPkg, mPkg, r.getSbn().getTag(),
+                r.getSbn().getId(), updatedNotification, r.getSbn().getUserId());
+        waitForIdle();
+
+        // Check that FLAG_GROUP_SUMMARY was not removed
+        assertThat(mService.mNotificationList).hasSize(1);
+        assertThat(mService.mNotificationList.get(0).getFlags() & FLAG_GROUP_SUMMARY).isEqualTo(
+                FLAG_GROUP_SUMMARY);
+    }
+
+    @Test
+    @EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
+    public void testRemoveFGSFlagFromNotification_enqueued_forceGrouped_clearsSummaryFlag() {
+        final String originalGroupName = "originalGroup";
+        final String aggregateGroupName = "Aggregate_Test";
+
+        final NotificationRecord r = generateNotificationRecord(mTestNotificationChannel, 0, null,
+                originalGroupName, true);
+        r.getSbn().getNotification().flags &= ~FLAG_GROUP_SUMMARY;
+        r.setOverrideGroupKey(aggregateGroupName);
+        mService.addEnqueuedNotification(r);
+
+        mInternalService.removeForegroundServiceFlagFromNotification(
+                mPkg, r.getSbn().getId(), r.getSbn().getUserId());
+        waitForIdle();
+
+        assertThat(mService.mEnqueuedNotifications).hasSize(1);
+        assertThat(mService.mEnqueuedNotifications.get(0).getFlags() & FLAG_GROUP_SUMMARY)
+                .isEqualTo(0);
+    }
+
+    @Test
+    @EnableFlags(FLAG_NOTIFICATION_FORCE_GROUPING)
+    public void testRemoveFGSFlagFromNotification_posted_forceGrouped_clearsSummaryFlag() {
+        final String originalGroupName = "originalGroup";
+        final String aggregateGroupName = "Aggregate_Test";
+
+        final NotificationRecord r = generateNotificationRecord(mTestNotificationChannel, 0, null,
+                originalGroupName, true);
+        r.getSbn().getNotification().flags &= ~FLAG_GROUP_SUMMARY;
+        r.setOverrideGroupKey(aggregateGroupName);
+        mService.addNotification(r);
+
+        mInternalService.removeForegroundServiceFlagFromNotification(
+                mPkg, r.getSbn().getId(), r.getSbn().getUserId());
+        waitForIdle();
+
+        assertThat(mService.mNotificationList).hasSize(1);
+        assertThat(mService.mNotificationList.get(0).getFlags() & FLAG_GROUP_SUMMARY).isEqualTo(0);
+    }
+
+    @Test
     public void testCancelAllNotifications_IgnoreForegroundService() throws Exception {
         when(mAmi.applyForegroundServiceNotification(
                 any(), anyString(), anyInt(), anyString(), anyInt())).thenReturn(SHOW_IMMEDIATELY);
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java
index 91eb2ed..c6cc941 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeDiffTest.java
@@ -60,6 +60,7 @@
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -69,6 +70,7 @@
 import platform.test.runner.parameterized.ParameterizedAndroidJunit4;
 import platform.test.runner.parameterized.Parameters;
 
+
 @SmallTest
 @RunWith(ParameterizedAndroidJunit4.class)
 @TestableLooper.RunWithLooper
@@ -127,7 +129,7 @@
         ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
         ArrayMap<String, Object> expectedTo = new ArrayMap<>();
         List<Field> fieldsForDiff = getFieldsForDiffCheck(
-                ZenModeConfig.ZenRule.class, getZenRuleExemptFields());
+                ZenModeConfig.ZenRule.class, getZenRuleExemptFields(), false);
         generateFieldDiffs(r1, r2, fieldsForDiff, expectedFrom, expectedTo);
 
         ZenModeDiff.RuleDiff d = new ZenModeDiff.RuleDiff(r1, r2);
@@ -145,6 +147,337 @@
         }
     }
 
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void testRuleDiff_toStringNoChangeAddRemove() throws Exception {
+        // Start with two identical rules
+        ZenModeConfig.ZenRule r1 = makeRule();
+        ZenModeConfig.ZenRule r2 = makeRule();
+
+        ZenModeDiff.RuleDiff d = new ZenModeDiff.RuleDiff(r1, r2);
+        assertThat(d.toString()).isEqualTo("ZenRuleDiff{no changes}");
+
+        d = new ZenModeDiff.RuleDiff(r1, null);
+        assertThat(d.toString()).isEqualTo("ZenRuleDiff{removed}");
+
+        d = new ZenModeDiff.RuleDiff(null, r2);
+        assertThat(d.toString()).isEqualTo("ZenRuleDiff{added}");
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void testRuleDiff_toString() throws Exception {
+        // Start with two identical rules
+        ZenModeConfig.ZenRule r1 = makeRule();
+        ZenModeConfig.ZenRule r2 = makeRule();
+
+        ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+        ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+        List<Field> fieldsForDiff = getFieldsForDiffCheck(
+                ZenModeConfig.ZenRule.class, getZenRuleExemptFields(), false);
+        generateFieldDiffs(r1, r2, fieldsForDiff, expectedFrom, expectedTo);
+
+        ZenModeDiff.RuleDiff d = new ZenModeDiff.RuleDiff(r1, r2);
+        assertThat(d.toString()).isEqualTo("ZenRuleDiff{"
+                + "enabled:true->false, "
+                + "conditionOverride:2->1, "
+                + "name:string1->string2, "
+                + "zenMode:2->1, "
+                + "conditionId:null->, "
+                + "condition:null->Condition["
+                + "state=STATE_TRUE,"
+                + "id=hello:,"
+                + "summary=,"
+                + "line1=,"
+                + "line2=,"
+                + "icon=-1,"
+                + "source=SOURCE_UNKNOWN,"
+                + "flags=2], "
+                + "component:null->ComponentInfo{b/b}, "
+                + "configurationActivity:null->ComponentInfo{a/a}, "
+                + "id:string1->string2, "
+                + "creationTime:200->100, "
+                + "enabler:string1->string2, "
+                + "zenPolicy:ZenPolicyDiff{"
+                + "mPriorityCategories_Reminders:1->2, "
+                + "mPriorityCategories_Events:1->2, "
+                + "mPriorityCategories_Messages:1->2, "
+                + "mPriorityCategories_Calls:1->2, "
+                + "mPriorityCategories_RepeatCallers:1->2, "
+                + "mPriorityCategories_Alarms:1->2, "
+                + "mPriorityCategories_Media:1->2, "
+                + "mPriorityCategories_System:1->2, "
+                + "mPriorityCategories_Conversations:1->2, "
+                + "mVisualEffects_FullScreenIntent:1->2, "
+                + "mVisualEffects_Lights:1->2, "
+                + "mVisualEffects_Peek:1->2, "
+                + "mVisualEffects_StatusBar:1->2, "
+                + "mVisualEffects_Badge:1->2, "
+                + "mVisualEffects_Ambient:1->2, "
+                + "mVisualEffects_NotificationList:1->2, "
+                + "mPriorityMessages:2->1, "
+                + "mPriorityCalls:2->1, "
+                + "mConversationSenders:2->1, "
+                + "mAllowChannels:2->1}, "
+                + "modified:true->false, "
+                + "pkg:string1->string2, "
+                + "zenDeviceEffects:ZenDeviceEffectsDiff{"
+                + "mGrayscale:true->false, "
+                + "mSuppressAmbientDisplay:true->false, "
+                + "mDimWallpaper:true->false, "
+                + "mNightMode:true->false, "
+                + "mDisableAutoBrightness:true->false, "
+                + "mDisableTapToWake:true->false, "
+                + "mDisableTiltToWake:true->false, "
+                + "mDisableTouch:true->false, "
+                + "mMinimizeRadioUsage:true->false, "
+                + "mMaximizeDoze:true->false, "
+                + "mExtraEffects:[effect1]->[effect2]}, "
+                + "triggerDescription:string1->string2, "
+                + "type:2->1, "
+                + "allowManualInvocation:true->false, "
+                + "iconResName:string1->string2, "
+                + "legacySuppressedEffects:2->1}");
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void testRuleDiff_toStringNullStartPolicy() throws Exception {
+        // Start with two identical rules
+        ZenModeConfig.ZenRule r1 = makeRule();
+        ZenModeConfig.ZenRule r2 = makeRule();
+
+        ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+        ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+        List<Field> fieldsForDiff = getFieldsForDiffCheck(
+                ZenModeConfig.ZenRule.class, getZenRuleExemptFields(), false);
+        generateFieldDiffs(r1, r2, fieldsForDiff, expectedFrom, expectedTo);
+
+        // Create a ZenRule with ZenDeviceEffects and ZenPolicy as null.
+        r1.zenPolicy = null;
+        r1.zenDeviceEffects = null;
+        ZenModeDiff.RuleDiff d = new ZenModeDiff.RuleDiff(r1, r2);
+        assertThat(d.toString()).isEqualTo("ZenRuleDiff{"
+                + "enabled:true->false, "
+                + "conditionOverride:2->1, "
+                + "name:string1->string2, "
+                + "zenMode:2->1, "
+                + "conditionId:null->, "
+                + "condition:null->Condition["
+                + "state=STATE_TRUE,"
+                + "id=hello:,"
+                + "summary=,"
+                + "line1=,"
+                + "line2=,"
+                + "icon=-1,"
+                + "source=SOURCE_UNKNOWN,"
+                + "flags=2], "
+                + "component:null->ComponentInfo{b/b}, "
+                + "configurationActivity:null->ComponentInfo{a/a}, "
+                + "id:string1->string2, "
+                + "creationTime:200->100, "
+                + "enabler:string1->string2, "
+                + "zenPolicy:ZenPolicyDiff{added}, "
+                + "modified:true->false, "
+                + "pkg:string1->string2, "
+                + "zenDeviceEffects:ZenDeviceEffectsDiff{added}, "
+                + "triggerDescription:string1->string2, "
+                + "type:2->1, "
+                + "allowManualInvocation:true->false, "
+                + "iconResName:string1->string2, "
+                + "legacySuppressedEffects:2->1}");
+    }
+
+    @Test
+    public void testDeviceEffectsDiff_addRemoveSame() {
+        // Test add, remove, and both sides same
+        ZenDeviceEffects effects = new ZenDeviceEffects.Builder().build();
+
+        // Both sides same rule
+        ZenModeDiff.DeviceEffectsDiff dSame = new ZenModeDiff.DeviceEffectsDiff(effects, effects);
+        assertFalse(dSame.hasDiff());
+
+        // from existent rule to null: expect deleted
+        ZenModeDiff.DeviceEffectsDiff deleted = new ZenModeDiff.DeviceEffectsDiff(effects, null);
+        assertTrue(deleted.hasDiff());
+        assertTrue(deleted.wasRemoved());
+
+        // from null to new rule: expect added
+        ZenModeDiff.DeviceEffectsDiff added = new ZenModeDiff.DeviceEffectsDiff(null, effects);
+        assertTrue(added.hasDiff());
+        assertTrue(added.wasAdded());
+    }
+
+    @Test
+    public void testDeviceEffectsDiff_fieldDiffs() throws Exception {
+        // Start these the same
+        ZenDeviceEffects effects1 = new ZenDeviceEffects.Builder().build();
+        ZenDeviceEffects effects2 = new ZenDeviceEffects.Builder().build();
+
+        // maps mapping field name -> expected output value as we set diffs
+        ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+        ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+        List<Field> fieldsForDiff = getFieldsForDiffCheck(
+                ZenDeviceEffects.class, Collections.emptySet() /*no exempt fields*/, true);
+        generateFieldDiffs(effects1, effects2, fieldsForDiff, expectedFrom, expectedTo);
+
+        ZenModeDiff.DeviceEffectsDiff d = new ZenModeDiff.DeviceEffectsDiff(effects1, effects2);
+        assertTrue(d.hasDiff());
+
+        // Now diff them and check that each of the fields has a diff
+        for (Field f : fieldsForDiff) {
+            String name = f.getName();
+            assertNotNull("diff not found for field: " + name, d.getDiffForField(name));
+            assertTrue(d.getDiffForField(name).hasDiff());
+            assertTrue("unexpected field: " + name, expectedFrom.containsKey(name));
+            assertTrue("unexpected field: " + name, expectedTo.containsKey(name));
+            assertEquals(expectedFrom.get(name), d.getDiffForField(name).from());
+            assertEquals(expectedTo.get(name), d.getDiffForField(name).to());
+        }
+    }
+
+    @Test
+    public void testDeviceEffectsDiff_toString() throws Exception {
+        // Ensure device effects toString is readable.
+        ZenDeviceEffects effects1 = new ZenDeviceEffects.Builder().build();
+        ZenDeviceEffects effects2 = new ZenDeviceEffects.Builder().build();
+
+        ZenModeDiff.DeviceEffectsDiff d = new ZenModeDiff.DeviceEffectsDiff(effects1, effects2);
+        assertThat(d.toString()).isEqualTo("ZenDeviceEffectsDiff{no changes}");
+
+        d = new ZenModeDiff.DeviceEffectsDiff(effects1, null);
+        assertThat(d.toString()).isEqualTo("ZenDeviceEffectsDiff{removed}");
+
+        d = new ZenModeDiff.DeviceEffectsDiff(null, effects2);
+        assertThat(d.toString()).isEqualTo("ZenDeviceEffectsDiff{added}");
+
+        ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+        ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+        List<Field> fieldsForDiff = getFieldsForDiffCheck(
+                ZenDeviceEffects.class, Collections.emptySet() /*no exempt fields*/, true);
+        generateFieldDiffs(effects1, effects2, fieldsForDiff, expectedFrom, expectedTo);
+
+        d = new ZenModeDiff.DeviceEffectsDiff(effects1, effects2);
+        assertThat(d.toString()).isEqualTo("ZenDeviceEffectsDiff{"
+                + "mGrayscale:true->false, "
+                + "mSuppressAmbientDisplay:true->false, "
+                + "mDimWallpaper:true->false, "
+                + "mNightMode:true->false, "
+                + "mDisableAutoBrightness:true->false, "
+                + "mDisableTapToWake:true->false, "
+                + "mDisableTiltToWake:true->false, "
+                + "mDisableTouch:true->false, "
+                + "mMinimizeRadioUsage:true->false, "
+                + "mMaximizeDoze:true->false, "
+                + "mExtraEffects:[effect1]->[effect2]}");
+    }
+
+
+    @Test
+    public void testPolicyDiff_addRemoveSame() {
+        // Test add, remove, and both sides same
+        ZenPolicy effects = new ZenPolicy.Builder().build();
+
+        // Both sides same rule
+        ZenModeDiff.PolicyDiff dSame = new ZenModeDiff.PolicyDiff(effects, effects);
+        assertFalse(dSame.hasDiff());
+
+        // from existent rule to null: expect deleted
+        ZenModeDiff.PolicyDiff deleted = new ZenModeDiff.PolicyDiff(effects, null);
+        assertTrue(deleted.hasDiff());
+        assertTrue(deleted.wasRemoved());
+
+        // from null to new rule: expect added
+        ZenModeDiff.PolicyDiff added = new ZenModeDiff.PolicyDiff(null, effects);
+        assertTrue(added.hasDiff());
+        assertTrue(added.wasAdded());
+    }
+
+    @Test
+    public void testPolicyDiff_fieldDiffs() throws Exception {
+        // Start these the same
+        ZenPolicy policy1 = new ZenPolicy.Builder().build();
+        ZenPolicy policy2 = new ZenPolicy.Builder().build();
+
+        // maps mapping field name -> expected output value as we set diffs
+        ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+        ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+
+        List<Field> fieldsForDiff = getFieldsForDiffCheck(ZenPolicy.class, Collections.emptySet(),
+                false);
+        generateFieldDiffsForZenPolicy(policy1, policy2, fieldsForDiff, expectedFrom, expectedTo);
+
+        ZenModeDiff.PolicyDiff d = new ZenModeDiff.PolicyDiff(policy1, policy2);
+        assertTrue(d.hasDiff());
+
+        // Now diff them and check that each of the fields has a diff.
+        // Because ZenPolicy consolidates priority category and visual effect fields in a list,
+        // we cannot use reflection on ZenPolicy to get the list of fields.
+        ArrayList<String> diffFields = new ArrayList<>();
+        Field[] fields = ZenModeDiff.PolicyDiff.class.getDeclaredFields();
+
+        for (Field field : fields) {
+            int m = field.getModifiers();
+            if (Modifier.isStatic(m) && Modifier.isFinal(m)) {
+                diffFields.add((String) field.get(policy1));
+            }
+        }
+
+        for (String name : diffFields) {
+            assertNotNull("diff not found for field: " + name, d.getDiffForField(name));
+            assertTrue(d.getDiffForField(name).hasDiff());
+            assertTrue("unexpected field: " + name, expectedFrom.containsKey(name));
+            assertTrue("unexpected field: " + name, expectedTo.containsKey(name));
+            assertEquals(expectedFrom.get(name), d.getDiffForField(name).from());
+            assertEquals(expectedTo.get(name), d.getDiffForField(name).to());
+        }
+    }
+
+    @Test
+    public void testPolicyDiff_toString() throws Exception {
+        // Ensure device effects toString is readable.
+        ZenPolicy policy1 = new ZenPolicy.Builder().build();
+        ZenPolicy policy2 = new ZenPolicy.Builder().build();
+
+        ZenModeDiff.PolicyDiff d = new ZenModeDiff.PolicyDiff(policy1, policy2);
+        assertThat(d.toString()).isEqualTo("ZenPolicyDiff{no changes}");
+
+        d = new ZenModeDiff.PolicyDiff(policy1, null);
+        assertThat(d.toString()).isEqualTo("ZenPolicyDiff{removed}");
+
+        d = new ZenModeDiff.PolicyDiff(null, policy2);
+        assertThat(d.toString()).isEqualTo("ZenPolicyDiff{added}");
+
+        ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+        ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+        List<Field> fieldsForDiff = getFieldsForDiffCheck(
+                ZenPolicy.class, Collections.emptySet() /*no exempt fields*/, false);
+        generateFieldDiffsForZenPolicy(policy1, policy2, fieldsForDiff, expectedFrom, expectedTo);
+
+        d = new ZenModeDiff.PolicyDiff(policy1, policy2);
+        assertThat(d.toString()).isEqualTo("ZenPolicyDiff{"
+                + "mPriorityCategories_Reminders:1->2, "
+                + "mPriorityCategories_Events:1->2, "
+                + "mPriorityCategories_Messages:1->2, "
+                + "mPriorityCategories_Calls:1->2, "
+                + "mPriorityCategories_RepeatCallers:1->2, "
+                + "mPriorityCategories_Alarms:1->2, "
+                + "mPriorityCategories_Media:1->2, "
+                + "mPriorityCategories_System:1->2, "
+                + "mPriorityCategories_Conversations:1->2, "
+                + "mVisualEffects_FullScreenIntent:1->2, "
+                + "mVisualEffects_Lights:1->2, "
+                + "mVisualEffects_Peek:1->2, "
+                + "mVisualEffects_StatusBar:1->2, "
+                + "mVisualEffects_Badge:1->2, "
+                + "mVisualEffects_Ambient:1->2, "
+                + "mVisualEffects_NotificationList:1->2, "
+                + "mPriorityMessages:2->1, "
+                + "mPriorityCalls:2->1, "
+                + "mConversationSenders:2->1, "
+                + "mAllowChannels:2->1}");
+    }
+
     private static Set<String> getZenRuleExemptFields() {
         // "Metadata" fields are never compared.
         Set<String> exemptFields = new LinkedHashSet<>(
@@ -194,7 +527,7 @@
         ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
         ArrayMap<String, Object> expectedTo = new ArrayMap<>();
         List<Field> fieldsForDiff = getFieldsForDiffCheck(
-                ZenModeConfig.class, getConfigExemptAndFlaggedFields());
+                ZenModeConfig.class, getConfigExemptAndFlaggedFields(), false);
         generateFieldDiffs(c1, c2, fieldsForDiff, expectedFrom, expectedTo);
 
         ZenModeDiff.ConfigDiff d = new ZenModeDiff.ConfigDiff(c1, c2);
@@ -223,7 +556,7 @@
         ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
         ArrayMap<String, Object> expectedTo = new ArrayMap<>();
         List<Field> fieldsForDiff = getFieldsForDiffCheck(
-                ZenModeConfig.class, ZEN_MODE_CONFIG_EXEMPT_FIELDS);
+                ZenModeConfig.class, ZEN_MODE_CONFIG_EXEMPT_FIELDS, false);
         generateFieldDiffs(c1, c2, fieldsForDiff, expectedFrom, expectedTo);
 
         ZenModeDiff.ConfigDiff d = new ZenModeDiff.ConfigDiff(c1, c2);
@@ -359,17 +692,23 @@
 
     // Get the fields on which we would want to check a diff. The requirements are: not final or/
     // static (as these should/can never change), and not in a specific list that's exempted.
-    private List<Field> getFieldsForDiffCheck(Class<?> c, Set<String> exemptNames)
+    private List<Field> getFieldsForDiffCheck(Class<?> c, Set<String> exemptNames,
+                                              boolean includeFinal)
             throws SecurityException {
         Field[] fields = c.getDeclaredFields();
         ArrayList<Field> out = new ArrayList<>();
 
         for (Field field : fields) {
             // Check for exempt reasons
+            // Anything in provided exemptNames is skipped.
+            if (exemptNames.contains(field.getName())) {
+                continue;
+            }
             int m = field.getModifiers();
-            if (Modifier.isFinal(m)
-                    || Modifier.isStatic(m)
-                    || exemptNames.contains(field.getName())) {
+            if (Modifier.isStatic(m)) {
+                continue;
+            }
+            if (!includeFinal && Modifier.isFinal(m)) {
                 continue;
             }
             out.add(field);
@@ -377,6 +716,106 @@
         return out;
     }
 
+    // Generate a set of diffs for two ZenPolicy objects. Store the results in the provided
+    // expectation maps.
+    private void generateFieldDiffsForZenPolicy(ZenPolicy a, ZenPolicy b, List<Field> fields,
+            ArrayMap<String, Object> expectedA, ArrayMap<String, Object> expectedB)
+            throws Exception {
+        // Loop through fields for which we want to check diffs, set a diff and keep track of
+        // what we set.
+        for (Field f : fields) {
+            f.setAccessible(true);
+            // Just double-check also that the fields actually are for the class declared
+            assertEquals(f.getDeclaringClass(), a.getClass());
+            Class<?> t = f.getType();
+
+            if (int.class.equals(t)) {
+                // these will not be valid for arbitrary int enums, but should suffice for a diff.
+                f.setInt(a, 2);
+                expectedA.put(f.getName(), 2);
+                f.setInt(b, 1);
+                expectedB.put(f.getName(), 1);
+            } else if (List.class.equals(t)) {
+                // Fieds mPriorityCategories and mVisualEffects store multiple values and
+                // must be treated separately.
+                List<Integer> aList = (ArrayList<Integer>) f.get(a);
+                List<Integer> bList = (ArrayList<Integer>) f.get(b);
+                if (f.getName().equals("mPriorityCategories")) {
+                    // PRIORITY_CATEGORY_REMINDERS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 0,
+                            "mPriorityCategories_Reminders");
+                    // PRIORITY_CATEGORY_EVENTS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 1,
+                            "mPriorityCategories_Events");
+                    // PRIORITY_CATEGORY_MESSAGES
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 2,
+                            "mPriorityCategories_Messages");
+                    // PRIORITY_CATEGORY_CALLS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 3,
+                            "mPriorityCategories_Calls");
+                    // PRIORITY_CATEGORY_REPEAT_CALLERS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 4,
+                            "mPriorityCategories_RepeatCallers");
+                    // PRIORITY_CATEGORY_ALARMS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 5,
+                            "mPriorityCategories_Alarms");
+                    // PRIORITY_CATEGORY_MEDIA
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 6,
+                            "mPriorityCategories_Media");
+                    // PRIORITY_CATEGORY_SYSTEM
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 7,
+                            "mPriorityCategories_System");
+                    // PRIORITY_CATEGORY_CONVERSATIONS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 8,
+                            "mPriorityCategories_Conversations");
+                    // Assert that we've set every PriorityCategory enum value.
+                    assertThat(Collections.frequency(aList, ZenPolicy.STATE_ALLOW))
+                            .isEqualTo(ZenPolicy.NUM_PRIORITY_CATEGORIES);
+                } else if (f.getName().equals("mVisualEffects")) {
+                    // VISUAL_EFFECT_FULL_SCREEN_INTENT
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 0,
+                            "mVisualEffects_FullScreenIntent");
+                    // VISUAL_EFFECT_LIGHTS
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 1,
+                            "mVisualEffects_Lights");
+                    // VISUAL_EFFECT_PEEK
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 2,
+                            "mVisualEffects_Peek");
+                    // VISUAL_EFFECT_STATUS_BAR
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 3,
+                            "mVisualEffects_StatusBar");
+                    // VISUAL_EFFECT_BADGE
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 4,
+                            "mVisualEffects_Badge");
+                    // VISUAL_EFFECT_AMBIENT
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 5,
+                            "mVisualEffects_Ambient");
+                    // VISUAL_EFFECT_NOTIFICATION_LIST
+                    setPolicyListValueDiff(aList, bList, expectedA, expectedB, 6,
+                            "mVisualEffects_NotificationList");
+                    // Assert that we've set every VisualeEffect enum value.
+                    assertThat(Collections.frequency(aList, ZenPolicy.STATE_ALLOW))
+                            .isEqualTo(ZenPolicy.NUM_VISUAL_EFFECTS);
+                } else {
+                    // Any other lists that are added should be added to the diff.
+                    fail("could not generate field diffs for policy list: " + f.getName());
+                }
+            }
+        }
+    }
+
+    // Helper function to create a diff in two list values at a given index, and record that
+    // diff's values in the associated expected maps under the provided field name.
+    private void setPolicyListValueDiff(List<Integer> aList, List<Integer> bList,
+                                        ArrayMap<String, Object> expectedA,
+                                        ArrayMap<String, Object> expectedB,
+                                        int index, String fieldName) {
+        aList.set(index, ZenPolicy.STATE_ALLOW);
+        expectedA.put(fieldName, ZenPolicy.STATE_ALLOW);
+        bList.set(index, ZenPolicy.STATE_DISALLOW);
+        expectedB.put(fieldName, ZenPolicy.STATE_DISALLOW);
+    }
+
     // Generate a set of generic diffs for the specified two objects and the fields to generate
     // diffs for, and store the results in the provided expectation maps to be able to check the
     // output later.
@@ -420,6 +859,44 @@
                 expectedA.put(f.getName(), "string1");
                 f.set(b, "string2");
                 expectedB.put(f.getName(), "string2");
+            } else if (Set.class.equals(t)) {
+                Set<String> aSet = Set.of("effect1");
+                Set<String> bSet = Set.of("effect2");
+                f.set(a, aSet);
+                expectedA.put(f.getName(), aSet);
+                f.set(b, bSet);
+                expectedB.put(f.getName(), bSet);
+            } else if (ZenDeviceEffects.class.equals(t)) {
+                // Recurse into generating field diffs for ZenDeviceEffects.
+                ZenDeviceEffects effects1 = new ZenDeviceEffects.Builder().build();
+                ZenDeviceEffects effects2 = new ZenDeviceEffects.Builder().build();
+                // maps mapping field name -> expected output value as we set diffs
+                ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+                ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+
+                List<Field> fieldsForDiff = getFieldsForDiffCheck(
+                        ZenDeviceEffects.class, Collections.emptySet() /*no exempt fields*/, true);
+                generateFieldDiffs(effects1, effects2, fieldsForDiff, expectedFrom, expectedTo);
+                f.set(a, effects1);
+                expectedA.put(f.getName(), effects1);
+                f.set(b, effects2);
+                expectedB.put(f.getName(), effects2);
+            } else if (ZenPolicy.class.equals(t)) {
+                // Recurse into generating field diffs for ZenPolicy.
+                ZenPolicy policy1 = new ZenPolicy.Builder().build();
+                ZenPolicy policy2 = new ZenPolicy.Builder().build();
+                // maps mapping field name -> expected output value as we set diffs
+                ArrayMap<String, Object> expectedFrom = new ArrayMap<>();
+                ArrayMap<String, Object> expectedTo = new ArrayMap<>();
+
+                List<Field> fieldsForDiff = getFieldsForDiffCheck(ZenPolicy.class,
+                        Collections.emptySet(), false);
+                generateFieldDiffsForZenPolicy(policy1, policy2, fieldsForDiff, expectedFrom,
+                        expectedTo);
+                f.set(a, policy1);
+                expectedA.put(f.getName(), policy1);
+                f.set(b, policy2);
+                expectedB.put(f.getName(), policy2);
             } else {
                 // catch-all for other types: have the field be "added"
                 f.set(a, null);
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeFilteringTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeFilteringTest.java
index b997f5d..a49f5a8 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeFilteringTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeFilteringTest.java
@@ -26,8 +26,6 @@
 import static android.app.NotificationManager.Policy.PRIORITY_CATEGORY_MESSAGES;
 import static android.app.NotificationManager.Policy.PRIORITY_CATEGORY_REPEAT_CALLERS;
 import static android.app.NotificationManager.Policy.PRIORITY_SENDERS_ANY;
-import static android.app.NotificationManager.Policy.SUPPRESSED_EFFECT_STATUS_BAR;
-import static android.provider.Settings.Global.ZEN_MODE_ALARMS;
 import static android.provider.Settings.Global.ZEN_MODE_IMPORTANT_INTERRUPTIONS;
 import static android.provider.Settings.Global.ZEN_MODE_NO_INTERRUPTIONS;
 import static android.provider.Settings.Global.ZEN_MODE_OFF;
@@ -57,7 +55,6 @@
 
 import androidx.test.filters.SmallTest;
 
-import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
 import com.android.internal.util.NotificationMessagingUtil;
 import com.android.server.UiServiceTestCase;
 
@@ -188,49 +185,6 @@
     }
 
     @Test
-    public void testSuppressDNDInfo_yes_VisEffectsAllowed() {
-        NotificationRecord r = getNotificationRecord();
-        when(r.getSbn().getPackageName()).thenReturn("android");
-        when(r.getSbn().getId()).thenReturn(SystemMessage.NOTE_ZEN_UPGRADE);
-        Policy policy = new Policy(0, 0, 0, Policy.getAllSuppressedVisualEffects()
-                - SUPPRESSED_EFFECT_STATUS_BAR, 0);
-
-        assertTrue(mZenModeFiltering.shouldIntercept(ZEN_MODE_IMPORTANT_INTERRUPTIONS, policy, r));
-    }
-
-    @Test
-    public void testSuppressDNDInfo_yes_WrongId() {
-        NotificationRecord r = getNotificationRecord();
-        when(r.getSbn().getPackageName()).thenReturn("android");
-        when(r.getSbn().getId()).thenReturn(SystemMessage.NOTE_ACCOUNT_CREDENTIAL_PERMISSION);
-        Policy policy = new Policy(0, 0, 0, Policy.getAllSuppressedVisualEffects(), 0);
-
-        assertTrue(mZenModeFiltering.shouldIntercept(ZEN_MODE_IMPORTANT_INTERRUPTIONS, policy, r));
-    }
-
-    @Test
-    public void testSuppressDNDInfo_yes_WrongPackage() {
-        NotificationRecord r = getNotificationRecord();
-        when(r.getSbn().getPackageName()).thenReturn("android2");
-        when(r.getSbn().getId()).thenReturn(SystemMessage.NOTE_ZEN_UPGRADE);
-        Policy policy = new Policy(0, 0, 0, Policy.getAllSuppressedVisualEffects(), 0);
-
-        assertTrue(mZenModeFiltering.shouldIntercept(ZEN_MODE_IMPORTANT_INTERRUPTIONS, policy, r));
-    }
-
-    @Test
-    public void testSuppressDNDInfo_no() {
-        NotificationRecord r = getNotificationRecord();
-        when(r.getSbn().getPackageName()).thenReturn("android");
-        when(r.getSbn().getId()).thenReturn(SystemMessage.NOTE_ZEN_UPGRADE);
-        Policy policy = new Policy(0, 0, 0, Policy.getAllSuppressedVisualEffects(), 0);
-
-        assertFalse(mZenModeFiltering.shouldIntercept(ZEN_MODE_IMPORTANT_INTERRUPTIONS, policy, r));
-        assertFalse(mZenModeFiltering.shouldIntercept(ZEN_MODE_ALARMS, policy, r));
-        assertFalse(mZenModeFiltering.shouldIntercept(ZEN_MODE_NO_INTERRUPTIONS, policy, r));
-    }
-
-    @Test
     public void testSuppressAnything_yes_ZenModeOff() {
         NotificationRecord r = getNotificationRecord();
         when(r.getSbn().getPackageName()).thenReturn("bananas");
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
index d4cba8d..294027b 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
@@ -169,7 +169,6 @@
 
 import com.android.internal.R;
 import com.android.internal.config.sysui.TestableFlagResolver;
-import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
 import com.android.modules.utils.TypedXmlPullParser;
 import com.android.modules.utils.TypedXmlSerializer;
 import com.android.os.AtomsProto;
@@ -747,54 +746,6 @@
     }
 
     @Test
-    public void testZenUpgradeNotification() {
-        /**
-         * Commit a485ec65b5ba947d69158ad90905abf3310655cf disabled DND status change
-         * notification on watches. So, assume that the device is not watch.
-         */
-        when(mContext.getPackageManager()).thenReturn(mPackageManager);
-        when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)).thenReturn(false);
-
-        // shows zen upgrade notification if stored settings says to shows,
-        // zen has not been updated, boot is completed
-        // and we're setting zen mode on
-        Settings.Secure.putInt(mContentResolver, Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 1);
-        Settings.Secure.putInt(mContentResolver, Settings.Secure.ZEN_SETTINGS_UPDATED, 0);
-        mZenModeHelper.mIsSystemServicesReady = true;
-        mZenModeHelper.mConsolidatedPolicy = new Policy(0, 0, 0, 0, 0, 0);
-        mZenModeHelper.setZenModeSetting(ZEN_MODE_IMPORTANT_INTERRUPTIONS);
-
-        verify(mNotificationManager, times(1)).notify(eq(ZenModeHelper.TAG),
-                eq(SystemMessage.NOTE_ZEN_UPGRADE), any());
-        assertEquals(0, Settings.Secure.getInt(mContentResolver,
-                Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, -1));
-    }
-
-    @Test
-    public void testNoZenUpgradeNotification() {
-        // doesn't show upgrade notification if stored settings says don't show
-        Settings.Secure.putInt(mContentResolver, Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0);
-        Settings.Secure.putInt(mContentResolver, Settings.Secure.ZEN_SETTINGS_UPDATED, 0);
-        mZenModeHelper.mIsSystemServicesReady = true;
-        mZenModeHelper.setZenModeSetting(ZEN_MODE_IMPORTANT_INTERRUPTIONS);
-
-        verify(mNotificationManager, never()).notify(eq(ZenModeHelper.TAG),
-                eq(SystemMessage.NOTE_ZEN_UPGRADE), any());
-    }
-
-    @Test
-    public void testNoZenUpgradeNotificationZenUpdated() {
-        // doesn't show upgrade notification since zen was already updated
-        Settings.Secure.putInt(mContentResolver, Settings.Secure.SHOW_ZEN_UPGRADE_NOTIFICATION, 0);
-        Settings.Secure.putInt(mContentResolver, Settings.Secure.ZEN_SETTINGS_UPDATED, 1);
-        mZenModeHelper.mIsSystemServicesReady = true;
-        mZenModeHelper.setZenModeSetting(ZEN_MODE_IMPORTANT_INTERRUPTIONS);
-
-        verify(mNotificationManager, never()).notify(eq(ZenModeHelper.TAG),
-                eq(SystemMessage.NOTE_ZEN_UPGRADE), any());
-    }
-
-    @Test
     public void testZenSetInternalRinger_AllPriorityNotificationSoundsMuted() {
         AudioManagerInternal mAudioManager = mock(AudioManagerInternal.class);
         mZenModeHelper.mAudioManager = mAudioManager;
@@ -3032,6 +2983,33 @@
     }
 
     @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void updateAutomaticZenRule_withTypeBedtime_replacesDisabledSleeping() {
+        ZenRule sleepingRule = createCustomAutomaticRule(ZEN_MODE_IMPORTANT_INTERRUPTIONS,
+                ZenModeConfig.EVERY_NIGHT_DEFAULT_RULE_ID);
+        sleepingRule.enabled = false;
+        sleepingRule.userModifiedFields = 0;
+        sleepingRule.name = "ZZZZZZZ...";
+        mZenModeHelper.mConfig.automaticRules.clear();
+        mZenModeHelper.mConfig.automaticRules.put(sleepingRule.id, sleepingRule);
+
+        AutomaticZenRule futureBedtime = new AutomaticZenRule.Builder("Bedtime (?)", CONDITION_ID)
+                .build();
+        String bedtimeRuleId = mZenModeHelper.addAutomaticZenRule(mPkg, futureBedtime,
+                ORIGIN_APP, "reason", CUSTOM_PKG_UID);
+        assertThat(mZenModeHelper.mConfig.automaticRules.keySet())
+                .containsExactly(sleepingRule.id, bedtimeRuleId);
+
+        AutomaticZenRule bedtime = new AutomaticZenRule.Builder("Bedtime (!)", CONDITION_ID)
+                .setType(TYPE_BEDTIME)
+                .build();
+        mZenModeHelper.updateAutomaticZenRule(bedtimeRuleId, bedtime, ORIGIN_APP, "reason",
+                CUSTOM_PKG_UID);
+
+        assertThat(mZenModeHelper.mConfig.automaticRules.keySet()).containsExactly(bedtimeRuleId);
+    }
+
+    @Test
     @EnableFlags(FLAG_MODES_API)
     public void testSetManualZenMode() {
         setupZenConfig();
diff --git a/services/tests/wmtests/src/com/android/server/policy/StemKeyGestureTests.java b/services/tests/wmtests/src/com/android/server/policy/StemKeyGestureTests.java
index 9b92ff4..3ea3235 100644
--- a/services/tests/wmtests/src/com/android/server/policy/StemKeyGestureTests.java
+++ b/services/tests/wmtests/src/com/android/server/policy/StemKeyGestureTests.java
@@ -23,6 +23,7 @@
 import static android.view.KeyEvent.KEYCODE_STEM_PRIMARY;
 
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.server.policy.PhoneWindowManager.DOUBLE_PRESS_PRIMARY_LAUNCH_DEFAULT_FITNESS_APP;
 import static com.android.server.policy.PhoneWindowManager.DOUBLE_PRESS_PRIMARY_SWITCH_RECENT_APP;
 import static com.android.server.policy.PhoneWindowManager.LONG_PRESS_PRIMARY_LAUNCH_VOICE_ASSISTANT;
 import static com.android.server.policy.PhoneWindowManager.SHORT_PRESS_PRIMARY_LAUNCH_ALL_APPS;
@@ -32,6 +33,7 @@
 import android.app.ActivityManager.RecentTaskInfo;
 import android.app.ActivityTaskManager.RootTaskInfo;
 import android.content.ComponentName;
+import android.hardware.input.KeyGestureEvent;
 import android.os.RemoteException;
 import android.provider.Settings;
 import android.view.Display;
@@ -236,6 +238,19 @@
     }
 
     @Test
+    public void stemDoubleKey_behaviorIsLaunchFitness_gestureEventFired() {
+        overrideBehavior(
+                STEM_PRIMARY_BUTTON_DOUBLE_PRESS, DOUBLE_PRESS_PRIMARY_LAUNCH_DEFAULT_FITNESS_APP);
+        setUpPhoneWindowManager(/* supportSettingsUpdate= */ true);
+
+        sendKey(KEYCODE_STEM_PRIMARY);
+        sendKey(KEYCODE_STEM_PRIMARY);
+
+        mPhoneWindowManager.assertKeyGestureEventSentToKeyGestureController(
+                KeyGestureEvent.KEY_GESTURE_TYPE_LAUNCH_DEFAULT_FITNESS);
+    }
+
+    @Test
     public void stemTripleKey_EarlyShortPress_AllAppsThenBackToOriginalThenToggleA11y()
             throws RemoteException {
         overrideBehavior(STEM_PRIMARY_BUTTON_SHORT_PRESS, SHORT_PRESS_PRIMARY_LAUNCH_ALL_APPS);
diff --git a/services/tests/wmtests/src/com/android/server/policy/TestPhoneWindowManager.java b/services/tests/wmtests/src/com/android/server/policy/TestPhoneWindowManager.java
index 1aa9087..a85f866 100644
--- a/services/tests/wmtests/src/com/android/server/policy/TestPhoneWindowManager.java
+++ b/services/tests/wmtests/src/com/android/server/policy/TestPhoneWindowManager.java
@@ -919,4 +919,9 @@
         mTestLooper.dispatchAll();
         Assert.assertEquals(expectEnabled, mIsTalkBackEnabled);
     }
+
+    void assertKeyGestureEventSentToKeyGestureController(int gestureType) {
+        verify(mInputManagerInternal)
+                .handleKeyGestureInKeyGestureController(anyInt(), any(), anyInt(), eq(gestureType));
+    }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/SizeCompatTests.java b/services/tests/wmtests/src/com/android/server/wm/SizeCompatTests.java
index adc969c..72f4fa91 100644
--- a/services/tests/wmtests/src/com/android/server/wm/SizeCompatTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/SizeCompatTests.java
@@ -4881,6 +4881,25 @@
         assertNotEquals(SCREEN_ORIENTATION_UNSPECIFIED, mActivity.getOverrideOrientation());
     }
 
+
+    @Test
+    @EnableCompatChanges({ActivityRecord.UNIVERSAL_RESIZABLE_BY_DEFAULT})
+    public void testUniversalResizeableByDefault() {
+        mSetFlagsRule.enableFlags(Flags.FLAG_UNIVERSAL_RESIZABLE_BY_DEFAULT);
+        mDisplayContent.setIgnoreOrientationRequest(false);
+        setUpApp(mDisplayContent);
+        assertFalse(mActivity.isUniversalResizeable());
+
+        mDisplayContent.setIgnoreOrientationRequest(true);
+        final int swDp = mDisplayContent.getConfiguration().smallestScreenWidthDp;
+        if (swDp < WindowManager.LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP) {
+            final int height = 100 + (int) (mDisplayContent.getDisplayMetrics().density
+                    * WindowManager.LARGE_SCREEN_SMALLEST_SCREEN_WIDTH_DP);
+            resizeDisplay(mDisplayContent, 100 + height, height);
+        }
+        assertTrue(mActivity.isUniversalResizeable());
+    }
+
     @Test
     public void testClearSizeCompat_resetOverrideConfig() {
         final int origDensity = 480;
diff --git a/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java b/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java
index 2bb86bc..1a42e80 100644
--- a/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java
+++ b/services/voiceinteraction/java/com/android/server/soundtrigger/SoundTriggerService.java
@@ -687,6 +687,7 @@
         @Override
         public int startRecognitionForService(ParcelUuid soundModelId, Bundle params,
                 ComponentName detectionService, SoundTrigger.RecognitionConfig config) {
+            final UserHandle userHandle = Binder.getCallingUserHandle();
             mEventLogger.enqueue(new SessionEvent(Type.START_RECOGNITION_SERVICE,
                         getUuid(soundModelId)));
             try (SafeCloseable ignored = ClearCallingIdentityContext.create()) {
@@ -699,7 +700,7 @@
 
                 IRecognitionStatusCallback callback =
                         new RemoteSoundTriggerDetectionService(soundModelId.getUuid(), params,
-                                detectionService, Binder.getCallingUserHandle(), config);
+                                detectionService, userHandle, config);
 
                 synchronized (mLock) {
                     SoundModel soundModel = mLoadedModels.get(soundModelId.getUuid());
diff --git a/telephony/java/android/telephony/TelephonyManager.java b/telephony/java/android/telephony/TelephonyManager.java
index 92effe0..ff966ae 100644
--- a/telephony/java/android/telephony/TelephonyManager.java
+++ b/telephony/java/android/telephony/TelephonyManager.java
@@ -2298,13 +2298,9 @@
      *
      * See {@link #getImei(int)} for details on the required permissions and behavior
      * when the caller does not hold sufficient permissions.
-     *
-     * @throws UnsupportedOperationException If the device does not have
-     *          {@link PackageManager#FEATURE_TELEPHONY_GSM}.
      */
     @SuppressAutoDoc // No support for device / profile owner or carrier privileges (b/72967236).
     @RequiresPermission(android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
-    @RequiresFeature(PackageManager.FEATURE_TELEPHONY_GSM)
     public String getImei() {
         return getImei(getSlotIndex());
     }
@@ -2343,13 +2339,9 @@
      * </ul>
      *
      * @param slotIndex of which IMEI is returned
-     *
-     * @throws UnsupportedOperationException If the device does not have
-     *          {@link PackageManager#FEATURE_TELEPHONY_GSM}.
      */
     @SuppressAutoDoc // No support for device / profile owner or carrier privileges (b/72967236).
     @RequiresPermission(android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
-    @RequiresFeature(PackageManager.FEATURE_TELEPHONY_GSM)
     public String getImei(int slotIndex) {
         ITelephony telephony = getITelephony();
         if (telephony == null) return null;
@@ -2366,11 +2358,7 @@
     /**
      * Returns the Type Allocation Code from the IMEI. Return null if Type Allocation Code is not
      * available.
-     *
-     * @throws UnsupportedOperationException If the device does not have
-     *          {@link PackageManager#FEATURE_TELEPHONY_GSM}.
      */
-    @RequiresFeature(PackageManager.FEATURE_TELEPHONY_GSM)
     @Nullable
     public String getTypeAllocationCode() {
         return getTypeAllocationCode(getSlotIndex());
@@ -2381,11 +2369,7 @@
      * available.
      *
      * @param slotIndex of which Type Allocation Code is returned
-     *
-     * @throws UnsupportedOperationException If the device does not have
-     *          {@link PackageManager#FEATURE_TELEPHONY_GSM}.
      */
-    @RequiresFeature(PackageManager.FEATURE_TELEPHONY_GSM)
     @Nullable
     public String getTypeAllocationCode(int slotIndex) {
         ITelephony telephony = getITelephony();
@@ -10613,20 +10597,31 @@
         return null;
     }
 
-    /** @hide */
+    /**
+     * Get the names of packages with carrier privileges for the current subscription.
+     *
+     * @throws UnsupportedOperationException If the device does not have {@link
+     *     PackageManager#FEATURE_TELEPHONY_SUBSCRIPTION}
+     * @hide
+     */
+    @FlaggedApi(android.os.Flags.FLAG_MAINLINE_VCN_PLATFORM_API)
+    @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
     @RequiresPermission(android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE)
-    public List<String> getPackagesWithCarrierPrivileges() {
+    @RequiresFeature(PackageManager.FEATURE_TELEPHONY_SUBSCRIPTION)
+    @NonNull
+    public Set<String> getPackagesWithCarrierPrivileges() {
+        final Set<String> result = new HashSet<>();
         try {
             ITelephony telephony = getITelephony();
             if (telephony != null) {
-                return telephony.getPackagesWithCarrierPrivileges(getPhoneId());
+                result.addAll(telephony.getPackagesWithCarrierPrivileges(getPhoneId()));
             }
         } catch (RemoteException ex) {
             Rlog.e(TAG, "getPackagesWithCarrierPrivileges RemoteException", ex);
         } catch (NullPointerException ex) {
             Rlog.e(TAG, "getPackagesWithCarrierPrivileges NPE", ex);
         }
-        return Collections.EMPTY_LIST;
+        return result;
     }
 
     /**
@@ -19367,12 +19362,9 @@
      * </ul>
      *
      * @return Primary IMEI of type string
-     * @throws UnsupportedOperationException If the device does not have
-     *          {@link PackageManager#FEATURE_TELEPHONY_GSM}.
      * @throws SecurityException if the caller does not have the required permission/privileges
      */
     @NonNull
-    @RequiresFeature(PackageManager.FEATURE_TELEPHONY_GSM)
     public String getPrimaryImei() {
         try {
             ITelephony telephony = getITelephony();
diff --git a/telephony/java/android/telephony/satellite/SatelliteManager.java b/telephony/java/android/telephony/satellite/SatelliteManager.java
index bd5c759..49ca6f3 100644
--- a/telephony/java/android/telephony/satellite/SatelliteManager.java
+++ b/telephony/java/android/telephony/satellite/SatelliteManager.java
@@ -249,6 +249,13 @@
     public static final String KEY_PROVISION_SATELLITE_TOKENS = "provision_satellite";
 
     /**
+     * Bundle key to get the response from
+     * {@link #deprovisionSatellite(List, Executor, OutcomeReceiver)}.
+     * @hide
+     */
+    public static final String KEY_DEPROVISION_SATELLITE_TOKENS = "deprovision_satellite";
+
+    /**
      * The request was successfully processed.
      */
     @FlaggedApi(Flags.FLAG_OEM_ENABLED_SATELLITE_FLAG)
@@ -2791,6 +2798,61 @@
         }
     }
 
+    /**
+     * Deliver the list of deprovisioned satellite subscriber infos.
+     *
+     * @param list The list of deprovisioned satellite subscriber infos.
+     * @param executor The executor on which the callback will be called.
+     * @param callback The callback object to which the result will be delivered.
+     *
+     * @throws SecurityException if the caller doesn't have required permission.
+     * @hide
+     */
+    @RequiresPermission(Manifest.permission.SATELLITE_COMMUNICATION)
+    @FlaggedApi(Flags.FLAG_CARRIER_ROAMING_NB_IOT_NTN)
+    public void deprovisionSatellite(@NonNull List<SatelliteSubscriberInfo> list,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Boolean, SatelliteException> callback) {
+        Objects.requireNonNull(executor);
+        Objects.requireNonNull(callback);
+
+        try {
+            ITelephony telephony = getITelephony();
+            if (telephony != null) {
+                ResultReceiver receiver = new ResultReceiver(null) {
+                    @Override
+                    protected void onReceiveResult(int resultCode, Bundle resultData) {
+                        if (resultCode == SATELLITE_RESULT_SUCCESS) {
+                            if (resultData.containsKey(KEY_DEPROVISION_SATELLITE_TOKENS)) {
+                                boolean isUpdated =
+                                        resultData.getBoolean(KEY_DEPROVISION_SATELLITE_TOKENS);
+                                executor.execute(() -> Binder.withCleanCallingIdentity(() ->
+                                        callback.onResult(isUpdated)));
+                            } else {
+                                loge("KEY_DEPROVISION_SATELLITE_TOKENS does not exist.");
+                                executor.execute(() -> Binder.withCleanCallingIdentity(() ->
+                                        callback.onError(new SatelliteException(
+                                                SATELLITE_RESULT_REQUEST_FAILED))));
+                            }
+                        } else {
+                            executor.execute(() -> Binder.withCleanCallingIdentity(() ->
+                                    callback.onError(new SatelliteException(resultCode))));
+                        }
+                    }
+                };
+                telephony.deprovisionSatellite(list, receiver);
+            } else {
+                loge("deprovisionSatellite() invalid telephony");
+                executor.execute(() -> Binder.withCleanCallingIdentity(() -> callback.onError(
+                        new SatelliteException(SATELLITE_RESULT_ILLEGAL_STATE))));
+            }
+        } catch (RemoteException ex) {
+            loge("deprovisionSatellite() RemoteException: " + ex);
+            executor.execute(() -> Binder.withCleanCallingIdentity(() -> callback.onError(
+                    new SatelliteException(SATELLITE_RESULT_ILLEGAL_STATE))));
+        }
+    }
+
     @Nullable
     private static ITelephony getITelephony() {
         ITelephony binder = ITelephony.Stub.asInterface(TelephonyFrameworkInitializer
diff --git a/telephony/java/com/android/internal/telephony/ITelephony.aidl b/telephony/java/com/android/internal/telephony/ITelephony.aidl
index 3161d17..61f0146 100644
--- a/telephony/java/com/android/internal/telephony/ITelephony.aidl
+++ b/telephony/java/com/android/internal/telephony/ITelephony.aidl
@@ -3444,4 +3444,15 @@
      */
     boolean overrideCarrierRoamingNtnEligibilityChanged(
             in boolean status, in boolean resetRequired);
+
+    /**
+     * Deliver the list of deprovisioned satellite subscriber infos.
+     *
+     * @param list The list of deprovisioned satellite subscriber infos.
+     * @param result The result receiver that returns whether deliver success or fail.
+     * @hide
+     */
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission("
+            + "android.Manifest.permission.SATELLITE_COMMUNICATION)")
+    void deprovisionSatellite(in List<SatelliteSubscriberInfo> list, in ResultReceiver result);
 }
diff --git a/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt b/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt
index c61a250..9f4df90 100644
--- a/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt
+++ b/tests/Input/src/com/android/test/input/UinputRecordingIntegrationTests.kt
@@ -39,6 +39,7 @@
 import junit.framework.Assert.fail
 import org.hamcrest.Matchers.allOf
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestName
@@ -107,6 +108,7 @@
         parser = InputJsonParser(instrumentation.context)
     }
 
+    @Ignore("b/366602644")
     @Test
     fun testEvemuRecording() {
         VirtualDisplayActivityScenario.AutoClose<CaptureEventActivity>(
diff --git a/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java b/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java
index 9d56a92..8ecddaa 100644
--- a/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java
+++ b/tests/Tracing/src/com/android/internal/protolog/ProtoLogTest.java
@@ -16,6 +16,8 @@
 
 package com.android.internal.protolog;
 
+import static org.junit.Assert.assertThrows;
+
 import android.platform.test.annotations.Presubmit;
 
 import com.android.internal.protolog.common.IProtoLogGroup;
@@ -44,8 +46,29 @@
                 .containsExactly(TEST_GROUP_1, TEST_GROUP_2);
     }
 
+    @Test
+    public void throwOnRegisteringDuplicateGroup() {
+        final var assertion = assertThrows(RuntimeException.class,
+                () -> ProtoLog.init(TEST_GROUP_1, TEST_GROUP_1, TEST_GROUP_2));
+
+        Truth.assertThat(assertion).hasMessageThat().contains("" + TEST_GROUP_1.getId());
+        Truth.assertThat(assertion).hasMessageThat().contains("duplicate");
+    }
+
+    @Test
+    public void throwOnRegisteringGroupsWithIdCollisions() {
+        final var assertion = assertThrows(RuntimeException.class,
+                () -> ProtoLog.init(TEST_GROUP_1, TEST_GROUP_WITH_COLLISION, TEST_GROUP_2));
+
+        Truth.assertThat(assertion).hasMessageThat()
+            .contains("" + TEST_GROUP_WITH_COLLISION.getId());
+        Truth.assertThat(assertion).hasMessageThat().contains("collision");
+    }
+
     private static final IProtoLogGroup TEST_GROUP_1 = new ProtoLogGroup("TEST_TAG_1", 1);
     private static final IProtoLogGroup TEST_GROUP_2 = new ProtoLogGroup("TEST_TAG_2", 2);
+    private static final IProtoLogGroup TEST_GROUP_WITH_COLLISION =
+            new ProtoLogGroup("TEST_TAG_WITH_COLLISION", 1);
 
     private static class ProtoLogGroup implements IProtoLogGroup {
         private final boolean mEnabled;
diff --git a/tests/vcn/java/android/net/vcn/VcnTransportInfoTest.java b/tests/vcn/java/android/net/vcn/VcnTransportInfoTest.java
index 81814b6..7bc9970 100644
--- a/tests/vcn/java/android/net/vcn/VcnTransportInfoTest.java
+++ b/tests/vcn/java/android/net/vcn/VcnTransportInfoTest.java
@@ -25,6 +25,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
 
 import android.net.NetworkCapabilities;
 import android.net.wifi.WifiConfiguration;
@@ -39,6 +40,7 @@
     private static final int SUB_ID = 1;
     private static final int NETWORK_ID = 5;
     private static final int MIN_UDP_PORT_4500_NAT_TIMEOUT = 120;
+    private static final int MIN_UDP_PORT_4500_NAT_TIMEOUT_INVALID = 119;
     private static final WifiInfo WIFI_INFO =
             new WifiInfo.Builder().setNetworkId(NETWORK_ID).build();
 
@@ -48,6 +50,27 @@
             new VcnTransportInfo(WIFI_INFO, MIN_UDP_PORT_4500_NAT_TIMEOUT);
 
     @Test
+    public void testBuilder() {
+        final VcnTransportInfo transportInfo =
+                new VcnTransportInfo.Builder()
+                        .setMinUdpPort4500NatTimeoutSeconds(MIN_UDP_PORT_4500_NAT_TIMEOUT)
+                        .build();
+
+        assertEquals(
+                MIN_UDP_PORT_4500_NAT_TIMEOUT, transportInfo.getMinUdpPort4500NatTimeoutSeconds());
+    }
+
+    @Test
+    public void testBuilder_withInvalidNatTimeout() {
+        try {
+            new VcnTransportInfo.Builder()
+                    .setMinUdpPort4500NatTimeoutSeconds(MIN_UDP_PORT_4500_NAT_TIMEOUT_INVALID);
+            fail("Expected to fail due to invalid NAT timeout");
+        } catch (Exception expected) {
+        }
+    }
+
+    @Test
     public void testGetWifiInfo() {
         assertEquals(WIFI_INFO, WIFI_UNDERLYING_INFO.getWifiInfo());
 
diff --git a/tests/vcn/java/android/net/vcn/VcnUtilsTest.java b/tests/vcn/java/android/net/vcn/VcnUtilsTest.java
new file mode 100644
index 0000000..3ce6c8f
--- /dev/null
+++ b/tests/vcn/java/android/net/vcn/VcnUtilsTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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 android.net.vcn;
+
+import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.TelephonyNetworkSpecifier;
+import android.net.wifi.WifiInfo;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+public class VcnUtilsTest {
+    private static final int SUB_ID = 1;
+
+    private static final WifiInfo WIFI_INFO = new WifiInfo.Builder().build();
+    private static final TelephonyNetworkSpecifier TEL_NETWORK_SPECIFIER =
+            new TelephonyNetworkSpecifier.Builder().setSubscriptionId(SUB_ID).build();
+    private static final VcnTransportInfo VCN_TRANSPORT_INFO =
+            new VcnTransportInfo.Builder().build();
+
+    private ConnectivityManager mMockConnectivityManager;
+    private Network mMockWifiNetwork;
+    private Network mMockCellNetwork;
+
+    private NetworkCapabilities mVcnCapsWithUnderlyingWifi;
+    private NetworkCapabilities mVcnCapsWithUnderlyingCell;
+
+    @Before
+    public void setUp() {
+        mMockConnectivityManager = mock(ConnectivityManager.class);
+
+        mMockWifiNetwork = mock(Network.class);
+        mVcnCapsWithUnderlyingWifi = newVcnCaps(VCN_TRANSPORT_INFO, mMockWifiNetwork);
+        final NetworkCapabilities wifiCaps =
+                new NetworkCapabilities.Builder()
+                        .addTransportType(TRANSPORT_WIFI)
+                        .setTransportInfo(WIFI_INFO)
+                        .build();
+        when(mMockConnectivityManager.getNetworkCapabilities(mMockWifiNetwork))
+                .thenReturn(wifiCaps);
+
+        mMockCellNetwork = mock(Network.class);
+        mVcnCapsWithUnderlyingCell = newVcnCaps(VCN_TRANSPORT_INFO, mMockCellNetwork);
+        final NetworkCapabilities cellCaps =
+                new NetworkCapabilities.Builder()
+                        .addTransportType(TRANSPORT_CELLULAR)
+                        .setNetworkSpecifier(TEL_NETWORK_SPECIFIER)
+                        .build();
+        when(mMockConnectivityManager.getNetworkCapabilities(mMockCellNetwork))
+                .thenReturn(cellCaps);
+    }
+
+    private static NetworkCapabilities newVcnCaps(
+            VcnTransportInfo vcnTransportInfo, Network underlyingNetwork) {
+        return new NetworkCapabilities.Builder()
+                .setTransportInfo(vcnTransportInfo)
+                .setUnderlyingNetworks(Collections.singletonList(underlyingNetwork))
+                .build();
+    }
+
+    @Test
+    public void getWifiInfoFromVcnCaps() {
+        assertEquals(
+                WIFI_INFO,
+                VcnUtils.getWifiInfoFromVcnCaps(
+                        mMockConnectivityManager, mVcnCapsWithUnderlyingWifi));
+    }
+
+    @Test
+    public void getWifiInfoFromVcnCaps_onVcnWithUnderlyingCell() {
+        assertNull(
+                VcnUtils.getWifiInfoFromVcnCaps(
+                        mMockConnectivityManager, mVcnCapsWithUnderlyingCell));
+    }
+
+    @Test
+    public void getSubIdFromVcnCaps() {
+        assertEquals(
+                SUB_ID,
+                VcnUtils.getSubIdFromVcnCaps(mMockConnectivityManager, mVcnCapsWithUnderlyingCell));
+    }
+
+    @Test
+    public void getSubIdFromVcnCaps_onVcnWithUnderlyingWifi() {
+        assertEquals(
+                INVALID_SUBSCRIPTION_ID,
+                VcnUtils.getSubIdFromVcnCaps(mMockConnectivityManager, mVcnCapsWithUnderlyingWifi));
+    }
+
+    @Test
+    public void getSubIdFromVcnCaps_onNonVcnNetwork() {
+        assertEquals(
+                INVALID_SUBSCRIPTION_ID,
+                VcnUtils.getSubIdFromVcnCaps(
+                        mMockConnectivityManager, new NetworkCapabilities.Builder().build()));
+    }
+
+    @Test
+    public void getSubIdFromVcnCaps_withMultipleUnderlyingNetworks() {
+        final NetworkCapabilities vcnCaps =
+                new NetworkCapabilities.Builder(mVcnCapsWithUnderlyingCell)
+                        .setUnderlyingNetworks(
+                                Arrays.asList(
+                                        new Network[] {mMockCellNetwork, mock(Network.class)}))
+                        .build();
+        assertEquals(SUB_ID, VcnUtils.getSubIdFromVcnCaps(mMockConnectivityManager, vcnCaps));
+    }
+}
diff --git a/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java b/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java
index b5cc553..f1f74bc 100644
--- a/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java
+++ b/tests/vcn/java/com/android/server/vcn/TelephonySubscriptionTrackerTest.java
@@ -206,7 +206,7 @@
                 .getAllSubscriptionInfoList();
 
         doReturn(mTelephonyManager).when(mTelephonyManager).createForSubscriptionId(anyInt());
-        setPrivilegedPackagesForMock(Collections.singletonList(PACKAGE_NAME));
+        setPrivilegedPackagesForMock(Collections.singleton(PACKAGE_NAME));
     }
 
     private IntentFilter getIntentFilter() {
@@ -293,7 +293,7 @@
                 Collections.singletonMap(TEST_SUBSCRIPTION_ID_1, TEST_CARRIER_CONFIG_WRAPPER));
     }
 
-    private void setPrivilegedPackagesForMock(@NonNull List<String> privilegedPackages) {
+    private void setPrivilegedPackagesForMock(@NonNull Set<String> privilegedPackages) {
         doReturn(privilegedPackages).when(mTelephonyManager).getPackagesWithCarrierPrivileges();
     }
 
@@ -390,7 +390,7 @@
     @Test
     public void testOnSubscriptionsChangedFired_onActiveSubIdsChanged() throws Exception {
         setupReadySubIds();
-        setPrivilegedPackagesForMock(Collections.emptyList());
+        setPrivilegedPackagesForMock(Collections.emptySet());
 
         doReturn(TEST_SUBSCRIPTION_ID_2).when(mDeps).getActiveDataSubscriptionId();
         final ActiveDataSubscriptionIdListener listener = getActiveDataSubscriptionIdListener();
@@ -411,7 +411,7 @@
     public void testOnSubscriptionsChangedFired_WithReadySubidsNoPrivilegedPackages()
             throws Exception {
         setupReadySubIds();
-        setPrivilegedPackagesForMock(Collections.emptyList());
+        setPrivilegedPackagesForMock(Collections.emptySet());
 
         final OnSubscriptionsChangedListener listener = getOnSubscriptionsChangedListener();
         listener.onSubscriptionsChanged();
@@ -567,7 +567,7 @@
         verify(mCallback).onNewSnapshot(eq(buildExpectedSnapshot(TEST_PRIVILEGED_PACKAGES)));
 
         // Simulate a loss of carrier privileges
-        setPrivilegedPackagesForMock(Collections.emptyList());
+        setPrivilegedPackagesForMock(Collections.emptySet());
         listener.onSubscriptionsChanged();
         mTestLooper.dispatchAll();
 
diff --git a/tools/aapt2/ResourceParser.cpp b/tools/aapt2/ResourceParser.cpp
index da092f4..fb576df 100644
--- a/tools/aapt2/ResourceParser.cpp
+++ b/tools/aapt2/ResourceParser.cpp
@@ -110,7 +110,7 @@
   std::optional<OverlayableItem> overlayable_item;
   std::optional<StagedId> staged_alias;
   std::optional<FeatureFlagAttribute> flag;
-  FlagStatus flag_status;
+  FlagStatus flag_status = FlagStatus::NoFlag;
 
   std::string comment;
   std::unique_ptr<Value> value;
diff --git a/tools/processors/property_cache/OWNERS b/tools/processors/property_cache/OWNERS
new file mode 100644
index 0000000..78650168
--- /dev/null
+++ b/tools/processors/property_cache/OWNERS
@@ -0,0 +1,3 @@
+include /ACTIVITY_MANAGER_OWNERS
+include /BROADCASTS_OWNERS
+include /MULTIUSER_OWNERS
\ No newline at end of file