Merge "AudioService: add test APIs for audio focus and ducking" into main
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java
index 6cad578..bf5b428 100644
--- a/core/java/android/app/Notification.java
+++ b/core/java/android/app/Notification.java
@@ -2900,11 +2900,6 @@
                 }
             }
 
-            final Person person = extras.getParcelable(EXTRA_MESSAGING_PERSON, Person.class);
-            if (person != null) {
-                person.visitUris(visitor);
-            }
-
             final RemoteInputHistoryItem[] history = extras.getParcelableArray(
                     Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS,
                     RemoteInputHistoryItem.class);
@@ -2916,9 +2911,14 @@
                     }
                 }
             }
-        }
 
-        if (isStyle(MessagingStyle.class) && extras != null) {
+            // Extras for MessagingStyle. We visit them even if not isStyle(MessagingStyle), since
+            // Notification Listeners might use directly (without the isStyle check).
+            final Person person = extras.getParcelable(EXTRA_MESSAGING_PERSON, Person.class);
+            if (person != null) {
+                person.visitUris(visitor);
+            }
+
             final Parcelable[] messages = extras.getParcelableArray(EXTRA_MESSAGES,
                     Parcelable.class);
             if (!ArrayUtils.isEmpty(messages)) {
@@ -2938,9 +2938,8 @@
             }
 
             visitIconUri(visitor, extras.getParcelable(EXTRA_CONVERSATION_ICON, Icon.class));
-        }
 
-        if (isStyle(CallStyle.class) & extras != null) {
+            // Extras for CallStyle (same reason for visiting without checking isStyle).
             Person callPerson = extras.getParcelable(EXTRA_CALL_PERSON, Person.class);
             if (callPerson != null) {
                 callPerson.visitUris(visitor);
diff --git a/core/java/android/app/admin/DevicePolicyManager.java b/core/java/android/app/admin/DevicePolicyManager.java
index 33b8b03..715edc5 100644
--- a/core/java/android/app/admin/DevicePolicyManager.java
+++ b/core/java/android/app/admin/DevicePolicyManager.java
@@ -16111,11 +16111,6 @@
      * Called by a profile owner of an organization-owned managed profile to suspend personal
      * apps on the device. When personal apps are suspended the device can only be used for calls.
      *
-     * <p>When personal apps are suspended, an ongoing notification about that is shown to the user.
-     * When the user taps the notification, system invokes {@link #ACTION_CHECK_POLICY_COMPLIANCE}
-     * in the profile owner package. Profile owner implementation that uses personal apps suspension
-     * must handle this intent.
-     *
      * @param admin Which {@link DeviceAdminReceiver} this request is associated with
      * @param suspended Whether personal apps should be suspended.
      * @throws IllegalStateException if the profile owner doesn't have an activity that handles
diff --git a/core/java/com/android/internal/os/OWNERS b/core/java/com/android/internal/os/OWNERS
index 0b9773e..e35b7f1 100644
--- a/core/java/com/android/internal/os/OWNERS
+++ b/core/java/com/android/internal/os/OWNERS
@@ -11,6 +11,7 @@
 per-file *ChargeCalculator* = file:/BATTERY_STATS_OWNERS
 per-file *PowerCalculator* = file:/BATTERY_STATS_OWNERS
 per-file *PowerEstimator* = file:/BATTERY_STATS_OWNERS
+per-file *PowerStats* = file:/BATTERY_STATS_OWNERS
 per-file *Kernel* = file:/BATTERY_STATS_OWNERS
 per-file *MultiState* = file:/BATTERY_STATS_OWNERS
 per-file *PowerProfile* = file:/BATTERY_STATS_OWNERS
diff --git a/core/java/com/android/internal/os/PowerStats.java b/core/java/com/android/internal/os/PowerStats.java
new file mode 100644
index 0000000..1169552
--- /dev/null
+++ b/core/java/com/android/internal/os/PowerStats.java
@@ -0,0 +1,67 @@
+/*
+ * 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.internal.os;
+
+import android.os.BatteryConsumer;
+import android.util.IndentingPrintWriter;
+import android.util.SparseArray;
+
+import java.util.Arrays;
+
+/**
+ * Container for power stats, acquired by various PowerStatsCollector classes. See subclasses for
+ * details.
+ */
+public final class PowerStats {
+    /**
+     * Power component (e.g. CPU, WIFI etc) that this snapshot relates to.
+     */
+    public @BatteryConsumer.PowerComponent int powerComponentId;
+
+    /**
+     * Duration, in milliseconds, covered by this snapshot.
+     */
+    public long durationMs;
+
+    /**
+     * Device-wide stats.
+     */
+    public long[] stats;
+
+    /**
+     * Per-UID CPU stats.
+     */
+    public final SparseArray<long[]> uidStats = new SparseArray<>();
+
+    /**
+     * Prints the contents of the stats snapshot.
+     */
+    public void dump(IndentingPrintWriter pw) {
+        pw.print("PowerStats: ");
+        pw.println(BatteryConsumer.powerComponentIdToString(powerComponentId));
+        pw.increaseIndent();
+        pw.print("duration", durationMs).println();
+        for (int i = 0; i < uidStats.size(); i++) {
+            pw.print("UID ");
+            pw.print(uidStats.keyAt(i));
+            pw.print(": ");
+            pw.print(Arrays.toString(uidStats.valueAt(i)));
+            pw.println();
+        }
+        pw.decreaseIndent();
+    }
+}
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index e0b6565..aeeaaca 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -6624,11 +6624,6 @@
     <!-- Whether to show weather on the lock screen by default. -->
     <bool name="config_lockscreenWeatherEnabledByDefault">false</bool>
 
-    <!-- Whether to reset Battery Stats on unplug when the battery level is high. -->
-    <bool name="config_batteryStatsResetOnUnplugHighBatteryLevel">true</bool>
-    <!-- Whether to reset Battery Stats on unplug if the battery was significantly charged -->
-    <bool name="config_batteryStatsResetOnUnplugAfterSignificantCharge">true</bool>
-
     <!-- Whether we should persist the brightness value in nits for the default display even if
          the underlying display device changes. -->
     <bool name="config_persistBrightnessNitsForDefaultDisplay">false</bool>
diff --git a/core/res/res/values/config_battery_stats.xml b/core/res/res/values/config_battery_stats.xml
new file mode 100644
index 0000000..8fb48bc
--- /dev/null
+++ b/core/res/res/values/config_battery_stats.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 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.
+  -->
+
+<!-- These resources are around just to allow their values to be customized
+     for different hardware and product builds.  Do not translate.
+
+     NOTE: The naming convention is "config_camelCaseValue". -->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+
+    <!-- Whether to reset Battery Stats on unplug when the battery level is high. -->
+    <bool name="config_batteryStatsResetOnUnplugHighBatteryLevel">true</bool>
+    <!-- Whether to reset Battery Stats on unplug if the battery was significantly charged -->
+    <bool name="config_batteryStatsResetOnUnplugAfterSignificantCharge">true</bool>
+
+    <!-- CPU power stats collection throttle period in milliseconds.  Since power stats collection
+    is a relatively expensive operation, this throttle period may need to be adjusted for low-power
+    devices-->
+    <integer name="config_defaultPowerStatsThrottlePeriodCpu">60000</integer>
+</resources>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 4918bbe..8330f7b 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -5089,6 +5089,7 @@
 
   <java-symbol type="bool" name="config_batteryStatsResetOnUnplugHighBatteryLevel" />
   <java-symbol type="bool" name="config_batteryStatsResetOnUnplugAfterSignificantCharge" />
+  <java-symbol type="integer" name="config_defaultPowerStatsThrottlePeriodCpu" />
 
   <java-symbol name="materialColorOnSecondaryFixedVariant" type="attr"/>
   <java-symbol name="materialColorOnTertiaryFixedVariant" type="attr"/>
diff --git a/core/tests/coretests/src/android/app/activity/BroadcastTest.java b/core/tests/coretests/src/android/app/activity/BroadcastTest.java
index 10452fd..bc69901 100644
--- a/core/tests/coretests/src/android/app/activity/BroadcastTest.java
+++ b/core/tests/coretests/src/android/app/activity/BroadcastTest.java
@@ -110,6 +110,7 @@
     public Intent makeBroadcastIntent(String action) {
         Intent intent = new Intent(action, null);
         intent.putExtra("caller", mCallTarget);
+        intent.setPackage(getContext().getPackageName());
         return intent;
     }
 
@@ -179,7 +180,8 @@
     public void registerMyReceiver(IntentFilter filter, String permission) {
         mReceiverRegistered = true;
         //System.out.println("Registering: " + mReceiver);
-        getContext().registerReceiver(mReceiver, filter, permission, null);
+        getContext().registerReceiver(mReceiver, filter, permission, null,
+                Context.RECEIVER_EXPORTED);
     }
 
     public void unregisterMyReceiver() {
@@ -255,7 +257,7 @@
     }
 
     @FlakyTest
-    public void testMulti() throws Exception {
+    public void ignore_testMulti() throws Exception {
         runLaunchpad(LaunchpadActivity.BROADCAST_MULTI);
     }
 
@@ -278,8 +280,11 @@
             Bundle map = new Bundle();
             map.putString("foo", "you");
             map.putString("remove", "me");
+            final Intent intent = new Intent(
+                    "com.android.frameworks.coretests.activity.BROADCAST_RESULT")
+                            .setPackage(getContext().getPackageName());
             getContext().sendOrderedBroadcast(
-                    new Intent("com.android.frameworks.coretests.activity.BROADCAST_RESULT"),
+                    intent,
                     null, broadcastReceiver, null, 1, "foo", map);
             while (!broadcastReceiver.mHaveResult) {
                 try {
@@ -313,7 +318,7 @@
         addIntermediate("finished-broadcast");
 
         IntentFilter filter = new IntentFilter(LaunchpadActivity.BROADCAST_STICKY1);
-        Intent sticky = getContext().registerReceiver(null, filter);
+        Intent sticky = getContext().registerReceiver(null, filter, Context.RECEIVER_EXPORTED);
         assertNotNull("Sticky not found", sticky);
         assertEquals(LaunchpadActivity.DATA_1, sticky.getStringExtra("test"));
     }
@@ -329,7 +334,7 @@
         addIntermediate("finished-unbroadcast");
 
         IntentFilter filter = new IntentFilter(LaunchpadActivity.BROADCAST_STICKY1);
-        Intent sticky = getContext().registerReceiver(null, filter);
+        Intent sticky = getContext().registerReceiver(null, filter, Context.RECEIVER_EXPORTED);
         assertNull("Sticky not found", sticky);
     }
 
@@ -343,7 +348,7 @@
         addIntermediate("finished-broadcast");
 
         IntentFilter filter = new IntentFilter(LaunchpadActivity.BROADCAST_STICKY1);
-        Intent sticky = getContext().registerReceiver(null, filter);
+        Intent sticky = getContext().registerReceiver(null, filter, Context.RECEIVER_EXPORTED);
         assertNotNull("Sticky not found", sticky);
         assertEquals(LaunchpadActivity.DATA_2, sticky.getStringExtra("test"));
     }
@@ -371,7 +376,7 @@
         runLaunchpad(LaunchpadActivity.BROADCAST_STICKY2);
     }
 
-    public void testRegisteredReceivePermissionGranted() throws Exception {
+    public void ignore_testRegisteredReceivePermissionGranted() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_REG});
         registerMyReceiver(new IntentFilter(BROADCAST_REGISTERED), PERMISSION_GRANTED);
         addIntermediate("after-register");
@@ -396,7 +401,7 @@
         waitForResultOrThrow(BROADCAST_TIMEOUT);
     }
 
-    public void testRegisteredBroadcastPermissionGranted() throws Exception {
+    public void ignore_testRegisteredBroadcastPermissionGranted() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_REG});
         registerMyReceiver(new IntentFilter(BROADCAST_REGISTERED), null);
         addIntermediate("after-register");
@@ -430,7 +435,7 @@
         waitForResultOrThrow(BROADCAST_TIMEOUT);
     }
 
-    public void testLocalReceivePermissionDenied() throws Exception {
+    public void ignore_testLocalReceivePermissionDenied() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_RESULTS});
 
         BroadcastReceiver finish = new BroadcastReceiver() {
@@ -446,7 +451,7 @@
         waitForResultOrThrow(BROADCAST_TIMEOUT);
     }
 
-    public void testLocalBroadcastPermissionGranted() throws Exception {
+    public void ignore_testLocalBroadcastPermissionGranted() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_LOCAL});
         getContext().sendBroadcast(
                 makeBroadcastIntent(BROADCAST_LOCAL),
@@ -476,7 +481,7 @@
         waitForResultOrThrow(BROADCAST_TIMEOUT);
     }
 
-    public void testRemoteReceivePermissionDenied() throws Exception {
+    public void ignore_testRemoteReceivePermissionDenied() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_RESULTS});
 
         BroadcastReceiver finish = new BroadcastReceiver() {
@@ -492,7 +497,7 @@
         waitForResultOrThrow(BROADCAST_TIMEOUT);
     }
 
-    public void testRemoteBroadcastPermissionGranted() throws Exception {
+    public void ignore_testRemoteBroadcastPermissionGranted() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_REMOTE});
         getContext().sendBroadcast(
                 makeBroadcastIntent(BROADCAST_REMOTE),
@@ -516,7 +521,7 @@
         waitForResultOrThrow(BROADCAST_TIMEOUT);
     }
 
-    public void testReceiverCanNotRegister() throws Exception {
+    public void ignore_testReceiverCanNotRegister() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_LOCAL});
         getContext().sendBroadcast(makeBroadcastIntent(BROADCAST_FAIL_REGISTER));
         waitForResultOrThrow(BROADCAST_TIMEOUT);
diff --git a/core/tests/coretests/src/android/app/activity/IntentSenderTest.java b/core/tests/coretests/src/android/app/activity/IntentSenderTest.java
index 1b52f80..a0645ce 100644
--- a/core/tests/coretests/src/android/app/activity/IntentSenderTest.java
+++ b/core/tests/coretests/src/android/app/activity/IntentSenderTest.java
@@ -27,7 +27,7 @@
 @LargeTest
 public class IntentSenderTest extends BroadcastTest {
 
-    public void testRegisteredReceivePermissionGranted() throws Exception {
+    public void ignore_testRegisteredReceivePermissionGranted() throws Exception {
         setExpectedReceivers(new String[]{RECEIVER_REG});
         registerMyReceiver(new IntentFilter(BROADCAST_REGISTERED), PERMISSION_GRANTED);
         addIntermediate("after-register");
@@ -71,7 +71,7 @@
         is.cancel();
     }
 
-    public void testLocalReceivePermissionDenied() throws Exception {
+    public void ignore_testLocalReceivePermissionDenied() throws Exception {
         final Intent intent = makeBroadcastIntent(BROADCAST_LOCAL_DENIED)
                 .setPackage(getContext().getPackageName());
 
diff --git a/core/tests/coretests/src/android/app/activity/LaunchpadActivity.java b/core/tests/coretests/src/android/app/activity/LaunchpadActivity.java
index 7662456..fda249f 100644
--- a/core/tests/coretests/src/android/app/activity/LaunchpadActivity.java
+++ b/core/tests/coretests/src/android/app/activity/LaunchpadActivity.java
@@ -438,6 +438,7 @@
     private Intent makeBroadcastIntent(String action) {
         Intent intent = new Intent(action, null);
         intent.putExtra("caller", mCallTarget);
+        intent.setPackage(getPackageName());
         return intent;
     }
 
@@ -466,7 +467,7 @@
     private void registerMyReceiver(IntentFilter filter) {
         mReceiverRegistered = true;
         //System.out.println("Registering: " + mReceiver);
-        registerReceiver(mReceiver, filter);
+        registerReceiver(mReceiver, filter, Context.RECEIVER_EXPORTED);
     }
 
     private void unregisterMyReceiver() {
diff --git a/core/tests/coretests/src/android/app/activity/LocalDeniedReceiver.java b/core/tests/coretests/src/android/app/activity/LocalDeniedReceiver.java
index 2120a1d..3271b8f 100644
--- a/core/tests/coretests/src/android/app/activity/LocalDeniedReceiver.java
+++ b/core/tests/coretests/src/android/app/activity/LocalDeniedReceiver.java
@@ -19,11 +19,11 @@
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
-import android.os.RemoteException;
 import android.os.IBinder;
 import android.os.Parcel;
+import android.os.RemoteException;
 
-class LocalDeniedReceiver extends BroadcastReceiver {
+public class LocalDeniedReceiver extends BroadcastReceiver {
     public LocalDeniedReceiver() {
     }
 
diff --git a/libs/WindowManager/Shell/res/layout/bubble_bar_manage_education.xml b/libs/WindowManager/Shell/res/layout/bubble_bar_manage_education.xml
new file mode 100644
index 0000000..a0a06f1
--- /dev/null
+++ b/libs/WindowManager/Shell/res/layout/bubble_bar_manage_education.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ 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
+  -->
+<com.android.wm.shell.common.bubbles.BubblePopupView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_gravity="center_horizontal"
+    android:layout_marginHorizontal="@dimen/bubble_popup_margin_horizontal"
+    android:layout_marginTop="@dimen/bubble_popup_margin_top"
+    android:elevation="@dimen/bubble_manage_menu_elevation"
+    android:gravity="center_horizontal"
+    android:orientation="vertical">
+
+    <ImageView
+        android:layout_width="32dp"
+        android:layout_height="32dp"
+        android:tint="?android:attr/colorAccent"
+        android:contentDescription="@null"
+        android:src="@drawable/pip_ic_settings"/>
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:maxWidth="@dimen/bubble_popup_content_max_width"
+        android:maxLines="1"
+        android:ellipsize="end"
+        android:textAppearance="@android:style/TextAppearance.DeviceDefault.Headline"
+        android:textColor="?android:attr/textColorPrimary"
+        android:text="@string/bubble_bar_education_manage_title"/>
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:maxWidth="@dimen/bubble_popup_content_max_width"
+        android:textAppearance="@android:style/TextAppearance.DeviceDefault"
+        android:textColor="?android:attr/textColorSecondary"
+        android:textAlignment="center"
+        android:text="@string/bubble_bar_education_manage_text"/>
+
+</com.android.wm.shell.common.bubbles.BubblePopupView>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml
index 597e899d..20bf81d 100644
--- a/libs/WindowManager/Shell/res/values/dimen.xml
+++ b/libs/WindowManager/Shell/res/values/dimen.xml
@@ -226,6 +226,20 @@
     <dimen name="bubble_user_education_padding_end">58dp</dimen>
     <!-- Padding between the bubble and the user education text. -->
     <dimen name="bubble_user_education_stack_padding">16dp</dimen>
+    <!-- Max width for the bubble popup view. -->
+    <dimen name="bubble_popup_content_max_width">300dp</dimen>
+    <!-- Horizontal margin for the bubble popup view. -->
+    <dimen name="bubble_popup_margin_horizontal">32dp</dimen>
+    <!-- Top margin for the bubble popup view. -->
+    <dimen name="bubble_popup_margin_top">16dp</dimen>
+    <!-- Width for the bubble popup view arrow. -->
+    <dimen name="bubble_popup_arrow_width">12dp</dimen>
+    <!-- Height for the bubble popup view arrow. -->
+    <dimen name="bubble_popup_arrow_height">10dp</dimen>
+    <!-- Corner radius for the bubble popup view arrow. -->
+    <dimen name="bubble_popup_arrow_corner_radius">2dp</dimen>
+    <!-- Padding for the bubble popup view contents. -->
+    <dimen name="bubble_popup_padding">24dp</dimen>
     <!-- The size of the caption bar inset at the top of bubble bar expanded view. -->
     <dimen name="bubble_bar_expanded_view_caption_height">32dp</dimen>
     <!-- The height of the dots shown for the caption menu in the bubble bar expanded view.. -->
diff --git a/libs/WindowManager/Shell/res/values/strings.xml b/libs/WindowManager/Shell/res/values/strings.xml
index 8cbc3d0..00c63d7 100644
--- a/libs/WindowManager/Shell/res/values/strings.xml
+++ b/libs/WindowManager/Shell/res/values/strings.xml
@@ -163,6 +163,11 @@
     <!-- [CHAR LIMIT=NONE] Empty overflow subtitle -->
     <string name="bubble_overflow_empty_subtitle">Recent bubbles and dismissed bubbles will appear here</string>
 
+    <!-- Title text for the bubble bar "manage" button tool tip highlighting where users can go to control bubble settings. [CHAR LIMIT=60]-->
+    <string name="bubble_bar_education_manage_title">Control bubbles anytime</string>
+    <!-- Descriptive text for the bubble bar "manage" button tool tip highlighting where users can go to control bubble settings. [CHAR LIMIT=80]-->
+    <string name="bubble_bar_education_manage_text">Tap here to manage which apps and conversations can bubble</string>
+
     <!-- [CHAR LIMIT=100] Notification Importance title -->
     <string name="notification_bubble_title">Bubble</string>
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
index 7e09c98..ff67110 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/Bubble.java
@@ -60,7 +60,6 @@
 /**
  * Encapsulates the data and UI elements of a bubble.
  */
-@VisibleForTesting
 public class Bubble implements BubbleViewProvider {
     private static final String TAG = "Bubble";
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt
new file mode 100644
index 0000000..e57f02c
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleEducationController.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.wm.shell.bubbles
+
+import android.content.Context
+import android.util.Log
+import androidx.core.content.edit
+import com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_USER_EDUCATION
+import com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES
+import com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME
+
+/** Manages bubble education flags. Provides convenience methods to check the education state */
+class BubbleEducationController(private val context: Context) {
+    private val prefs = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
+
+    /** Whether the user has seen the stack education */
+    @get:JvmName(name = "hasSeenStackEducation")
+    var hasSeenStackEducation: Boolean
+        get() = prefs.getBoolean(PREF_STACK_EDUCATION, false)
+        set(value) = prefs.edit { putBoolean(PREF_STACK_EDUCATION, value) }
+
+    /** Whether the user has seen the expanded view "manage" menu education */
+    @get:JvmName(name = "hasSeenManageEducation")
+    var hasSeenManageEducation: Boolean
+        get() = prefs.getBoolean(PREF_MANAGED_EDUCATION, false)
+        set(value) = prefs.edit { putBoolean(PREF_MANAGED_EDUCATION, value) }
+
+    /** Whether education view should show for the collapsed stack. */
+    fun shouldShowStackEducation(bubble: BubbleViewProvider?): Boolean {
+        val shouldShow = bubble != null &&
+                bubble.isConversationBubble && // show education for conversation bubbles only
+                (!hasSeenStackEducation || BubbleDebugConfig.forceShowUserEducation(context))
+        logDebug("Show stack edu: $shouldShow")
+        return shouldShow
+    }
+
+    /** Whether the educational view should show for the expanded view "manage" menu. */
+    fun shouldShowManageEducation(bubble: BubbleViewProvider?): Boolean {
+        val shouldShow = bubble != null &&
+                bubble.isConversationBubble && // show education for conversation bubbles only
+                (!hasSeenManageEducation || BubbleDebugConfig.forceShowUserEducation(context))
+        logDebug("Show manage edu: $shouldShow")
+        return shouldShow
+    }
+
+    private fun logDebug(message: String) {
+        if (DEBUG_USER_EDUCATION) {
+            Log.d(TAG, message)
+        }
+    }
+
+    companion object {
+        private val TAG = if (TAG_WITH_CLASS_NAME) "BubbleEducationController" else TAG_BUBBLES
+        const val PREF_STACK_EDUCATION: String = "HasSeenBubblesOnboarding"
+        const val PREF_MANAGED_EDUCATION: String = "HasSeenBubblesManageOnboarding"
+    }
+}
+
+/** Convenience extension method to check if the bubble is a conversation bubble */
+private val BubbleViewProvider.isConversationBubble: Boolean
+    get() = if (this is Bubble) isConversation else false
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePopupViewExt.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePopupViewExt.kt
new file mode 100644
index 0000000..bdb09e1
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePopupViewExt.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.wm.shell.bubbles
+
+import android.graphics.Color
+import com.android.wm.shell.R
+import com.android.wm.shell.common.bubbles.BubblePopupDrawable
+import com.android.wm.shell.common.bubbles.BubblePopupView
+
+/**
+ * A convenience method to setup the [BubblePopupView] with the correct config using local resources
+ */
+fun BubblePopupView.setup() {
+    val attrs =
+        context.obtainStyledAttributes(
+            intArrayOf(
+                com.android.internal.R.attr.materialColorSurface,
+                android.R.attr.dialogCornerRadius
+            )
+        )
+
+    val res = context.resources
+    val config =
+        BubblePopupDrawable.Config(
+            color = attrs.getColor(0, Color.WHITE),
+            cornerRadius = attrs.getDimension(1, 0f),
+            contentPadding = res.getDimensionPixelSize(R.dimen.bubble_popup_padding),
+            arrowWidth = res.getDimension(R.dimen.bubble_popup_arrow_width),
+            arrowHeight = res.getDimension(R.dimen.bubble_popup_arrow_height),
+            arrowRadius = res.getDimension(R.dimen.bubble_popup_arrow_corner_radius)
+        )
+    attrs.recycle()
+    setupBackground(config)
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
index da5974f..74f830e 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
@@ -46,7 +46,6 @@
 import android.graphics.RectF;
 import android.graphics.drawable.ColorDrawable;
 import android.os.Bundle;
-import android.os.SystemProperties;
 import android.provider.Settings;
 import android.util.Log;
 import android.view.Choreographer;
@@ -108,12 +107,6 @@
  */
 public class BubbleStackView extends FrameLayout
         implements ViewTreeObserver.OnComputeInternalInsetsListener {
-
-    // LINT.IfChange
-    public static final boolean ENABLE_FLING_TO_DISMISS_BUBBLE =
-            SystemProperties.getBoolean("persist.wm.debug.fling_to_dismiss_bubble", true);
-    // LINT.ThenChange(com/android/launcher3/taskbar/bubbles/BubbleDismissController.java)
-
     private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleStackView" : TAG_BUBBLES;
 
     /** How far the flyout needs to be dragged before it's dismissed regardless of velocity. */
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
index 33629f9..c20733a 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
@@ -19,7 +19,6 @@
 import static android.view.View.LAYOUT_DIRECTION_RTL;
 
 import static com.android.wm.shell.bubbles.BubblePositioner.NUM_VISIBLE_WHEN_RESTING;
-import static com.android.wm.shell.bubbles.BubbleStackView.ENABLE_FLING_TO_DISMISS_BUBBLE;
 
 import android.content.res.Resources;
 import android.graphics.Path;
@@ -355,7 +354,6 @@
         mMagnetizedBubbleDraggingOut.setMagnetListener(listener);
         mMagnetizedBubbleDraggingOut.setHapticsEnabled(true);
         mMagnetizedBubbleDraggingOut.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
-        mMagnetizedBubbleDraggingOut.setFlingToTargetEnabled(ENABLE_FLING_TO_DISMISS_BUBBLE);
     }
 
     private void springBubbleTo(View bubble, float x, float y) {
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
index 5533842..4bb1ab4 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/StackAnimationController.java
@@ -17,7 +17,6 @@
 package com.android.wm.shell.bubbles.animation;
 
 import static com.android.wm.shell.bubbles.BubblePositioner.NUM_VISIBLE_WHEN_RESTING;
-import static com.android.wm.shell.bubbles.BubbleStackView.ENABLE_FLING_TO_DISMISS_BUBBLE;
 
 import android.content.ContentResolver;
 import android.content.res.Resources;
@@ -1026,7 +1025,6 @@
             };
             mMagnetizedStack.setHapticsEnabled(true);
             mMagnetizedStack.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
-            mMagnetizedStack.setFlingToTargetEnabled(ENABLE_FLING_TO_DISMISS_BUBBLE);
         }
 
         final ContentResolver contentResolver = mLayout.getContext().getContentResolver();
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
index 6b6d6ba..79f188a 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java
@@ -39,7 +39,6 @@
 import com.android.wm.shell.bubbles.Bubbles;
 import com.android.wm.shell.taskview.TaskView;
 
-import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 /**
@@ -48,6 +47,18 @@
  * {@link BubbleController#isShowingAsBubbleBar()}
  */
 public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskViewHelper.Listener {
+    /**
+     * The expanded view listener notifying the {@link BubbleBarLayerView} about the internal
+     * actions and events
+     */
+    public interface Listener {
+        /** Called when the task view task is first created. */
+        void onTaskCreated();
+        /** Called when expanded view needs to un-bubble the given conversation */
+        void onUnBubbleConversation(String bubbleKey);
+        /** Called when expanded view task view back button pressed */
+        void onBackPressed();
+    }
 
     private static final String TAG = BubbleBarExpandedView.class.getSimpleName();
     private static final int INVALID_TASK_ID = -1;
@@ -57,7 +68,7 @@
     private BubbleTaskViewHelper mBubbleTaskViewHelper;
     private BubbleBarMenuViewController mMenuViewController;
     private @Nullable Supplier<Rect> mLayerBoundsSupplier;
-    private @Nullable Consumer<String> mUnBubbleConversationCallback;
+    private @Nullable Listener mListener;
 
     private BubbleBarHandleView mHandleView = new BubbleBarHandleView(getContext());
     private @Nullable TaskView mTaskView;
@@ -145,15 +156,13 @@
         mMenuViewController.setListener(new BubbleBarMenuViewController.Listener() {
             @Override
             public void onMenuVisibilityChanged(boolean visible) {
-                if (mTaskView == null || mLayerBoundsSupplier == null) return;
-                // Updates the obscured touchable region for the task surface.
-                mTaskView.setObscuredTouchRect(visible ? mLayerBoundsSupplier.get() : null);
+                setObscured(visible);
             }
 
             @Override
             public void onUnBubbleConversation(Bubble bubble) {
-                if (mUnBubbleConversationCallback != null) {
-                    mUnBubbleConversationCallback.accept(bubble.getKey());
+                if (mListener != null) {
+                    mListener.onUnBubbleConversation(bubble.getKey());
                 }
             }
 
@@ -231,6 +240,9 @@
     public void onTaskCreated() {
         setContentVisibility(true);
         updateHandleColor(false /* animated */);
+        if (mListener != null) {
+            mListener.onTaskCreated();
+        }
     }
 
     @Override
@@ -240,7 +252,8 @@
 
     @Override
     public void onBackPressed() {
-        mController.collapseStack();
+        if (mListener == null) return;
+        mListener.onBackPressed();
     }
 
     /** Cleans up task view, should be called when the bubble is no longer active. */
@@ -254,6 +267,18 @@
         mMenuViewController.hideMenu(false /* animated */);
     }
 
+    /**
+     * Hides the current modal menu view or collapses the bubble stack.
+     * Called from {@link BubbleBarLayerView}
+     */
+    public void hideMenuOrCollapse() {
+        if (mMenuViewController.isMenuVisible()) {
+            mMenuViewController.hideMenu(/* animated = */ true);
+        } else {
+            mController.collapseStack();
+        }
+    }
+
     /** Updates the bubble shown in the expanded view. */
     public void update(Bubble bubble) {
         mBubbleTaskViewHelper.update(bubble);
@@ -270,10 +295,16 @@
         mLayerBoundsSupplier = supplier;
     }
 
-    /** Sets the function to call to un-bubble the given conversation. */
-    public void setUnBubbleConversationCallback(
-            @Nullable Consumer<String> unBubbleConversationCallback) {
-        mUnBubbleConversationCallback = unBubbleConversationCallback;
+    /** Sets expanded view listener */
+    void setListener(@Nullable Listener listener) {
+        mListener = listener;
+    }
+
+    /** Sets whether the view is obscured by some modal view */
+    void setObscured(boolean obscured) {
+        if (mTaskView == null || mLayerBoundsSupplier == null) return;
+        // Updates the obscured touchable region for the task surface.
+        mTaskView.setObscuredTouchRect(obscured ? mLayerBoundsSupplier.get() : null);
     }
 
     /**
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java
index bc04bfc..8f11253 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java
@@ -52,6 +52,7 @@
     private final BubbleController mBubbleController;
     private final BubblePositioner mPositioner;
     private final BubbleBarAnimationHelper mAnimationHelper;
+    private final BubbleEducationViewController mEducationViewController;
     private final View mScrimView;
 
     @Nullable
@@ -80,6 +81,10 @@
 
         mAnimationHelper = new BubbleBarAnimationHelper(context,
                 this, mPositioner);
+        mEducationViewController = new BubbleEducationViewController(context, (boolean visible) -> {
+            if (mExpandedView == null) return;
+            mExpandedView.setObscured(visible);
+        });
 
         mScrimView = new View(getContext());
         mScrimView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
@@ -90,9 +95,7 @@
         mScrimView.setBackgroundDrawable(new ColorDrawable(
                 getResources().getColor(android.R.color.system_neutral1_1000)));
 
-        setOnClickListener(view -> {
-            mBubbleController.collapseStack();
-        });
+        setOnClickListener(view -> hideMenuOrCollapse());
     }
 
     @Override
@@ -108,6 +111,7 @@
         getViewTreeObserver().removeOnComputeInternalInsetsListener(this);
 
         if (mExpandedView != null) {
+            mEducationViewController.hideManageEducation(/* animated = */ false);
             removeView(mExpandedView);
             mExpandedView = null;
         }
@@ -162,14 +166,27 @@
             final int width = mPositioner.getExpandedViewWidthForBubbleBar(isOverflowExpanded);
             final int height = mPositioner.getExpandedViewHeightForBubbleBar(isOverflowExpanded);
             mExpandedView.setVisibility(GONE);
-            mExpandedView.setUnBubbleConversationCallback(mUnBubbleConversationCallback);
+            mExpandedView.setY(mPositioner.getExpandedViewBottomForBubbleBar() - height);
             mExpandedView.setLayerBoundsSupplier(() -> new Rect(0, 0, getWidth(), getHeight()));
-            mExpandedView.setUnBubbleConversationCallback(bubbleKey -> {
-                if (mUnBubbleConversationCallback != null) {
-                    mUnBubbleConversationCallback.accept(bubbleKey);
+            mExpandedView.setListener(new BubbleBarExpandedView.Listener() {
+                @Override
+                public void onTaskCreated() {
+                    mEducationViewController.maybeShowManageEducation(b, mExpandedView);
+                }
+
+                @Override
+                public void onUnBubbleConversation(String bubbleKey) {
+                    if (mUnBubbleConversationCallback != null) {
+                        mUnBubbleConversationCallback.accept(bubbleKey);
+                    }
+                }
+
+                @Override
+                public void onBackPressed() {
+                    hideMenuOrCollapse();
                 }
             });
-            mExpandedView.setY(mPositioner.getExpandedViewBottomForBubbleBar() - height);
+
             addView(mExpandedView, new FrameLayout.LayoutParams(width, height));
         }
 
@@ -193,6 +210,7 @@
     public void collapse() {
         mIsExpanded = false;
         final BubbleBarExpandedView viewToRemove = mExpandedView;
+        mEducationViewController.hideManageEducation(/* animated = */ true);
         mAnimationHelper.animateCollapse(() -> removeView(viewToRemove));
         mBubbleController.getSysuiProxy().onStackExpandChanged(false);
         mExpandedView = null;
@@ -206,6 +224,17 @@
         mUnBubbleConversationCallback = unBubbleConversationCallback;
     }
 
+    /** Hides the current modal education/menu view, expanded view or collapses the bubble stack */
+    private void hideMenuOrCollapse() {
+        if (mEducationViewController.isManageEducationVisible()) {
+            mEducationViewController.hideManageEducation(/* animated = */ true);
+        } else if (isExpanded() && mExpandedView != null) {
+            mExpandedView.hideMenuOrCollapse();
+        } else {
+            mBubbleController.collapseStack();
+        }
+    }
+
     /** Updates the expanded view size and position. */
     private void updateExpandedView() {
         if (mExpandedView == null) return;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java
index 8be140c..81e7582 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarMenuViewController.java
@@ -56,6 +56,11 @@
                 SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_LOW_BOUNCY);
     }
 
+    /** Tells if the menu is visible or being animated */
+    boolean isMenuVisible() {
+        return mMenuView != null && mMenuView.getVisibility() == View.VISIBLE;
+    }
+
     /** Sets menu actions listener */
     void setListener(@Nullable Listener listener) {
         mListener = listener;
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt
new file mode 100644
index 0000000..7b39c6f
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleEducationViewController.kt
@@ -0,0 +1,148 @@
+/*
+ * 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.wm.shell.bubbles.bar
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.view.doOnLayout
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.SpringForce
+import com.android.wm.shell.R
+import com.android.wm.shell.animation.PhysicsAnimator
+import com.android.wm.shell.bubbles.BubbleEducationController
+import com.android.wm.shell.bubbles.BubbleViewProvider
+import com.android.wm.shell.bubbles.setup
+import com.android.wm.shell.common.bubbles.BubblePopupView
+
+/** Manages bubble education presentation and animation */
+class BubbleEducationViewController(private val context: Context, private val listener: Listener) {
+    interface Listener {
+        fun onManageEducationVisibilityChanged(isVisible: Boolean)
+    }
+
+    private var rootView: ViewGroup? = null
+    private var educationView: BubblePopupView? = null
+    private var animator: PhysicsAnimator<BubblePopupView>? = null
+
+    private val springConfig by lazy {
+        PhysicsAnimator.SpringConfig(
+            SpringForce.STIFFNESS_MEDIUM,
+            SpringForce.DAMPING_RATIO_LOW_BOUNCY
+        )
+    }
+
+    private val controller by lazy { BubbleEducationController(context) }
+
+    /** Whether the education view is visible or being animated */
+    val isManageEducationVisible: Boolean
+        get() = educationView != null && rootView != null
+
+    /**
+     * Show manage bubble education if hasn't been shown before
+     *
+     * @param bubble the bubble used for the manage education check
+     * @param root the view to show manage education in
+     */
+    fun maybeShowManageEducation(bubble: BubbleViewProvider, root: ViewGroup) {
+        if (!controller.shouldShowManageEducation(bubble)) return
+        showManageEducation(root)
+    }
+
+    /**
+     * Hide the manage education view if visible
+     *
+     * @param animated whether should hide with animation
+     */
+    fun hideManageEducation(animated: Boolean) {
+        rootView?.let {
+            fun cleanUp() {
+                it.removeView(educationView)
+                rootView = null
+                listener.onManageEducationVisibilityChanged(isVisible = false)
+            }
+
+            if (animated) {
+                animateTransition(show = false, ::cleanUp)
+            } else {
+                cleanUp()
+            }
+        }
+    }
+
+    /**
+     * Show manage education with animation
+     *
+     * @param root the view to show manage education in
+     */
+    private fun showManageEducation(root: ViewGroup) {
+        hideManageEducation(animated = false)
+        if (educationView == null) {
+            val eduView = createEducationView(root)
+            educationView = eduView
+            animator = createAnimation(eduView)
+        }
+        root.addView(educationView)
+        rootView = root
+        animateTransition(show = true) {
+            controller.hasSeenManageEducation = true
+            listener.onManageEducationVisibilityChanged(isVisible = true)
+        }
+    }
+
+    /**
+     * Animate show/hide transition for the education view
+     *
+     * @param show whether to show or hide the view
+     * @param endActions a closure to be called when the animation completes
+     */
+    private fun animateTransition(show: Boolean, endActions: () -> Unit) {
+        animator?.let { animator ->
+            animator
+                .spring(DynamicAnimation.ALPHA, if (show) 1f else 0f)
+                .spring(DynamicAnimation.SCALE_X, if (show) 1f else EDU_SCALE_HIDDEN)
+                .spring(DynamicAnimation.SCALE_Y, if (show) 1f else EDU_SCALE_HIDDEN)
+                .withEndActions(endActions)
+                .start()
+        } ?: endActions()
+    }
+
+    private fun createEducationView(root: ViewGroup): BubblePopupView {
+        val view =
+            LayoutInflater.from(context).inflate(R.layout.bubble_bar_manage_education, root, false)
+                as BubblePopupView
+
+        return view.apply {
+            setup()
+            alpha = 0f
+            pivotY = 0f
+            scaleX = EDU_SCALE_HIDDEN
+            scaleY = EDU_SCALE_HIDDEN
+            doOnLayout { it.pivotX = it.width / 2f }
+            setOnClickListener { hideManageEducation(animated = true) }
+        }
+    }
+
+    private fun createAnimation(view: BubblePopupView): PhysicsAnimator<BubblePopupView> {
+        val animator = PhysicsAnimator.getInstance(view)
+        animator.setDefaultSpringConfig(springConfig)
+        return animator
+    }
+
+    companion object {
+        private const val EDU_SCALE_HIDDEN = 0.5f
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/bubbles/BubblePopupDrawable.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/bubbles/BubblePopupDrawable.kt
new file mode 100644
index 0000000..8b5283d
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/bubbles/BubblePopupDrawable.kt
@@ -0,0 +1,232 @@
+/*
+ * 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.wm.shell.common.bubbles
+
+import android.annotation.ColorInt
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.Matrix
+import android.graphics.Outline
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Rect
+import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import kotlin.math.atan
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.properties.Delegates
+
+/** A drawable for the [BubblePopupView] that draws a popup background with a directional arrow */
+class BubblePopupDrawable(private val config: Config) : Drawable() {
+    /** The direction of the arrow in the popup drawable */
+    enum class ArrowDirection {
+        UP,
+        DOWN
+    }
+
+    /** The arrow position on the side of the popup bubble */
+    sealed class ArrowPosition {
+        object Start : ArrowPosition()
+        object Center : ArrowPosition()
+        object End : ArrowPosition()
+        class Custom(val value: Float) : ArrowPosition()
+    }
+
+    /** The configuration for drawable features */
+    data class Config(
+        @ColorInt val color: Int,
+        val cornerRadius: Float,
+        val contentPadding: Int,
+        val arrowWidth: Float,
+        val arrowHeight: Float,
+        val arrowRadius: Float
+    )
+
+    /**
+     * The direction of the arrow in the popup drawable. It affects the content padding and requires
+     * it to be updated in the view.
+     */
+    var arrowDirection: ArrowDirection by
+        Delegates.observable(ArrowDirection.UP) { _, _, _ -> requestPathUpdate() }
+
+    /**
+     * Arrow position along the X axis and its direction. The position is adjusted to the content
+     * corner radius when applied so it doesn't go into rounded corner area
+     */
+    var arrowPosition: ArrowPosition by
+        Delegates.observable(ArrowPosition.Center) { _, _, _ -> requestPathUpdate() }
+
+    private val path = Path()
+    private val paint = Paint()
+    private var shouldUpdatePath = true
+
+    init {
+        paint.color = config.color
+        paint.style = Paint.Style.FILL
+        paint.isAntiAlias = true
+    }
+
+    override fun draw(canvas: Canvas) {
+        updatePathIfNeeded()
+        canvas.drawPath(path, paint)
+    }
+
+    override fun onBoundsChange(bounds: Rect?) {
+        requestPathUpdate()
+    }
+
+    /** Should be applied to the view padding if arrow direction changes */
+    override fun getPadding(padding: Rect): Boolean {
+        padding.set(
+            config.contentPadding,
+            config.contentPadding,
+            config.contentPadding,
+            config.contentPadding
+        )
+        when (arrowDirection) {
+            ArrowDirection.UP -> padding.top += config.arrowHeight.toInt()
+            ArrowDirection.DOWN -> padding.bottom += config.arrowHeight.toInt()
+        }
+        return true
+    }
+
+    override fun getOutline(outline: Outline) {
+        updatePathIfNeeded()
+        outline.setPath(path)
+    }
+
+    override fun getOpacity(): Int {
+        return paint.alpha
+    }
+
+    override fun setAlpha(alpha: Int) {
+        paint.alpha = alpha
+    }
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {
+        paint.colorFilter = colorFilter
+    }
+
+    /** Schedules path update for the next redraw */
+    private fun requestPathUpdate() {
+        shouldUpdatePath = true
+    }
+
+    /** Updates the path if required, when bounds or arrow direction/position changes */
+    private fun updatePathIfNeeded() {
+        if (shouldUpdatePath) {
+            updatePath()
+            shouldUpdatePath = false
+        }
+    }
+
+    /** Updates the path value using the current bounds, config, arrow direction and position */
+    private fun updatePath() {
+        if (bounds.isEmpty) return
+        // Reset the path state
+        path.reset()
+        // The content rect where the filled rounded rect will be drawn
+        val contentRect = RectF(bounds)
+        when (arrowDirection) {
+            ArrowDirection.UP -> {
+                // Add rounded arrow pointing up to the path
+                addRoundedArrowPositioned(path, arrowPosition)
+                // Inset content rect by the arrow size from the top
+                contentRect.top += config.arrowHeight
+            }
+            ArrowDirection.DOWN -> {
+                val matrix = Matrix()
+                // Flip the path with the matrix to draw arrow pointing down
+                matrix.setScale(1f, -1f, bounds.width() / 2f, bounds.height() / 2f)
+                path.transform(matrix)
+                // Add rounded arrow with the flipped matrix applied, will point down
+                addRoundedArrowPositioned(path, arrowPosition)
+                // Restore the path matrix to the original state with inverted matrix
+                matrix.invert(matrix)
+                path.transform(matrix)
+                // Inset content rect by the arrow size from the bottom
+                contentRect.bottom -= config.arrowHeight
+            }
+        }
+        // Add the content area rounded rect
+        path.addRoundRect(contentRect, config.cornerRadius, config.cornerRadius, Path.Direction.CW)
+    }
+
+    /** Add a rounded arrow pointing up in the horizontal position on the canvas */
+    private fun addRoundedArrowPositioned(path: Path, position: ArrowPosition) {
+        val matrix = Matrix()
+        var translationX = positionValue(position) - config.arrowWidth / 2
+        // Offset to position between rounded corners of the content view
+        translationX = translationX.coerceIn(config.cornerRadius,
+                bounds.width() - config.cornerRadius - config.arrowWidth)
+        // Translate to add the arrow in the center horizontally
+        matrix.setTranslate(-translationX, 0f)
+        path.transform(matrix)
+        // Add rounded arrow
+        addRoundedArrow(path)
+        // Restore the path matrix to the original state with inverted matrix
+        matrix.invert(matrix)
+        path.transform(matrix)
+    }
+
+    /** Adds a rounded arrow pointing up to the path, can be flipped if needed */
+    private fun addRoundedArrow(path: Path) {
+        // Theta is half of the angle inside the triangle tip
+        val thetaTan = config.arrowWidth / (config.arrowHeight * 2f)
+        val theta = atan(thetaTan)
+        val thetaDeg = Math.toDegrees(theta.toDouble()).toFloat()
+        // The center Y value of the circle for the triangle tip
+        val tipCircleCenterY = config.arrowRadius / sin(theta)
+        // The length from triangle tip to intersection point with the circle
+        val tipIntersectionSideLength = config.arrowRadius / thetaTan
+        // The offset from the top to the point of intersection
+        val intersectionTopOffset = tipIntersectionSideLength * cos(theta)
+        // The offset from the center to the point of intersection
+        val intersectionCenterOffset = tipIntersectionSideLength * sin(theta)
+        // The center X of the triangle
+        val arrowCenterX = config.arrowWidth / 2f
+
+        // Set initial position in bottom left of the arrow
+        path.moveTo(0f, config.arrowHeight)
+        // Add the left side of the triangle
+        path.lineTo(arrowCenterX - intersectionCenterOffset, intersectionTopOffset)
+        // Add the arc from the left to the right side of the triangle
+        path.arcTo(
+            /* left = */ arrowCenterX - config.arrowRadius,
+            /* top = */ tipCircleCenterY - config.arrowRadius,
+            /* right = */ arrowCenterX + config.arrowRadius,
+            /* bottom = */ tipCircleCenterY + config.arrowRadius,
+            /* startAngle = */ 180 + thetaDeg,
+            /* sweepAngle = */ 180 - (2 * thetaDeg),
+            /* forceMoveTo = */ false
+        )
+        // Add the right side of the triangle
+        path.lineTo(config.arrowWidth, config.arrowHeight)
+        // Close the path
+        path.close()
+    }
+
+    /** The value of the arrow position provided the position and current bounds */
+    private fun positionValue(position: ArrowPosition): Float {
+        return when (position) {
+            is ArrowPosition.Start -> 0f
+            is ArrowPosition.Center -> bounds.width().toFloat() / 2f
+            is ArrowPosition.End -> bounds.width().toFloat()
+            is ArrowPosition.Custom -> position.value
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/bubbles/BubblePopupView.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/bubbles/BubblePopupView.kt
new file mode 100644
index 0000000..f8a4946
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/bubbles/BubblePopupView.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.wm.shell.common.bubbles
+
+import android.content.Context
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.widget.LinearLayout
+
+/** A popup container view that uses [BubblePopupDrawable] as a background */
+open class BubblePopupView
+@JvmOverloads
+constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+    defStyleRes: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {
+    private var popupDrawable: BubblePopupDrawable? = null
+
+    /**
+     * Sets up the popup drawable with the config provided. Required to remove dependency on local
+     * resources
+     */
+    fun setupBackground(config: BubblePopupDrawable.Config) {
+        popupDrawable = BubblePopupDrawable(config)
+        background = popupDrawable
+        forceLayout()
+    }
+
+    /**
+     * Sets the arrow direction for the background drawable and updates the padding to fit the
+     * content inside of the popup drawable
+     */
+    fun setArrowDirection(direction: BubblePopupDrawable.ArrowDirection) {
+        popupDrawable?.let {
+            it.arrowDirection = direction
+            val padding = Rect()
+            if (it.getPadding(padding)) {
+                setPadding(padding.left, padding.top, padding.right, padding.bottom)
+            }
+        }
+    }
+
+    /** Sets the arrow position for the background drawable and triggers redraw */
+    fun setArrowPosition(position: BubblePopupDrawable.ArrowPosition) {
+        popupDrawable?.let {
+            it.arrowPosition = position
+            invalidate()
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java
index b251f6f..8f0a8e1 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java
@@ -33,7 +33,6 @@
 import android.graphics.PointF;
 import android.graphics.Rect;
 import android.os.Debug;
-import android.os.SystemProperties;
 
 import com.android.internal.protolog.common.ProtoLog;
 import com.android.wm.shell.R;
@@ -48,19 +47,16 @@
 import com.android.wm.shell.pip.PipTransitionController;
 import com.android.wm.shell.protolog.ShellProtoLogGroup;
 
-import java.util.function.Consumer;
-
 import kotlin.Unit;
 import kotlin.jvm.functions.Function0;
 
+import java.util.function.Consumer;
+
 /**
  * A helper to animate and manipulate the PiP.
  */
 public class PipMotionHelper implements PipAppOpsListener.Callback,
         FloatingContentCoordinator.FloatingContent {
-
-    public static final boolean ENABLE_FLING_TO_DISMISS_PIP =
-            SystemProperties.getBoolean("persist.wm.debug.fling_to_dismiss_pip", false);
     private static final String TAG = "PipMotionHelper";
     private static final boolean DEBUG = false;
 
@@ -707,7 +703,7 @@
                     loc[1] = animatedPipBounds.top;
                 }
             };
-            mMagnetizedPip.setFlingToTargetEnabled(ENABLE_FLING_TO_DISMISS_PIP);
+            mMagnetizedPip.setFlingToTargetEnabled(false);
         }
 
         return mMagnetizedPip;
diff --git a/libs/input/MouseCursorController.cpp b/libs/input/MouseCursorController.cpp
index c3ad767..6a46544 100644
--- a/libs/input/MouseCursorController.cpp
+++ b/libs/input/MouseCursorController.cpp
@@ -47,7 +47,7 @@
     mLocked.pointerX = 0;
     mLocked.pointerY = 0;
     mLocked.pointerAlpha = 0.0f; // pointer is initially faded
-    mLocked.pointerSprite = mContext.getSpriteController()->createSprite();
+    mLocked.pointerSprite = mContext.getSpriteController().createSprite();
     mLocked.updatePointerIcon = false;
     mLocked.requestedPointerType = PointerIconStyle::TYPE_NOT_SPECIFIED;
     mLocked.resolvedPointerType = PointerIconStyle::TYPE_NOT_SPECIFIED;
@@ -325,8 +325,8 @@
     }
 
     if (timestamp - mLocked.lastFrameUpdatedTime > iter->second.durationPerFrame) {
-        sp<SpriteController> spriteController = mContext.getSpriteController();
-        spriteController->openTransaction();
+        auto& spriteController = mContext.getSpriteController();
+        spriteController.openTransaction();
 
         int incr = (timestamp - mLocked.lastFrameUpdatedTime) / iter->second.durationPerFrame;
         mLocked.animationFrameIndex += incr;
@@ -336,7 +336,7 @@
         }
         mLocked.pointerSprite->setIcon(iter->second.animationFrames[mLocked.animationFrameIndex]);
 
-        spriteController->closeTransaction();
+        spriteController.closeTransaction();
     }
     // Keep animating.
     return true;
@@ -346,8 +346,8 @@
     if (!mLocked.viewport.isValid()) {
         return;
     }
-    sp<SpriteController> spriteController = mContext.getSpriteController();
-    spriteController->openTransaction();
+    auto& spriteController = mContext.getSpriteController();
+    spriteController.openTransaction();
 
     mLocked.pointerSprite->setLayer(Sprite::BASE_LAYER_POINTER);
     mLocked.pointerSprite->setPosition(mLocked.pointerX, mLocked.pointerY);
@@ -392,7 +392,7 @@
         mLocked.updatePointerIcon = false;
     }
 
-    spriteController->closeTransaction();
+    spriteController.closeTransaction();
 }
 
 void MouseCursorController::loadResourcesLocked(bool getAdditionalMouseResources) REQUIRES(mLock) {
diff --git a/libs/input/PointerController.cpp b/libs/input/PointerController.cpp
index bb3d9d7..435452c 100644
--- a/libs/input/PointerController.cpp
+++ b/libs/input/PointerController.cpp
@@ -63,7 +63,7 @@
 
 std::shared_ptr<PointerController> PointerController::create(
         const sp<PointerControllerPolicyInterface>& policy, const sp<Looper>& looper,
-        const sp<SpriteController>& spriteController) {
+        SpriteController& spriteController) {
     // using 'new' to access non-public constructor
     std::shared_ptr<PointerController> controller = std::shared_ptr<PointerController>(
             new PointerController(policy, looper, spriteController));
@@ -85,8 +85,7 @@
 }
 
 PointerController::PointerController(const sp<PointerControllerPolicyInterface>& policy,
-                                     const sp<Looper>& looper,
-                                     const sp<SpriteController>& spriteController)
+                                     const sp<Looper>& looper, SpriteController& spriteController)
       : PointerController(
                 policy, looper, spriteController,
                 [](const sp<android::gui::WindowInfosListener>& listener) {
@@ -97,8 +96,7 @@
                 }) {}
 
 PointerController::PointerController(const sp<PointerControllerPolicyInterface>& policy,
-                                     const sp<Looper>& looper,
-                                     const sp<SpriteController>& spriteController,
+                                     const sp<Looper>& looper, SpriteController& spriteController,
                                      WindowListenerConsumer registerListener,
                                      WindowListenerConsumer unregisterListener)
       : mContext(policy, looper, spriteController, *this),
diff --git a/libs/input/PointerController.h b/libs/input/PointerController.h
index 62ee743..c7e772d 100644
--- a/libs/input/PointerController.h
+++ b/libs/input/PointerController.h
@@ -47,7 +47,7 @@
 public:
     static std::shared_ptr<PointerController> create(
             const sp<PointerControllerPolicyInterface>& policy, const sp<Looper>& looper,
-            const sp<SpriteController>& spriteController);
+            SpriteController& spriteController);
 
     ~PointerController() override;
 
@@ -83,13 +83,12 @@
 
     // Constructor used to test WindowInfosListener registration.
     PointerController(const sp<PointerControllerPolicyInterface>& policy, const sp<Looper>& looper,
-                      const sp<SpriteController>& spriteController,
-                      WindowListenerConsumer registerListener,
+                      SpriteController& spriteController, WindowListenerConsumer registerListener,
                       WindowListenerConsumer unregisterListener);
 
 private:
     PointerController(const sp<PointerControllerPolicyInterface>& policy, const sp<Looper>& looper,
-                      const sp<SpriteController>& spriteController);
+                      SpriteController& spriteController);
 
     friend PointerControllerContext::LooperCallback;
     friend PointerControllerContext::MessageHandler;
diff --git a/libs/input/PointerControllerContext.cpp b/libs/input/PointerControllerContext.cpp
index c1545107..15c3517 100644
--- a/libs/input/PointerControllerContext.cpp
+++ b/libs/input/PointerControllerContext.cpp
@@ -32,7 +32,7 @@
 
 PointerControllerContext::PointerControllerContext(
         const sp<PointerControllerPolicyInterface>& policy, const sp<Looper>& looper,
-        const sp<SpriteController>& spriteController, PointerController& controller)
+        SpriteController& spriteController, PointerController& controller)
       : mPolicy(policy),
         mLooper(looper),
         mSpriteController(spriteController),
@@ -93,7 +93,7 @@
     return mPolicy;
 }
 
-sp<SpriteController> PointerControllerContext::getSpriteController() {
+SpriteController& PointerControllerContext::getSpriteController() {
     return mSpriteController;
 }
 
diff --git a/libs/input/PointerControllerContext.h b/libs/input/PointerControllerContext.h
index f6f5d3b..98c3988 100644
--- a/libs/input/PointerControllerContext.h
+++ b/libs/input/PointerControllerContext.h
@@ -92,7 +92,7 @@
 class PointerControllerContext {
 public:
     PointerControllerContext(const sp<PointerControllerPolicyInterface>& policy,
-                             const sp<Looper>& looper, const sp<SpriteController>& spriteController,
+                             const sp<Looper>& looper, SpriteController& spriteController,
                              PointerController& controller);
     ~PointerControllerContext();
 
@@ -109,7 +109,7 @@
     void setCallbackController(std::shared_ptr<PointerController> controller);
 
     sp<PointerControllerPolicyInterface> getPolicy();
-    sp<SpriteController> getSpriteController();
+    SpriteController& getSpriteController();
 
     void handleDisplayEvents();
 
@@ -163,7 +163,7 @@
 
     sp<PointerControllerPolicyInterface> mPolicy;
     sp<Looper> mLooper;
-    sp<SpriteController> mSpriteController;
+    SpriteController& mSpriteController;
     sp<MessageHandler> mHandler;
     sp<LooperCallback> mCallback;
 
diff --git a/libs/input/SpriteController.cpp b/libs/input/SpriteController.cpp
index d40f49e..6dc45a6 100644
--- a/libs/input/SpriteController.cpp
+++ b/libs/input/SpriteController.cpp
@@ -37,10 +37,10 @@
     mLocked.deferredSpriteUpdate = false;
 }
 
-void SpriteController::setHandlerController(const sp<android::SpriteController>& controller) {
-    // Initialize the weak message handler outside the constructor, because we cannot get a strong
-    // pointer to self in the constructor as the initial ref count is only incremented after
-    // construction.
+void SpriteController::setHandlerController(
+        const std::shared_ptr<android::SpriteController>& controller) {
+    // Initialize the weak message handler outside the constructor, because we cannot get a shared
+    // pointer to self in the constructor.
     mHandler->spriteController = controller;
 }
 
@@ -54,7 +54,7 @@
 }
 
 sp<Sprite> SpriteController::createSprite() {
-    return sp<SpriteImpl>::make(sp<SpriteController>::fromExisting(this));
+    return sp<SpriteImpl>::make(*this);
 }
 
 void SpriteController::openTransaction() {
@@ -352,7 +352,7 @@
 // --- SpriteController::Handler ---
 
 void SpriteController::Handler::handleMessage(const android::Message& message) {
-    auto controller = spriteController.promote();
+    auto controller = spriteController.lock();
     if (!controller) {
         return;
     }
@@ -369,22 +369,21 @@
 
 // --- SpriteController::SpriteImpl ---
 
-SpriteController::SpriteImpl::SpriteImpl(const sp<SpriteController>& controller)
-      : mController(controller) {}
+SpriteController::SpriteImpl::SpriteImpl(SpriteController& controller) : mController(controller) {}
 
 SpriteController::SpriteImpl::~SpriteImpl() {
-    AutoMutex _m(mController->mLock);
+    AutoMutex _m(mController.mLock);
 
     // Let the controller take care of deleting the last reference to sprite
     // surfaces so that we do not block the caller on an IPC here.
     if (mLocked.state.surfaceControl != NULL) {
-        mController->disposeSurfaceLocked(mLocked.state.surfaceControl);
+        mController.disposeSurfaceLocked(mLocked.state.surfaceControl);
         mLocked.state.surfaceControl.clear();
     }
 }
 
 void SpriteController::SpriteImpl::setIcon(const SpriteIcon& icon) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     uint32_t dirty;
     if (icon.isValid()) {
@@ -414,7 +413,7 @@
 }
 
 void SpriteController::SpriteImpl::setVisible(bool visible) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     if (mLocked.state.visible != visible) {
         mLocked.state.visible = visible;
@@ -423,7 +422,7 @@
 }
 
 void SpriteController::SpriteImpl::setPosition(float x, float y) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     if (mLocked.state.positionX != x || mLocked.state.positionY != y) {
         mLocked.state.positionX = x;
@@ -433,7 +432,7 @@
 }
 
 void SpriteController::SpriteImpl::setLayer(int32_t layer) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     if (mLocked.state.layer != layer) {
         mLocked.state.layer = layer;
@@ -442,7 +441,7 @@
 }
 
 void SpriteController::SpriteImpl::setAlpha(float alpha) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     if (mLocked.state.alpha != alpha) {
         mLocked.state.alpha = alpha;
@@ -452,7 +451,7 @@
 
 void SpriteController::SpriteImpl::setTransformationMatrix(
         const SpriteTransformationMatrix& matrix) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     if (mLocked.state.transformationMatrix != matrix) {
         mLocked.state.transformationMatrix = matrix;
@@ -461,7 +460,7 @@
 }
 
 void SpriteController::SpriteImpl::setDisplayId(int32_t displayId) {
-    AutoMutex _l(mController->mLock);
+    AutoMutex _l(mController.mLock);
 
     if (mLocked.state.displayId != displayId) {
         mLocked.state.displayId = displayId;
@@ -474,7 +473,7 @@
     mLocked.state.dirty |= dirty;
 
     if (!wasDirty) {
-        mController->invalidateSpriteLocked(sp<SpriteImpl>::fromExisting(this));
+        mController.invalidateSpriteLocked(sp<SpriteImpl>::fromExisting(this));
     }
 }
 
diff --git a/libs/input/SpriteController.h b/libs/input/SpriteController.h
index 3144401..04ecb38 100644
--- a/libs/input/SpriteController.h
+++ b/libs/input/SpriteController.h
@@ -109,18 +109,19 @@
  *
  * Clients are responsible for animating sprites by periodically updating their properties.
  */
-class SpriteController : public RefBase {
-protected:
-    virtual ~SpriteController();
-
+class SpriteController {
 public:
     using ParentSurfaceProvider = std::function<sp<SurfaceControl>(int /*displayId*/)>;
     SpriteController(const sp<Looper>& looper, int32_t overlayLayer, ParentSurfaceProvider parent);
+    SpriteController(const SpriteController&) = delete;
+    SpriteController& operator=(const SpriteController&) = delete;
+    virtual ~SpriteController();
 
     /* Initialize the callback for the message handler. */
-    void setHandlerController(const sp<SpriteController>& controller);
+    void setHandlerController(const std::shared_ptr<SpriteController>& controller);
 
-    /* Creates a new sprite, initially invisible. */
+    /* Creates a new sprite, initially invisible. The lifecycle of the sprite must not extend beyond
+     * the lifecycle of this SpriteController. */
     virtual sp<Sprite> createSprite();
 
     /* Opens or closes a transaction to perform a batch of sprite updates as part of
@@ -137,7 +138,7 @@
         enum { MSG_UPDATE_SPRITES, MSG_DISPOSE_SURFACES };
 
         void handleMessage(const Message& message) override;
-        wp<SpriteController> spriteController;
+        std::weak_ptr<SpriteController> spriteController;
     };
 
     enum {
@@ -198,7 +199,7 @@
         virtual ~SpriteImpl();
 
     public:
-        explicit SpriteImpl(const sp<SpriteController>& controller);
+        explicit SpriteImpl(SpriteController& controller);
 
         virtual void setIcon(const SpriteIcon& icon);
         virtual void setVisible(bool visible);
@@ -226,7 +227,7 @@
         }
 
     private:
-        sp<SpriteController> mController;
+        SpriteController& mController;
 
         struct Locked {
             SpriteState state;
diff --git a/libs/input/TouchSpotController.cpp b/libs/input/TouchSpotController.cpp
index c212608..b8de919 100644
--- a/libs/input/TouchSpotController.cpp
+++ b/libs/input/TouchSpotController.cpp
@@ -98,8 +98,8 @@
 #endif
 
     std::scoped_lock lock(mLock);
-    sp<SpriteController> spriteController = mContext.getSpriteController();
-    spriteController->openTransaction();
+    auto& spriteController = mContext.getSpriteController();
+    spriteController.openTransaction();
 
     // Add or move spots for fingers that are down.
     for (BitSet32 idBits(spotIdBits); !idBits.isEmpty();) {
@@ -125,7 +125,7 @@
         }
     }
 
-    spriteController->closeTransaction();
+    spriteController.closeTransaction();
 }
 
 void TouchSpotController::clearSpots() {
@@ -167,7 +167,7 @@
         sprite = mLocked.recycledSprites.back();
         mLocked.recycledSprites.pop_back();
     } else {
-        sprite = mContext.getSpriteController()->createSprite();
+        sprite = mContext.getSpriteController().createSprite();
     }
 
     // Return the new spot.
diff --git a/libs/input/tests/PointerController_test.cpp b/libs/input/tests/PointerController_test.cpp
index 8574751..3e2e43f 100644
--- a/libs/input/tests/PointerController_test.cpp
+++ b/libs/input/tests/PointerController_test.cpp
@@ -157,7 +157,7 @@
 
     sp<MockSprite> mPointerSprite;
     sp<MockPointerControllerPolicyInterface> mPolicy;
-    sp<MockSpriteController> mSpriteController;
+    std::unique_ptr<MockSpriteController> mSpriteController;
     std::shared_ptr<PointerController> mPointerController;
 
 private:
@@ -175,14 +175,13 @@
 
 PointerControllerTest::PointerControllerTest() : mPointerSprite(new NiceMock<MockSprite>),
         mLooper(new MyLooper), mThread(&PointerControllerTest::loopThread, this) {
-
-    mSpriteController = new NiceMock<MockSpriteController>(mLooper);
+    mSpriteController.reset(new NiceMock<MockSpriteController>(mLooper));
     mPolicy = new MockPointerControllerPolicyInterface();
 
     EXPECT_CALL(*mSpriteController, createSprite())
             .WillOnce(Return(mPointerSprite));
 
-    mPointerController = PointerController::create(mPolicy, mLooper, mSpriteController);
+    mPointerController = PointerController::create(mPolicy, mLooper, *mSpriteController);
 }
 
 PointerControllerTest::~PointerControllerTest() {
@@ -319,10 +318,9 @@
 class TestPointerController : public PointerController {
 public:
     TestPointerController(sp<android::gui::WindowInfosListener>& registeredListener,
-                          const sp<Looper>& looper)
+                          const sp<Looper>& looper, SpriteController& spriteController)
           : PointerController(
-                    new MockPointerControllerPolicyInterface(), looper,
-                    new NiceMock<MockSpriteController>(looper),
+                    new MockPointerControllerPolicyInterface(), looper, spriteController,
                     [&registeredListener](const sp<android::gui::WindowInfosListener>& listener) {
                         // Register listener
                         registeredListener = listener;
@@ -335,10 +333,12 @@
 
 TEST_F(PointerControllerWindowInfoListenerTest,
        doesNotCrashIfListenerCalledAfterPointerControllerDestroyed) {
+    sp<Looper> looper = new Looper(false);
+    auto spriteController = NiceMock<MockSpriteController>(looper);
     sp<android::gui::WindowInfosListener> registeredListener;
     sp<android::gui::WindowInfosListener> localListenerCopy;
     {
-        TestPointerController pointerController(registeredListener, new Looper(false));
+        TestPointerController pointerController(registeredListener, looper, spriteController);
         ASSERT_NE(nullptr, registeredListener) << "WindowInfosListener was not registered";
         localListenerCopy = registeredListener;
     }
diff --git a/media/java/android/media/projection/IMediaProjectionManager.aidl b/media/java/android/media/projection/IMediaProjectionManager.aidl
index 304eecb..d294601 100644
--- a/media/java/android/media/projection/IMediaProjectionManager.aidl
+++ b/media/java/android/media/projection/IMediaProjectionManager.aidl
@@ -108,6 +108,7 @@
                 + ".permission.MANAGE_MEDIA_PROJECTION)")
     void notifyActiveProjectionCapturedContentVisibilityChanged(boolean isVisible);
 
+    @EnforcePermission("MANAGE_MEDIA_PROJECTION")
     @JavaPassthrough(annotation = "@android.annotation.RequiresPermission(android.Manifest"
                 + ".permission.MANAGE_MEDIA_PROJECTION)")
     void addCallback(IMediaProjectionWatcherCallback callback);
diff --git a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt
index 3f7cc19..b650034 100644
--- a/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt
+++ b/packages/SettingsLib/Spa/spa/src/com/android/settingslib/spa/framework/compose/Keyboards.kt
@@ -21,7 +21,6 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.platform.LocalSoftwareKeyboardController
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.filter
@@ -29,7 +28,6 @@
 /**
  * An action when run, hides the keyboard if it's open.
  */
-@OptIn(ExperimentalComposeUiApi::class)
 @Composable
 fun hideKeyboardAction(): () -> Unit {
     val keyboardController = LocalSoftwareKeyboardController.current
@@ -41,7 +39,6 @@
  *
  * And when user scrolling the lazy list, hides the keyboard if it's open.
  */
-@OptIn(ExperimentalComposeUiApi::class)
 @Composable
 fun rememberLazyListStateAndHideKeyboardWhenStartScroll(): LazyListState {
     val listState = rememberLazyListState()
diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/compose/KeyboardsTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/compose/KeyboardsTest.kt
index 944ef7f..e9b3109 100644
--- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/compose/KeyboardsTest.kt
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/compose/KeyboardsTest.kt
@@ -21,7 +21,6 @@
 import androidx.compose.material3.Text
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalSoftwareKeyboardController
 import androidx.compose.ui.platform.SoftwareKeyboardController
@@ -32,12 +31,11 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mock
-import org.mockito.Mockito.never
-import org.mockito.Mockito.verify
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
 
-@OptIn(ExperimentalComposeUiApi::class)
 @RunWith(AndroidJUnit4::class)
 class KeyboardsTest {
     @get:Rule
diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/theme/SettingsThemeTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/theme/SettingsThemeTest.kt
index 2ff3039..bd8a54b 100644
--- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/theme/SettingsThemeTest.kt
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/theme/SettingsThemeTest.kt
@@ -31,10 +31,10 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mock
-import org.mockito.Mockito.anyInt
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
-import org.mockito.Mockito.`when` as whenever
+import org.mockito.kotlin.any
+import org.mockito.kotlin.whenever
 
 @RunWith(AndroidJUnit4::class)
 class SettingsThemeTest {
@@ -55,7 +55,7 @@
     @Before
     fun setUp() {
         whenever(context.resources).thenReturn(resources)
-        whenever(resources.getString(anyInt())).thenReturn("")
+        whenever(resources.getString(any())).thenReturn("")
     }
 
     private fun mockAndroidConfig(configName: String, configValue: String) {
diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/util/AnnotatedTextTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/util/AnnotatedTextTest.kt
index 2c218e3..5e59620 100644
--- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/util/AnnotatedTextTest.kt
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/framework/util/AnnotatedTextTest.kt
@@ -32,9 +32,9 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mock
-import org.mockito.Mockito.verify
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.verify
 
 @RunWith(AndroidJUnit4::class)
 class AnnotatedTextTest {
diff --git a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffoldTest.kt b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffoldTest.kt
index 872d957..3e8fdec 100644
--- a/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffoldTest.kt
+++ b/packages/SettingsLib/Spa/tests/src/com/android/settingslib/spa/widget/scaffold/SettingsScaffoldTest.kt
@@ -33,9 +33,9 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.Mock
-import org.mockito.Mockito.verify
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.verify
 
 @RunWith(AndroidJUnit4::class)
 class SettingsScaffoldTest {
diff --git a/packages/SettingsLib/Spa/testutils/Android.bp b/packages/SettingsLib/Spa/testutils/Android.bp
index 65f5d34..4031cd7 100644
--- a/packages/SettingsLib/Spa/testutils/Android.bp
+++ b/packages/SettingsLib/Spa/testutils/Android.bp
@@ -30,7 +30,7 @@
         "androidx.compose.ui_ui-test-junit4",
         "androidx.compose.ui_ui-test-manifest",
         "androidx.lifecycle_lifecycle-runtime-testing",
-        "mockito",
+        "mockito-kotlin2",
         "truth-prebuilt",
     ],
     kotlincflags: [
diff --git a/packages/SettingsLib/Spa/testutils/build.gradle.kts b/packages/SettingsLib/Spa/testutils/build.gradle.kts
index f5a22c9..50243dc 100644
--- a/packages/SettingsLib/Spa/testutils/build.gradle.kts
+++ b/packages/SettingsLib/Spa/testutils/build.gradle.kts
@@ -41,7 +41,12 @@
     api("androidx.arch.core:core-testing:2.2.0-alpha01")
     api("androidx.compose.ui:ui-test-junit4:$jetpackComposeVersion")
     api("androidx.lifecycle:lifecycle-runtime-testing")
+    api("org.mockito.kotlin:mockito-kotlin:5.1.0")
+    api("org.mockito:mockito-core") {
+        version {
+            strictly("2.28.2")
+        }
+    }
     api(libs.truth)
-    api("org.mockito:mockito-core:2.21.0")
     debugApi("androidx.compose.ui:ui-test-manifest:$jetpackComposeVersion")
 }
diff --git a/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/MockitoHelper.kt b/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/MockitoHelper.kt
deleted file mode 100644
index 5ba54c1..0000000
--- a/packages/SettingsLib/Spa/testutils/src/com/android/settingslib/spa/testutils/MockitoHelper.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright (C) 2022 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.settingslib.spa.testutils
-
-import org.mockito.Mockito
-
-/**
- * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when null is
- * returned.
- *
- * Generic T is nullable because implicitly bounded by Any?.
- */
-fun <T> any(type: Class<T>): T = Mockito.any(type)
-
-inline fun <reified T> any(): T = any(T::class.java)
diff --git a/packages/SystemUI/res-keyguard/layout/shade_carrier_new.xml b/packages/SystemUI/res-keyguard/layout/shade_carrier_new.xml
new file mode 100644
index 0000000..952f056
--- /dev/null
+++ b/packages/SystemUI/res-keyguard/layout/shade_carrier_new.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+**
+** Copyright 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.
+*/
+-->
+<com.android.systemui.statusbar.pipeline.mobile.ui.view.ModernShadeCarrierGroupMobileView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/carrier_combo"
+    android:layout_width="wrap_content"
+    android:layout_height="match_parent"
+    android:gravity="center_vertical"
+    android:orientation="horizontal" >
+
+    <com.android.systemui.util.AutoMarqueeTextView
+        android:id="@+id/mobile_carrier_text"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:layout_marginEnd="@dimen/qs_carrier_margin_width"
+        android:visibility="gone"
+        android:textDirection="locale"
+        android:marqueeRepeatLimit="marquee_forever"
+        android:singleLine="true"
+        android:maxEms="7"/>
+
+    <include layout="@layout/status_bar_mobile_signal_group_new" />
+
+</com.android.systemui.statusbar.pipeline.mobile.ui.view.ModernShadeCarrierGroupMobileView>
+
diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt
index 1a8e642..f0a048b 100644
--- a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt
+++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt
@@ -368,10 +368,6 @@
     // TODO(b/244064524): Tracking Bug
     @JvmField val QS_SECONDARY_DATA_SUB_INFO = releasedFlag("qs_secondary_data_sub_info")
 
-    /** Enables Font Scaling Quick Settings tile */
-    // TODO(b/269341316): Tracking Bug
-    @JvmField val ENABLE_FONT_SCALING_TILE = releasedFlag("enable_font_scaling_tile")
-
     /** Enables new QS Edit Mode visual refresh */
     // TODO(b/269787742): Tracking Bug
     @JvmField
@@ -398,6 +394,10 @@
     // TODO(b/294588085): Tracking Bug
     val WIFI_SECONDARY_NETWORKS = releasedFlag("wifi_secondary_networks")
 
+    // TODO(b/290676905): Tracking Bug
+    val NEW_SHADE_CARRIER_GROUP_MOBILE_ICONS =
+        unreleasedFlag("new_shade_carrier_group_mobile_icons")
+
     // 700 - dialer/calls
     // TODO(b/254512734): Tracking Bug
     val ONGOING_CALL_STATUS_BAR_CHIP = releasedFlag("ongoing_call_status_bar_chip")
@@ -502,16 +502,6 @@
     val WM_CAPTION_ON_SHELL =
         sysPropBooleanFlag("persist.wm.debug.caption_on_shell", default = true)
 
-    @Keep
-    @JvmField
-    val ENABLE_FLING_TO_DISMISS_BUBBLE =
-        sysPropBooleanFlag("persist.wm.debug.fling_to_dismiss_bubble", default = true)
-
-    @Keep
-    @JvmField
-    val ENABLE_FLING_TO_DISMISS_PIP =
-        sysPropBooleanFlag("persist.wm.debug.fling_to_dismiss_pip", default = true)
-
     // TODO(b/256873975): Tracking Bug
     @JvmField
     @Keep
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt
index 423fa80..1f9979a 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/FontScalingTile.kt
@@ -28,8 +28,6 @@
 import com.android.systemui.animation.DialogLaunchAnimator
 import com.android.systemui.dagger.qualifiers.Background
 import com.android.systemui.dagger.qualifiers.Main
-import com.android.systemui.flags.FeatureFlags
-import com.android.systemui.flags.Flags
 import com.android.systemui.plugins.ActivityStarter
 import com.android.systemui.plugins.FalsingManager
 import com.android.systemui.plugins.qs.QSTile
@@ -64,7 +62,6 @@
     private val systemSettings: SystemSettings,
     private val secureSettings: SecureSettings,
     private val systemClock: SystemClock,
-    private val featureFlags: FeatureFlags,
     private val userTracker: UserTracker,
     @Background private val backgroundDelayableExecutor: DelayableExecutor
 ) :
@@ -81,10 +78,6 @@
     ) {
     private val icon = ResourceIcon.get(R.drawable.ic_qs_font_scaling)
 
-    override fun isAvailable(): Boolean {
-        return featureFlags.isEnabled(Flags.ENABLE_FONT_SCALING_TILE)
-    }
-
     override fun newTileState(): QSTile.State {
         return QSTile.State()
     }
diff --git a/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrier.java b/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrier.java
index 8586828..8612cdf 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrier.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrier.java
@@ -34,6 +34,7 @@
 import com.android.settingslib.graph.SignalDrawable;
 import com.android.systemui.FontSizeUtils;
 import com.android.systemui.R;
+import com.android.systemui.statusbar.pipeline.mobile.ui.view.ModernShadeCarrierGroupMobileView;
 import com.android.systemui.util.LargeScreenUtils;
 
 import java.util.Objects;
@@ -44,6 +45,7 @@
     private TextView mCarrierText;
     private ImageView mMobileSignal;
     private ImageView mMobileRoaming;
+    private ModernShadeCarrierGroupMobileView mModernMobileView;
     private View mSpacer;
     @Nullable
     private CellSignalState mLastSignalState;
@@ -77,6 +79,23 @@
         updateResources();
     }
 
+    /** Removes a ModernStatusBarMobileView from the ViewGroup. */
+    public void removeModernMobileView() {
+        if (mModernMobileView != null) {
+            removeView(mModernMobileView);
+            mModernMobileView = null;
+        }
+    }
+
+    /** Adds a ModernStatusBarMobileView to the ViewGroup. */
+    public void addModernMobileView(ModernShadeCarrierGroupMobileView mobileView) {
+        mModernMobileView = mobileView;
+        mMobileGroup.setVisibility(View.GONE);
+        mSpacer.setVisibility(View.GONE);
+        mCarrierText.setVisibility(View.GONE);
+        addView(mobileView);
+    }
+
     /**
      * Update the state of this view
      * @param state the current state of the signal for this view
diff --git a/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrierGroupController.java b/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrierGroupController.java
index ad49b26..98d8a53 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrierGroupController.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/carrier/ShadeCarrierGroupController.java
@@ -16,6 +16,7 @@
 
 package com.android.systemui.shade.carrier;
 
+import static android.telephony.SubscriptionManager.INVALID_SIM_SLOT_INDEX;
 import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES;
 
 import android.annotation.MainThread;
@@ -46,8 +47,17 @@
 import com.android.systemui.statusbar.connectivity.MobileDataIndicators;
 import com.android.systemui.statusbar.connectivity.NetworkController;
 import com.android.systemui.statusbar.connectivity.SignalCallback;
+import com.android.systemui.statusbar.connectivity.ui.MobileContextProvider;
+import com.android.systemui.statusbar.phone.StatusBarLocation;
+import com.android.systemui.statusbar.pipeline.StatusBarPipelineFlags;
+import com.android.systemui.statusbar.pipeline.mobile.ui.MobileUiAdapter;
+import com.android.systemui.statusbar.pipeline.mobile.ui.binder.MobileIconsBinder;
+import com.android.systemui.statusbar.pipeline.mobile.ui.view.ModernShadeCarrierGroupMobileView;
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel;
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.ShadeCarrierGroupMobileIconViewModel;
 import com.android.systemui.util.CarrierConfigTracker;
 
+import java.util.List;
 import java.util.function.Consumer;
 
 import javax.inject.Inject;
@@ -62,12 +72,16 @@
 
     private final ActivityStarter mActivityStarter;
     private final Handler mBgHandler;
+    private final Context mContext;
     private final NetworkController mNetworkController;
     private final CarrierTextManager mCarrierTextManager;
     private final TextView mNoSimTextView;
     // Non final for testing
     private H mMainHandler;
     private final Callback mCallback;
+    private final MobileIconsViewModel mMobileIconsViewModel;
+    private final MobileContextProvider mMobileContextProvider;
+    private final StatusBarPipelineFlags mStatusBarPipelineFlags;
     private boolean mListening;
     private final CellSignalState[] mInfos =
             new CellSignalState[SIM_SLOTS];
@@ -91,7 +105,7 @@
                         Log.w(TAG, "setMobileDataIndicators - slot: " + slotIndex);
                         return;
                     }
-                    if (slotIndex == SubscriptionManager.INVALID_SIM_SLOT_INDEX) {
+                    if (slotIndex == INVALID_SIM_SLOT_INDEX) {
                         Log.e(TAG, "Invalid SIM slot index for subscription: " + indicators.subId);
                         return;
                     }
@@ -129,15 +143,25 @@
         }
     }
 
-    private ShadeCarrierGroupController(ShadeCarrierGroup view, ActivityStarter activityStarter,
-            @Background Handler bgHandler, @Main Looper mainLooper,
+    private ShadeCarrierGroupController(
+            ShadeCarrierGroup view,
+            ActivityStarter activityStarter,
+            @Background Handler bgHandler,
+            @Main Looper mainLooper,
             NetworkController networkController,
-            CarrierTextManager.Builder carrierTextManagerBuilder, Context context,
-            CarrierConfigTracker carrierConfigTracker, SlotIndexResolver slotIndexResolver) {
-
+            CarrierTextManager.Builder carrierTextManagerBuilder,
+            Context context,
+            CarrierConfigTracker carrierConfigTracker,
+            SlotIndexResolver slotIndexResolver,
+            MobileUiAdapter mobileUiAdapter,
+            MobileContextProvider mobileContextProvider,
+            StatusBarPipelineFlags statusBarPipelineFlags
+    ) {
+        mContext = context;
         mActivityStarter = activityStarter;
         mBgHandler = bgHandler;
         mNetworkController = networkController;
+        mStatusBarPipelineFlags = statusBarPipelineFlags;
         mCarrierTextManager = carrierTextManagerBuilder
                 .setShowAirplaneMode(false)
                 .setShowMissingSim(false)
@@ -162,6 +186,14 @@
         mCarrierGroups[1] = view.getCarrier2View();
         mCarrierGroups[2] = view.getCarrier3View();
 
+        mMobileContextProvider = mobileContextProvider;
+        mMobileIconsViewModel = mobileUiAdapter.getMobileIconsViewModel();
+
+        if (mStatusBarPipelineFlags.useNewShadeCarrierGroupMobileIcons()) {
+            mobileUiAdapter.setShadeCarrierGroupController(this);
+            MobileIconsBinder.bind(view, mMobileIconsViewModel);
+        }
+
         mCarrierDividers[0] = view.getCarrierDivider1();
         mCarrierDividers[1] = view.getCarrierDivider2();
 
@@ -193,6 +225,50 @@
         });
     }
 
+    /** Updates the number of visible mobile icons using the new pipeline. */
+    public void updateModernMobileIcons(List<Integer> subIds) {
+        if (!mStatusBarPipelineFlags.useNewShadeCarrierGroupMobileIcons()) {
+            Log.d(TAG, "ignoring new pipeline callback because new mobile icon is disabled");
+            return;
+        }
+
+        for (ShadeCarrier carrier : mCarrierGroups) {
+            carrier.removeModernMobileView();
+        }
+
+        List<IconData> iconDataList = processSubIdList(subIds);
+
+        for (IconData iconData : iconDataList) {
+            ShadeCarrier carrier = mCarrierGroups[iconData.slotIndex];
+
+            Context mobileContext =
+                    mMobileContextProvider.getMobileContextForSub(iconData.subId, mContext);
+            ModernShadeCarrierGroupMobileView modernMobileView = ModernShadeCarrierGroupMobileView
+                    .constructAndBind(
+                        mobileContext,
+                        mMobileIconsViewModel.getLogger(),
+                        "mobile_carrier_shade_group",
+                        (ShadeCarrierGroupMobileIconViewModel) mMobileIconsViewModel
+                                .viewModelForSub(iconData.subId,
+                                    StatusBarLocation.SHADE_CARRIER_GROUP)
+                    );
+            carrier.addModernMobileView(modernMobileView);
+        }
+    }
+
+    @VisibleForTesting
+    List<IconData> processSubIdList(List<Integer> subIds) {
+        return subIds
+                .stream()
+                .limit(SIM_SLOTS)
+                .map(subId -> new IconData(subId, getSlotIndex(subId)))
+                .filter(iconData ->
+                        iconData.slotIndex < SIM_SLOTS
+                                && iconData.slotIndex != INVALID_SIM_SLOT_INDEX
+                )
+                .toList();
+    }
+
     @VisibleForTesting
     protected int getSlotIndex(int subscriptionId) {
         return mSlotIndexResolver.getSlotIndex(subscriptionId);
@@ -269,8 +345,12 @@
             }
         }
 
-        for (int i = 0; i < SIM_SLOTS; i++) {
-            mCarrierGroups[i].updateState(mInfos[i], singleCarrier);
+        if (mStatusBarPipelineFlags.useNewShadeCarrierGroupMobileIcons()) {
+            Log.d(TAG, "ignoring old pipeline callback because new mobile icon is enabled");
+        } else {
+            for (int i = 0; i < SIM_SLOTS; i++) {
+                mCarrierGroups[i].updateState(mInfos[i], singleCarrier);
+            }
         }
 
         mCarrierDividers[0].setVisibility(
@@ -306,7 +386,7 @@
                         Log.w(TAG, "updateInfoCarrier - slot: " + slot);
                         continue;
                     }
-                    if (slot == SubscriptionManager.INVALID_SIM_SLOT_INDEX) {
+                    if (slot == INVALID_SIM_SLOT_INDEX) {
                         Log.e(TAG,
                                 "Invalid SIM slot index for subscription: "
                                         + info.subscriptionIds[i]);
@@ -385,12 +465,24 @@
         private final Context mContext;
         private final CarrierConfigTracker mCarrierConfigTracker;
         private final SlotIndexResolver mSlotIndexResolver;
+        private final MobileUiAdapter mMobileUiAdapter;
+        private final MobileContextProvider mMobileContextProvider;
+        private final StatusBarPipelineFlags mStatusBarPipelineFlags;
 
         @Inject
-        public Builder(ActivityStarter activityStarter, @Background Handler handler,
-                @Main Looper looper, NetworkController networkController,
-                CarrierTextManager.Builder carrierTextControllerBuilder, Context context,
-                CarrierConfigTracker carrierConfigTracker, SlotIndexResolver slotIndexResolver) {
+        public Builder(
+                ActivityStarter activityStarter,
+                @Background Handler handler,
+                @Main Looper looper,
+                NetworkController networkController,
+                CarrierTextManager.Builder carrierTextControllerBuilder,
+                Context context,
+                CarrierConfigTracker carrierConfigTracker,
+                SlotIndexResolver slotIndexResolver,
+                MobileUiAdapter mobileUiAdapter,
+                MobileContextProvider mobileContextProvider,
+                StatusBarPipelineFlags statusBarPipelineFlags
+        ) {
             mActivityStarter = activityStarter;
             mHandler = handler;
             mLooper = looper;
@@ -399,6 +491,9 @@
             mContext = context;
             mCarrierConfigTracker = carrierConfigTracker;
             mSlotIndexResolver = slotIndexResolver;
+            mMobileUiAdapter = mobileUiAdapter;
+            mMobileContextProvider = mobileContextProvider;
+            mStatusBarPipelineFlags = statusBarPipelineFlags;
         }
 
         public Builder setShadeCarrierGroup(ShadeCarrierGroup view) {
@@ -407,9 +502,20 @@
         }
 
         public ShadeCarrierGroupController build() {
-            return new ShadeCarrierGroupController(mView, mActivityStarter, mHandler, mLooper,
-                    mNetworkController, mCarrierTextControllerBuilder, mContext,
-                    mCarrierConfigTracker, mSlotIndexResolver);
+            return new ShadeCarrierGroupController(
+                    mView,
+                    mActivityStarter,
+                    mHandler,
+                    mLooper,
+                    mNetworkController,
+                    mCarrierTextControllerBuilder,
+                    mContext,
+                    mCarrierConfigTracker,
+                    mSlotIndexResolver,
+                    mMobileUiAdapter,
+                    mMobileContextProvider,
+                    mStatusBarPipelineFlags
+            );
         }
     }
 
@@ -448,4 +554,15 @@
             return SubscriptionManager.getSlotIndex(subscriptionId);
         }
     }
+
+    @VisibleForTesting
+    static class IconData {
+        public final int subId;
+        public final int slotIndex;
+
+        IconData(int subId, int slotIndex) {
+            this.subId = subId;
+            this.slotIndex = slotIndex;
+        }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarLocation.kt b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarLocation.kt
index 5ace226..32e5c35 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarLocation.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBarLocation.kt
@@ -24,4 +24,6 @@
     KEYGUARD,
     /** Quick settings (inside the shade). */
     QS,
+    /** ShadeCarrierGroup (above QS status bar in expanded mode). */
+    SHADE_CARRIER_GROUP,
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/StatusBarPipelineFlags.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/StatusBarPipelineFlags.kt
index 6e51ed0..c695773 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/StatusBarPipelineFlags.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/StatusBarPipelineFlags.kt
@@ -18,6 +18,9 @@
 
 import android.content.Context
 import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.flags.FeatureFlags
+import com.android.systemui.flags.Flags
+import com.android.systemui.shade.carrier.ShadeCarrierGroup
 import javax.inject.Inject
 
 /** All flagging methods related to the new status bar pipeline (see b/238425913). */
@@ -26,11 +29,19 @@
 @Inject
 constructor(
     context: Context,
+    private val featureFlags: FeatureFlags,
 ) {
     private val mobileSlot = context.getString(com.android.internal.R.string.status_bar_mobile)
     private val wifiSlot = context.getString(com.android.internal.R.string.status_bar_wifi)
 
     /**
+     * True if we should display the mobile icons in the [ShadeCarrierGroup] using the new status
+     * bar Data pipeline.
+     */
+    fun useNewShadeCarrierGroupMobileIcons(): Boolean =
+        featureFlags.isEnabled(Flags.NEW_SHADE_CARRIER_GROUP_MOBILE_ICONS)
+
+    /**
      * For convenience in the StatusBarIconController, we want to gate some actions based on slot
      * name and the flag together.
      *
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/NetworkNameModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/NetworkNameModel.kt
index 78231e2..99ed2d9 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/NetworkNameModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/NetworkNameModel.kt
@@ -60,6 +60,19 @@
         }
     }
 
+    /** This name has been derived from SubscriptionModel. see [SubscriptionModel] */
+    data class SubscriptionDerived(override val name: String) : NetworkNameModel {
+        override fun logDiffs(prevVal: NetworkNameModel, row: TableRowLogger) {
+            if (prevVal !is SubscriptionDerived || prevVal.name != name) {
+                row.logChange(COL_NETWORK_NAME, "SubscriptionDerived($name)")
+            }
+        }
+
+        override fun logFull(row: TableRowLogger) {
+            row.logChange(COL_NETWORK_NAME, "SubscriptionDerived($name)")
+        }
+    }
+
     /**
      * This name has been derived from the sim via
      * [android.telephony.TelephonyManager.getSimOperatorName].
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt
index 16c4027..27f6df4 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/model/SubscriptionModel.kt
@@ -34,4 +34,7 @@
 
     /** Subscriptions in the same group may be filtered or treated as a single subscription */
     val groupUuid: ParcelUuid? = null,
+
+    /** Text representing the name for this connection */
+    val carrierName: String,
 )
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt
index c1af6df..a89b1b2 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileConnectionRepository.kt
@@ -115,10 +115,18 @@
      */
     val cdmaRoaming: StateFlow<Boolean>
 
-    /** The service provider name for this network connection, or the default name */
+    /** The service provider name for this network connection, or the default name. */
     val networkName: StateFlow<NetworkNameModel>
 
     /**
+     * The service provider name for this network connection, or the default name.
+     *
+     * TODO(b/296600321): De-duplicate this field with [networkName] after determining the data
+     *   provided is identical
+     */
+    val carrierName: StateFlow<NetworkNameModel>
+
+    /**
      * True if this type of connection is allowed while airplane mode is on, and false otherwise.
      */
     val isAllowedDuringAirplaneMode: StateFlow<Boolean>
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepository.kt
index 17d20c2..c576b82 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionRepository.kt
@@ -184,7 +184,10 @@
 
     override val cdmaRoaming = MutableStateFlow(false)
 
-    override val networkName = MutableStateFlow(NetworkNameModel.IntentDerived("demo network"))
+    override val networkName = MutableStateFlow(NetworkNameModel.IntentDerived(DEMO_CARRIER_NAME))
+
+    override val carrierName =
+        MutableStateFlow(NetworkNameModel.SubscriptionDerived(DEMO_CARRIER_NAME))
 
     override val isAllowedDuringAirplaneMode = MutableStateFlow(false)
 
@@ -200,6 +203,7 @@
         // This is always true here, because we split out disabled states at the data-source level
         dataEnabled.value = true
         networkName.value = NetworkNameModel.IntentDerived(event.name)
+        carrierName.value = NetworkNameModel.SubscriptionDerived("${event.name} ${event.subId}")
 
         _carrierId.value = event.carrierId ?: INVALID_SUBSCRIPTION_ID
 
@@ -227,6 +231,7 @@
         // This is always true here, because we split out disabled states at the data-source level
         dataEnabled.value = true
         networkName.value = NetworkNameModel.IntentDerived(CARRIER_MERGED_NAME)
+        carrierName.value = NetworkNameModel.SubscriptionDerived(CARRIER_MERGED_NAME)
         // TODO(b/276943904): is carrierId a thing with carrier merged networks?
         _carrierId.value = INVALID_SUBSCRIPTION_ID
         numberOfLevels.value = event.numberOfLevels
@@ -248,6 +253,7 @@
     }
 
     companion object {
+        private const val DEMO_CARRIER_NAME = "Demo Carrier"
         private const val CARRIER_MERGED_NAME = "Carrier Merged Network"
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt
index 0e4ceeb..ee13d93 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepository.kt
@@ -92,9 +92,12 @@
 
     private fun maybeCreateSubscription(subId: Int) {
         if (!subscriptionInfoCache.containsKey(subId)) {
-            SubscriptionModel(subscriptionId = subId, isOpportunistic = false).also {
-                subscriptionInfoCache[subId] = it
-            }
+            SubscriptionModel(
+                    subscriptionId = subId,
+                    isOpportunistic = false,
+                    carrierName = DEFAULT_CARRIER_NAME,
+                )
+                .also { subscriptionInfoCache[subId] = it }
 
             _subscriptions.value = subscriptionInfoCache.values.toList()
         }
@@ -327,6 +330,7 @@
         private const val TAG = "DemoMobileConnectionsRepo"
 
         private const val DEFAULT_SUB_ID = 1
+        private const val DEFAULT_CARRIER_NAME = "demo carrier"
     }
 }
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepository.kt
index 65f4866..28be3be 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/CarrierMergedConnectionRepository.kt
@@ -108,6 +108,8 @@
                 NetworkNameModel.SimDerived(telephonyManager.simOperatorName),
             )
 
+    override val carrierName: StateFlow<NetworkNameModel> = networkName
+
     override val numberOfLevels: StateFlow<Int> =
         wifiRepository.wifiNetwork
             .map {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepository.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepository.kt
index 8ba7d21..ee11c06 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepository.kt
@@ -22,6 +22,7 @@
 import com.android.systemui.log.table.TableLogBufferFactory
 import com.android.systemui.log.table.logDiffsForTable
 import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
 import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
@@ -47,6 +48,7 @@
     override val subId: Int,
     startingIsCarrierMerged: Boolean,
     override val tableLogBuffer: TableLogBuffer,
+    subscriptionModel: StateFlow<SubscriptionModel?>,
     private val defaultNetworkName: NetworkNameModel,
     private val networkNameSeparator: String,
     @Application scope: CoroutineScope,
@@ -80,6 +82,7 @@
         mobileRepoFactory.build(
             subId,
             tableLogBuffer,
+            subscriptionModel,
             defaultNetworkName,
             networkNameSeparator,
         )
@@ -287,6 +290,16 @@
             )
             .stateIn(scope, SharingStarted.WhileSubscribed(), activeRepo.value.networkName.value)
 
+    override val carrierName =
+        activeRepo
+            .flatMapLatest { it.carrierName }
+            .logDiffsForTable(
+                tableLogBuffer,
+                columnPrefix = "",
+                initialValue = activeRepo.value.carrierName.value,
+            )
+            .stateIn(scope, SharingStarted.WhileSubscribed(), activeRepo.value.carrierName.value)
+
     override val isAllowedDuringAirplaneMode =
         activeRepo
             .flatMapLatest { it.isAllowedDuringAirplaneMode }
@@ -307,6 +320,7 @@
         fun build(
             subId: Int,
             startingIsCarrierMerged: Boolean,
+            subscriptionModel: StateFlow<SubscriptionModel?>,
             defaultNetworkName: NetworkNameModel,
             networkNameSeparator: String,
         ): FullMobileConnectionRepository {
@@ -317,6 +331,7 @@
                 subId,
                 startingIsCarrierMerged,
                 mobileLogger,
+                subscriptionModel,
                 defaultNetworkName,
                 networkNameSeparator,
                 scope,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
index aadc975..1f1ac92 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryImpl.kt
@@ -43,6 +43,7 @@
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
 import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
 import com.android.systemui.statusbar.pipeline.mobile.data.model.toDataConnectionType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.toNetworkNameModel
@@ -80,6 +81,7 @@
 @OptIn(ExperimentalCoroutinesApi::class)
 class MobileConnectionRepositoryImpl(
     override val subId: Int,
+    subscriptionModel: StateFlow<SubscriptionModel?>,
     defaultNetworkName: NetworkNameModel,
     networkNameSeparator: String,
     private val telephonyManager: TelephonyManager,
@@ -281,6 +283,14 @@
             }
             .stateIn(scope, SharingStarted.WhileSubscribed(), DEFAULT_NUM_LEVELS)
 
+    override val carrierName =
+        subscriptionModel
+            .map {
+                it?.let { model -> NetworkNameModel.SubscriptionDerived(model.carrierName) }
+                    ?: defaultNetworkName
+            }
+            .stateIn(scope, SharingStarted.WhileSubscribed(), defaultNetworkName)
+
     /**
      * There are a few cases where we will need to poll [TelephonyManager] so we can update some
      * internal state where callbacks aren't provided. Any of those events should be merged into
@@ -350,11 +360,13 @@
         fun build(
             subId: Int,
             mobileLogger: TableLogBuffer,
+            subscriptionModel: StateFlow<SubscriptionModel?>,
             defaultNetworkName: NetworkNameModel,
             networkNameSeparator: String,
         ): MobileConnectionRepository {
             return MobileConnectionRepositoryImpl(
                 subId,
+                subscriptionModel,
                 defaultNetworkName,
                 networkNameSeparator,
                 telephonyManager.createForSubscriptionId(subId),
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt
index 54948a4..67b04db 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryImpl.kt
@@ -319,10 +319,17 @@
 
     @VisibleForTesting fun getSubIdRepoCache() = subIdRepositoryCache
 
+    private fun subscriptionModelForSubId(subId: Int): StateFlow<SubscriptionModel?> {
+        return subscriptions
+            .map { list -> list.firstOrNull { model -> model.subscriptionId == subId } }
+            .stateIn(scope, SharingStarted.WhileSubscribed(), null)
+    }
+
     private fun createRepositoryForSubId(subId: Int): FullMobileConnectionRepository {
         return fullMobileRepoFactory.build(
             subId,
             isCarrierMerged(subId),
+            subscriptionModelForSubId(subId),
             defaultNetworkName,
             networkNameSeparator,
         )
@@ -373,6 +380,7 @@
             subscriptionId = subscriptionId,
             isOpportunistic = isOpportunistic,
             groupUuid = groupUuid,
+            carrierName = carrierName.toString(),
         )
 
     companion object {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractor.kt
index 1a13827..4cfde5b 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractor.kt
@@ -92,6 +92,22 @@
      */
     val networkName: StateFlow<NetworkNameModel>
 
+    /**
+     * Provider name for this network connection. The name can be one of 3 values:
+     * 1. The default network name, if one is configured
+     * 2. A name provided by the [SubscriptionModel] of this network connection
+     * 3. Or, in the case where the repository sends us the default network name, we check for an
+     *    override in [connectionInfo.operatorAlphaShort], a value that is derived from
+     *    [ServiceState]
+     *
+     * TODO(b/296600321): De-duplicate this field with [networkName] after determining the data
+     *   provided is identical
+     */
+    val carrierName: StateFlow<String>
+
+    /** True if there is only one active subscription. */
+    val isSingleCarrier: StateFlow<Boolean>
+
     /** True if this line of service is emergency-only */
     val isEmergencyOnly: StateFlow<Boolean>
 
@@ -126,6 +142,7 @@
     defaultSubscriptionHasDataEnabled: StateFlow<Boolean>,
     override val alwaysShowDataRatIcon: StateFlow<Boolean>,
     override val alwaysUseCdmaLevel: StateFlow<Boolean>,
+    override val isSingleCarrier: StateFlow<Boolean>,
     override val mobileIsDefault: StateFlow<Boolean>,
     defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>,
     defaultMobileIconGroup: StateFlow<MobileIconGroup>,
@@ -171,6 +188,22 @@
                 connectionRepository.networkName.value
             )
 
+    override val carrierName =
+        combine(connectionRepository.operatorAlphaShort, connectionRepository.carrierName) {
+                operatorAlphaShort,
+                networkName ->
+                if (networkName is NetworkNameModel.Default && operatorAlphaShort != null) {
+                    operatorAlphaShort
+                } else {
+                    networkName.name
+                }
+            }
+            .stateIn(
+                scope,
+                SharingStarted.WhileSubscribed(),
+                connectionRepository.carrierName.value.name
+            )
+
     /** What the mobile icon would be before carrierId overrides */
     private val defaultNetworkType: StateFlow<MobileIconGroup> =
         combine(
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
index e90f40c7..d08808b 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
@@ -76,6 +76,9 @@
     /** True if the CDMA level should be preferred over the primary level. */
     val alwaysUseCdmaLevel: StateFlow<Boolean>
 
+    /** True if there is only one active subscription. */
+    val isSingleCarrier: StateFlow<Boolean>
+
     /** The icon mapping from network type to [MobileIconGroup] for the default subscription */
     val defaultMobileIconMapping: StateFlow<Map<String, MobileIconGroup>>
 
@@ -252,6 +255,17 @@
             .mapLatest { it.alwaysShowCdmaRssi }
             .stateIn(scope, SharingStarted.WhileSubscribed(), false)
 
+    override val isSingleCarrier: StateFlow<Boolean> =
+        mobileConnectionsRepo.subscriptions
+            .map { it.size == 1 }
+            .logDiffsForTable(
+                tableLogger,
+                columnPrefix = LOGGING_PREFIX,
+                columnName = "isSingleCarrier",
+                initialValue = false,
+            )
+            .stateIn(scope, SharingStarted.WhileSubscribed(), false)
+
     /** If there is no mapping in [defaultMobileIconMapping], then use this default icon group */
     override val defaultMobileIconGroup: StateFlow<MobileIconGroup> =
         mobileConnectionsRepo.defaultMobileIconGroup.stateIn(
@@ -298,6 +312,7 @@
             activeDataConnectionHasDataEnabled,
             alwaysShowDataRatIcon,
             alwaysUseCdmaLevel,
+            isSingleCarrier,
             mobileIsDefault,
             defaultMobileIconMapping,
             defaultMobileIconGroup,
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt
index d7fcf48..02e50a0 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileUiAdapter.kt
@@ -19,6 +19,7 @@
 import com.android.systemui.CoreStartable
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.shade.carrier.ShadeCarrierGroupController
 import com.android.systemui.statusbar.phone.StatusBarIconController
 import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor
 import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel
@@ -49,6 +50,8 @@
     private var isCollecting: Boolean = false
     private var lastValue: List<Int>? = null
 
+    private var shadeCarrierGroupController: ShadeCarrierGroupController? = null
+
     override fun start() {
         // Start notifying the icon controller of subscriptions
         scope.launch {
@@ -57,10 +60,16 @@
                 logger.logUiAdapterSubIdsSentToIconController(it)
                 lastValue = it
                 iconController.setNewMobileIconSubIds(it)
+                shadeCarrierGroupController?.updateModernMobileIcons(it)
             }
         }
     }
 
+    /** Set the [ShadeCarrierGroupController] to notify of subscription updates */
+    fun setShadeCarrierGroupController(controller: ShadeCarrierGroupController) {
+        shadeCarrierGroupController = controller
+    }
+
     override fun dump(pw: PrintWriter, args: Array<out String>) {
         pw.println("isCollecting=$isCollecting")
         pw.println("Last values sent to icon controller: $lastValue")
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt
index cea6654..2af6795b 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/MobileViewLogger.kt
@@ -57,7 +57,7 @@
             {
                 str1 = view.getIdForLogging()
                 str2 = viewModel.getIdForLogging()
-                str3 = viewModel.locationName
+                str3 = viewModel.location.name
             },
             { "New view binding. viewId=$str1, viewModelId=$str2, viewModelLocation=$str3" },
         )
@@ -71,7 +71,7 @@
             {
                 str1 = view.getIdForLogging()
                 str2 = viewModel.getIdForLogging()
-                str3 = viewModel.locationName
+                str3 = viewModel.location.name
             },
             { "Collection started. viewId=$str1, viewModelId=$str2, viewModelLocation=$str3" },
         )
@@ -85,7 +85,7 @@
             {
                 str1 = view.getIdForLogging()
                 str2 = viewModel.getIdForLogging()
-                str3 = viewModel.locationName
+                str3 = viewModel.location.name
             },
             { "Collection stopped. viewId=$str1, viewModelId=$str2, viewModelLocation=$str3" },
         )
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/MobileIconBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/MobileIconBinder.kt
index 55bc8d5..4b2fb43 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/MobileIconBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/MobileIconBinder.kt
@@ -50,6 +50,7 @@
     fun bind(
         view: ViewGroup,
         viewModel: LocationBasedMobileViewModel,
+        @StatusBarIconView.VisibleState initialVisibilityState: Int = STATE_HIDDEN,
         logger: MobileViewLogger,
     ): ModernStatusBarViewBinding {
         val mobileGroupView = view.requireViewById<ViewGroup>(R.id.mobile_group)
@@ -68,12 +69,12 @@
 
         // TODO(b/238425913): We should log this visibility state.
         @StatusBarIconView.VisibleState
-        val visibilityState: MutableStateFlow<Int> = MutableStateFlow(STATE_HIDDEN)
+        val visibilityState: MutableStateFlow<Int> = MutableStateFlow(initialVisibilityState)
 
         val iconTint: MutableStateFlow<Int> = MutableStateFlow(viewModel.defaultColor)
         val decorTint: MutableStateFlow<Int> = MutableStateFlow(viewModel.defaultColor)
 
-        var isCollecting: Boolean = false
+        var isCollecting = false
 
         view.repeatWhenAttached {
             repeatOnLifecycle(Lifecycle.State.STARTED) {
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/ShadeCarrierBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/ShadeCarrierBinder.kt
new file mode 100644
index 0000000..081e101
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/binder/ShadeCarrierBinder.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.statusbar.pipeline.mobile.ui.binder
+
+import androidx.core.view.isVisible
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.ShadeCarrierGroupMobileIconViewModel
+import com.android.systemui.util.AutoMarqueeTextView
+import kotlinx.coroutines.launch
+
+object ShadeCarrierBinder {
+    /** Binds the view to the view-model, continuing to update the former based on the latter */
+    @JvmStatic
+    fun bind(
+        carrierTextView: AutoMarqueeTextView,
+        viewModel: ShadeCarrierGroupMobileIconViewModel,
+    ) {
+        carrierTextView.isVisible = true
+
+        carrierTextView.repeatWhenAttached {
+            repeatOnLifecycle(Lifecycle.State.STARTED) {
+                launch { viewModel.carrierName.collect { carrierTextView.text = it } }
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernShadeCarrierGroupMobileView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernShadeCarrierGroupMobileView.kt
new file mode 100644
index 0000000..f407127
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernShadeCarrierGroupMobileView.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.statusbar.pipeline.mobile.ui.view
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import com.android.systemui.R
+import com.android.systemui.statusbar.StatusBarIconView.STATE_ICON
+import com.android.systemui.statusbar.pipeline.mobile.ui.MobileViewLogger
+import com.android.systemui.statusbar.pipeline.mobile.ui.binder.MobileIconBinder
+import com.android.systemui.statusbar.pipeline.mobile.ui.binder.ShadeCarrierBinder
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.ShadeCarrierGroupMobileIconViewModel
+import com.android.systemui.util.AutoMarqueeTextView
+
+/**
+ * ViewGroup containing a mobile carrier name and icon in the Shade Header. Can be multiple
+ * instances as children under [ShadeCarrierGroup]
+ */
+class ModernShadeCarrierGroupMobileView(
+    context: Context,
+    attrs: AttributeSet?,
+) : LinearLayout(context, attrs) {
+
+    var subId: Int = -1
+
+    override fun toString(): String {
+        return "ModernShadeCarrierGroupMobileView(" +
+            "subId=$subId, " +
+            "viewString=${super.toString()}"
+    }
+
+    companion object {
+
+        /**
+         * Inflates a new instance of [ModernShadeCarrierGroupMobileView], binds it to [viewModel],
+         * and returns it.
+         */
+        @JvmStatic
+        fun constructAndBind(
+            context: Context,
+            logger: MobileViewLogger,
+            slot: String,
+            viewModel: ShadeCarrierGroupMobileIconViewModel,
+        ): ModernShadeCarrierGroupMobileView {
+            return (LayoutInflater.from(context).inflate(R.layout.shade_carrier_new, null)
+                    as ModernShadeCarrierGroupMobileView)
+                .also {
+                    it.subId = viewModel.subscriptionId
+
+                    val iconView = it.requireViewById<ModernStatusBarMobileView>(R.id.mobile_combo)
+                    iconView.initView(slot) {
+                        MobileIconBinder.bind(iconView, viewModel, STATE_ICON, logger)
+                    }
+                    logger.logNewViewBinding(it, viewModel)
+
+                    val textView = it.requireViewById<AutoMarqueeTextView>(R.id.mobile_carrier_text)
+                    ShadeCarrierBinder.bind(textView, viewModel)
+                }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernStatusBarMobileView.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernStatusBarMobileView.kt
index 4144293d..68d02de 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernStatusBarMobileView.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/view/ModernStatusBarMobileView.kt
@@ -60,7 +60,9 @@
                     as ModernStatusBarMobileView)
                 .also {
                     it.subId = viewModel.subscriptionId
-                    it.initView(slot) { MobileIconBinder.bind(it, viewModel, logger) }
+                    it.initView(slot) {
+                        MobileIconBinder.bind(view = it, viewModel = viewModel, logger = logger)
+                    }
                     logger.logNewViewBinding(it, viewModel)
                 }
         }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/LocationBasedMobileViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/LocationBasedMobileViewModel.kt
index a51982c..e7c311d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/LocationBasedMobileViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/LocationBasedMobileViewModel.kt
@@ -18,7 +18,13 @@
 
 import android.graphics.Color
 import com.android.systemui.statusbar.phone.StatusBarLocation
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconInteractor
 import com.android.systemui.statusbar.pipeline.mobile.ui.VerboseMobileViewLogger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
 
 /**
  * A view model for an individual mobile icon that embeds the notion of a [StatusBarLocation]. This
@@ -26,12 +32,12 @@
  *
  * @param commonImpl for convenience, this class wraps a base interface that can provides all of the
  *   common implementations between locations. See [MobileIconViewModel]
- * @property locationName the name of the location of this VM, used for logging.
+ * @property location the [StatusBarLocation] of this VM.
  * @property verboseLogger an optional logger to log extremely verbose view updates.
  */
 abstract class LocationBasedMobileViewModel(
     val commonImpl: MobileIconViewModelCommon,
-    val locationName: String,
+    val location: StatusBarLocation,
     val verboseLogger: VerboseMobileViewLogger?,
 ) : MobileIconViewModelCommon by commonImpl {
     val defaultColor: Int = Color.WHITE
@@ -39,10 +45,12 @@
     companion object {
         fun viewModelForLocation(
             commonImpl: MobileIconViewModelCommon,
+            interactor: MobileIconInteractor,
             verboseMobileViewLogger: VerboseMobileViewLogger,
-            loc: StatusBarLocation,
+            location: StatusBarLocation,
+            scope: CoroutineScope,
         ): LocationBasedMobileViewModel =
-            when (loc) {
+            when (location) {
                 StatusBarLocation.HOME ->
                     HomeMobileIconViewModel(
                         commonImpl,
@@ -50,6 +58,12 @@
                     )
                 StatusBarLocation.KEYGUARD -> KeyguardMobileIconViewModel(commonImpl)
                 StatusBarLocation.QS -> QsMobileIconViewModel(commonImpl)
+                StatusBarLocation.SHADE_CARRIER_GROUP ->
+                    ShadeCarrierGroupMobileIconViewModel(
+                        commonImpl,
+                        interactor,
+                        scope,
+                    )
             }
     }
 }
@@ -61,7 +75,7 @@
     MobileIconViewModelCommon,
     LocationBasedMobileViewModel(
         commonImpl,
-        locationName = "Home",
+        location = StatusBarLocation.HOME,
         verboseMobileViewLogger,
     )
 
@@ -71,18 +85,40 @@
     MobileIconViewModelCommon,
     LocationBasedMobileViewModel(
         commonImpl,
-        locationName = "QS",
+        location = StatusBarLocation.QS,
         // Only do verbose logging for the Home location.
         verboseLogger = null,
     )
 
+class ShadeCarrierGroupMobileIconViewModel(
+    commonImpl: MobileIconViewModelCommon,
+    interactor: MobileIconInteractor,
+    scope: CoroutineScope,
+) :
+    MobileIconViewModelCommon,
+    LocationBasedMobileViewModel(
+        commonImpl,
+        location = StatusBarLocation.SHADE_CARRIER_GROUP,
+        // Only do verbose logging for the Home location.
+        verboseLogger = null,
+    ) {
+    private val isSingleCarrier = interactor.isSingleCarrier
+    val carrierName = interactor.carrierName
+
+    override val isVisible: StateFlow<Boolean> =
+        combine(super.isVisible, isSingleCarrier) { isVisible, isSingleCarrier ->
+                if (isSingleCarrier) false else isVisible
+            }
+            .stateIn(scope, SharingStarted.WhileSubscribed(), super.isVisible.value)
+}
+
 class KeyguardMobileIconViewModel(
     commonImpl: MobileIconViewModelCommon,
 ) :
     MobileIconViewModelCommon,
     LocationBasedMobileViewModel(
         commonImpl,
-        locationName = "Keyguard",
+        location = StatusBarLocation.KEYGUARD,
         // Only do verbose logging for the Home location.
         verboseLogger = null,
     )
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt
index 5cf887e..216afb9 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModel.kt
@@ -22,6 +22,7 @@
 import com.android.systemui.statusbar.phone.StatusBarLocation
 import com.android.systemui.statusbar.pipeline.StatusBarPipelineFlags
 import com.android.systemui.statusbar.pipeline.airplane.domain.interactor.AirplaneModeInteractor
+import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconInteractor
 import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor
 import com.android.systemui.statusbar.pipeline.mobile.ui.MobileViewLogger
 import com.android.systemui.statusbar.pipeline.mobile.ui.VerboseMobileViewLogger
@@ -58,6 +59,8 @@
     private val statusBarPipelineFlags: StatusBarPipelineFlags,
 ) {
     @VisibleForTesting val mobileIconSubIdCache = mutableMapOf<Int, MobileIconViewModel>()
+    @VisibleForTesting
+    val mobileIconInteractorSubIdCache = mutableMapOf<Int, MobileIconInteractor>()
 
     val subscriptionIdsFlow: StateFlow<List<Int>> =
         interactor.filteredSubscriptions
@@ -91,15 +94,17 @@
             .stateIn(scope, SharingStarted.WhileSubscribed(), false)
 
     init {
-        scope.launch { subscriptionIdsFlow.collect { removeInvalidModelsFromCache(it) } }
+        scope.launch { subscriptionIdsFlow.collect { invalidateCaches(it) } }
     }
 
     fun viewModelForSub(subId: Int, location: StatusBarLocation): LocationBasedMobileViewModel {
         val common = commonViewModelForSub(subId)
         return LocationBasedMobileViewModel.viewModelForLocation(
             common,
+            mobileIconInteractorForSub(subId),
             verboseLogger,
             location,
+            scope,
         )
     }
 
@@ -107,7 +112,7 @@
         return mobileIconSubIdCache[subId]
             ?: MobileIconViewModel(
                     subId,
-                    interactor.createMobileConnectionInteractorForSubId(subId),
+                    mobileIconInteractorForSub(subId),
                     airplaneModeInteractor,
                     constants,
                     scope,
@@ -115,8 +120,20 @@
                 .also { mobileIconSubIdCache[subId] = it }
     }
 
-    private fun removeInvalidModelsFromCache(subIds: List<Int>) {
+    @VisibleForTesting
+    fun mobileIconInteractorForSub(subId: Int): MobileIconInteractor {
+        return mobileIconInteractorSubIdCache[subId]
+            ?: interactor.createMobileConnectionInteractorForSubId(subId).also {
+                mobileIconInteractorSubIdCache[subId] = it
+            }
+    }
+
+    private fun invalidateCaches(subIds: List<Int>) {
         val subIdsToRemove = mobileIconSubIdCache.keys.filter { !subIds.contains(it) }
         subIdsToRemove.forEach { mobileIconSubIdCache.remove(it) }
+
+        mobileIconInteractorSubIdCache.keys
+            .filter { !subIds.contains(it) }
+            .forEach { subId -> mobileIconInteractorSubIdCache.remove(subId) }
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/wifi/ui/viewmodel/LocationBasedWifiViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/wifi/ui/viewmodel/LocationBasedWifiViewModel.kt
index cd5b92c..00bd616 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/wifi/ui/viewmodel/LocationBasedWifiViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/wifi/ui/viewmodel/LocationBasedWifiViewModel.kt
@@ -18,6 +18,7 @@
 
 import android.graphics.Color
 import com.android.systemui.statusbar.phone.StatusBarLocation
+import java.lang.IllegalArgumentException
 
 /**
  * A view model for a wifi icon in a specific location. This allows us to control parameters that
@@ -43,6 +44,8 @@
                 StatusBarLocation.HOME -> HomeWifiViewModel(commonImpl)
                 StatusBarLocation.KEYGUARD -> KeyguardWifiViewModel(commonImpl)
                 StatusBarLocation.QS -> QsWifiViewModel(commonImpl)
+                StatusBarLocation.SHADE_CARRIER_GROUP ->
+                    throw IllegalArgumentException("invalid location for WifiViewModel: $location")
             }
     }
 }
@@ -64,3 +67,11 @@
 class QsWifiViewModel(
     commonImpl: WifiViewModelCommon,
 ) : WifiViewModelCommon, LocationBasedWifiViewModel(commonImpl)
+
+/**
+ * A view model for the wifi icon in the shade carrier group (visible when quick settings is fully
+ * expanded, and in large screen shade). Currently unused.
+ */
+class ShadeCarrierGroupWifiViewModel(
+    commonImpl: WifiViewModelCommon,
+) : WifiViewModelCommon, LocationBasedWifiViewModel(commonImpl)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/FontScalingTileTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/FontScalingTileTest.kt
index cbc3553..d1d3c17 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/FontScalingTileTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/FontScalingTileTest.kt
@@ -26,8 +26,6 @@
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.animation.DialogLaunchAnimator
 import com.android.systemui.classifier.FalsingManagerFake
-import com.android.systemui.flags.FakeFeatureFlags
-import com.android.systemui.flags.Flags
 import com.android.systemui.plugins.ActivityStarter
 import com.android.systemui.plugins.statusbar.StatusBarStateController
 import com.android.systemui.qs.QSHost
@@ -74,8 +72,6 @@
     private lateinit var backgroundDelayableExecutor: FakeExecutor
     private lateinit var fontScalingTile: FontScalingTile
 
-    val featureFlags = FakeFeatureFlags()
-
     @Captor private lateinit var argumentCaptor: ArgumentCaptor<Runnable>
 
     @Before
@@ -102,7 +98,6 @@
                 FakeSettings(),
                 FakeSettings(),
                 FakeSystemClock(),
-                featureFlags,
                 userTracker,
                 backgroundDelayableExecutor,
             )
@@ -117,18 +112,7 @@
     }
 
     @Test
-    fun isAvailable_whenFlagIsFalse_returnsFalse() {
-        featureFlags.set(Flags.ENABLE_FONT_SCALING_TILE, false)
-
-        val isAvailable = fontScalingTile.isAvailable()
-
-        assertThat(isAvailable).isFalse()
-    }
-
-    @Test
-    fun isAvailable_whenFlagIsTrue_returnsTrue() {
-        featureFlags.set(Flags.ENABLE_FONT_SCALING_TILE, true)
-
+    fun isAvailable_alwaysReturnsTrue() {
         val isAvailable = fontScalingTile.isAvailable()
 
         assertThat(isAvailable).isTrue()
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/carrier/ShadeCarrierGroupControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/shade/carrier/ShadeCarrierGroupControllerTest.java
index 31bfa3fd..5fa6b3a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/shade/carrier/ShadeCarrierGroupControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/shade/carrier/ShadeCarrierGroupControllerTest.java
@@ -29,6 +29,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyZeroInteractions;
 import static org.mockito.Mockito.when;
@@ -50,6 +51,12 @@
 import com.android.systemui.statusbar.connectivity.MobileDataIndicators;
 import com.android.systemui.statusbar.connectivity.NetworkController;
 import com.android.systemui.statusbar.connectivity.SignalCallback;
+import com.android.systemui.statusbar.connectivity.ui.MobileContextProvider;
+import com.android.systemui.statusbar.pipeline.StatusBarPipelineFlags;
+import com.android.systemui.statusbar.pipeline.mobile.ui.MobileUiAdapter;
+import com.android.systemui.statusbar.pipeline.mobile.ui.MobileViewLogger;
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel;
+import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.ShadeCarrierGroupMobileIconViewModel;
 import com.android.systemui.util.CarrierConfigTracker;
 import com.android.systemui.utils.leaks.LeakCheckedTest;
 import com.android.systemui.utils.os.FakeHandler;
@@ -61,6 +68,10 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
 @RunWith(AndroidTestingRunner.class)
 @TestableLooper.RunWithLooper
 @SmallTest
@@ -95,6 +106,18 @@
     private TestableLooper mTestableLooper;
     @Mock
     private ShadeCarrierGroupController.OnSingleCarrierChangedListener mOnSingleCarrierChangedListener;
+    @Mock
+    private MobileUiAdapter mMobileUiAdapter;
+    @Mock
+    private MobileIconsViewModel mMobileIconsViewModel;
+    @Mock
+    private ShadeCarrierGroupMobileIconViewModel mShadeCarrierGroupMobileIconViewModel;
+    @Mock
+    private MobileViewLogger mMobileViewLogger;
+    @Mock
+    private MobileContextProvider mMobileContextProvider;
+    @Mock
+    private StatusBarPipelineFlags mStatusBarPipelineFlags;
 
     private FakeSlotIndexResolver mSlotIndexResolver;
     private ClickListenerTextView mNoCarrierTextView;
@@ -133,16 +156,35 @@
 
         mSlotIndexResolver = new FakeSlotIndexResolver();
 
+        when(mMobileUiAdapter.getMobileIconsViewModel()).thenReturn(mMobileIconsViewModel);
+
         mShadeCarrierGroupController = new ShadeCarrierGroupController.Builder(
-                mActivityStarter, handler, TestableLooper.get(this).getLooper(),
-                mNetworkController, mCarrierTextControllerBuilder, mContext, mCarrierConfigTracker,
-                mSlotIndexResolver)
+                mActivityStarter,
+                handler,
+                TestableLooper.get(this).getLooper(),
+                mNetworkController,
+                mCarrierTextControllerBuilder,
+                mContext,
+                mCarrierConfigTracker,
+                mSlotIndexResolver,
+                mMobileUiAdapter,
+                mMobileContextProvider,
+                mStatusBarPipelineFlags
+            )
                 .setShadeCarrierGroup(mShadeCarrierGroup)
                 .build();
 
         mShadeCarrierGroupController.setListening(true);
     }
 
+    private void setupWithNewPipeline() {
+        when(mStatusBarPipelineFlags.useNewShadeCarrierGroupMobileIcons()).thenReturn(true);
+        when(mMobileContextProvider.getMobileContextForSub(anyInt(), any())).thenReturn(mContext);
+        when(mMobileIconsViewModel.getLogger()).thenReturn(mMobileViewLogger);
+        when(mMobileIconsViewModel.viewModelForSub(anyInt(), any()))
+                .thenReturn(mShadeCarrierGroupMobileIconViewModel);
+    }
+
     @Test
     public void testInitiallyMultiCarrier() {
         assertFalse(mShadeCarrierGroupController.isSingleCarrier());
@@ -406,6 +448,129 @@
         verify(mOnSingleCarrierChangedListener, never()).onSingleCarrierChanged(anyBoolean());
     }
 
+    @TestableLooper.RunWithLooper(setAsMainLooper = true)
+    @Test
+    public void testUpdateModernMobileIcons_addSubscription() {
+        setupWithNewPipeline();
+
+        mShadeCarrier1.setVisibility(View.GONE);
+        mShadeCarrier2.setVisibility(View.GONE);
+        mShadeCarrier3.setVisibility(View.GONE);
+
+        List<Integer> subIds = new ArrayList<>();
+        subIds.add(0);
+        mShadeCarrierGroupController.updateModernMobileIcons(subIds);
+
+        verify(mShadeCarrier1).addModernMobileView(any());
+        verify(mShadeCarrier2, never()).addModernMobileView(any());
+        verify(mShadeCarrier3, never()).addModernMobileView(any());
+
+        resetShadeCarriers();
+
+        subIds.add(1);
+        mShadeCarrierGroupController.updateModernMobileIcons(subIds);
+
+        verify(mShadeCarrier1, times(1)).removeModernMobileView();
+
+        verify(mShadeCarrier1).addModernMobileView(any());
+        verify(mShadeCarrier2).addModernMobileView(any());
+        verify(mShadeCarrier3, never()).addModernMobileView(any());
+    }
+
+    @TestableLooper.RunWithLooper(setAsMainLooper = true)
+    @Test
+    public void testUpdateModernMobileIcons_removeSubscription() {
+        setupWithNewPipeline();
+
+        List<Integer> subIds = new ArrayList<>();
+        subIds.add(0);
+        subIds.add(1);
+        mShadeCarrierGroupController.updateModernMobileIcons(subIds);
+
+        verify(mShadeCarrier1).addModernMobileView(any());
+        verify(mShadeCarrier2).addModernMobileView(any());
+        verify(mShadeCarrier3, never()).addModernMobileView(any());
+
+        resetShadeCarriers();
+
+        subIds.remove(1);
+        mShadeCarrierGroupController.updateModernMobileIcons(subIds);
+
+        verify(mShadeCarrier1, times(1)).removeModernMobileView();
+        verify(mShadeCarrier2, times(1)).removeModernMobileView();
+
+        verify(mShadeCarrier1).addModernMobileView(any());
+        verify(mShadeCarrier2, never()).addModernMobileView(any());
+        verify(mShadeCarrier3, never()).addModernMobileView(any());
+    }
+
+    @TestableLooper.RunWithLooper(setAsMainLooper = true)
+    @Test
+    public void testUpdateModernMobileIcons_removeSubscriptionOutOfOrder() {
+        setupWithNewPipeline();
+
+        List<Integer> subIds = new ArrayList<>();
+        subIds.add(0);
+        subIds.add(1);
+        subIds.add(2);
+        mShadeCarrierGroupController.updateModernMobileIcons(subIds);
+
+        verify(mShadeCarrier1).addModernMobileView(any());
+        verify(mShadeCarrier2).addModernMobileView(any());
+        verify(mShadeCarrier3).addModernMobileView(any());
+
+        resetShadeCarriers();
+
+        subIds.remove(1);
+        mShadeCarrierGroupController.updateModernMobileIcons(subIds);
+
+        verify(mShadeCarrier1).removeModernMobileView();
+        verify(mShadeCarrier2).removeModernMobileView();
+        verify(mShadeCarrier3).removeModernMobileView();
+
+        verify(mShadeCarrier1).addModernMobileView(any());
+        verify(mShadeCarrier2, never()).addModernMobileView(any());
+        verify(mShadeCarrier3).addModernMobileView(any());
+    }
+
+    @TestableLooper.RunWithLooper(setAsMainLooper = true)
+    @Test
+    public void testProcessSubIdList_moreSubsThanSimSlots_listLimitedToMax() {
+        setupWithNewPipeline();
+
+        List<Integer> subIds = Arrays.asList(0, 1, 2, 2);
+
+        assertThat(mShadeCarrierGroupController.processSubIdList(subIds).size()).isEqualTo(3);
+    }
+
+    @TestableLooper.RunWithLooper(setAsMainLooper = true)
+    @Test
+    public void testProcessSubIdList_invalidSimSlotIndexFilteredOut() {
+        setupWithNewPipeline();
+
+        List<Integer> subIds = Arrays.asList(0, 1, -1);
+
+        List<ShadeCarrierGroupController.IconData> processedSubs =
+                mShadeCarrierGroupController.processSubIdList(subIds);
+        assertThat(processedSubs).hasSize(2);
+        assertThat(processedSubs.get(0).subId).isNotEqualTo(-1);
+        assertThat(processedSubs.get(1).subId).isNotEqualTo(-1);
+    }
+
+    @TestableLooper.RunWithLooper(setAsMainLooper = true)
+    @Test
+    public void testProcessSubIdList_indexGreaterThanSimSlotsFilteredOut() {
+        setupWithNewPipeline();
+
+        List<Integer> subIds = Arrays.asList(0, 4);
+
+        List<ShadeCarrierGroupController.IconData> processedSubs =
+                mShadeCarrierGroupController.processSubIdList(subIds);
+        assertThat(processedSubs).hasSize(1);
+        assertThat(processedSubs.get(0).subId).isNotEqualTo(4);
+    }
+
+
     @Test
     public void testOnlyInternalViewsHaveClickableListener() {
         ArgumentCaptor<View.OnClickListener> captor =
@@ -447,6 +612,12 @@
                 .isEqualTo(Settings.ACTION_WIRELESS_SETTINGS);
     }
 
+    private void resetShadeCarriers() {
+        reset(mShadeCarrier1);
+        reset(mShadeCarrier2);
+        reset(mShadeCarrier3);
+    }
+
     private class FakeSlotIndexResolver implements ShadeCarrierGroupController.SlotIndexResolver {
         public boolean overrideInvalid;
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepository.kt
index 50ee6a3..ff28753 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepository.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionRepository.kt
@@ -52,12 +52,19 @@
 
     override val cdmaRoaming = MutableStateFlow(false)
 
-    override val networkName =
-        MutableStateFlow<NetworkNameModel>(NetworkNameModel.Default("default"))
+    override val networkName: MutableStateFlow<NetworkNameModel> =
+        MutableStateFlow(NetworkNameModel.Default(DEFAULT_NETWORK_NAME))
+
+    override val carrierName: MutableStateFlow<NetworkNameModel> =
+        MutableStateFlow(NetworkNameModel.Default(DEFAULT_NETWORK_NAME))
 
     override val isAllowedDuringAirplaneMode = MutableStateFlow(false)
 
     fun setDataEnabled(enabled: Boolean) {
         _dataEnabled.value = enabled
     }
+
+    companion object {
+        const val DEFAULT_NETWORK_NAME = "default name"
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepository.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepository.kt
index 3591c17..99e4030 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepository.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/FakeMobileConnectionsRepository.kt
@@ -75,7 +75,11 @@
 
     override fun getRepoForSubId(subId: Int): MobileConnectionRepository {
         return subIdRepos[subId]
-            ?: FakeMobileConnectionRepository(subId, tableLogBuffer).also { subIdRepos[subId] = it }
+            ?: FakeMobileConnectionRepository(
+                    subId,
+                    tableLogBuffer,
+                )
+                .also { subIdRepos[subId] = it }
     }
 
     override val defaultDataSubRatConfig = MutableStateFlow(MobileMappings.Config())
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt
index 5a887eb..d005972 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/MobileRepositorySwitcherTest.kt
@@ -243,13 +243,29 @@
         private val IMMEDIATE = Dispatchers.Main.immediate
 
         private const val SUB_1_ID = 1
+        private const val SUB_1_NAME = "Carrier $SUB_1_ID"
         private val SUB_1 =
-            mock<SubscriptionInfo>().also { whenever(it.subscriptionId).thenReturn(SUB_1_ID) }
-        private val MODEL_1 = SubscriptionModel(subscriptionId = SUB_1_ID)
+            mock<SubscriptionInfo>().also {
+                whenever(it.subscriptionId).thenReturn(SUB_1_ID)
+                whenever(it.carrierName).thenReturn(SUB_1_NAME)
+            }
+        private val MODEL_1 =
+            SubscriptionModel(
+                subscriptionId = SUB_1_ID,
+                carrierName = SUB_1_NAME,
+            )
 
         private const val SUB_2_ID = 2
+        private const val SUB_2_NAME = "Carrier $SUB_2_ID"
         private val SUB_2 =
-            mock<SubscriptionInfo>().also { whenever(it.subscriptionId).thenReturn(SUB_2_ID) }
-        private val MODEL_2 = SubscriptionModel(subscriptionId = SUB_2_ID)
+            mock<SubscriptionInfo>().also {
+                whenever(it.subscriptionId).thenReturn(SUB_2_ID)
+                whenever(it.carrierName).thenReturn(SUB_2_NAME)
+            }
+        private val MODEL_2 =
+            SubscriptionModel(
+                subscriptionId = SUB_2_ID,
+                carrierName = SUB_2_NAME,
+            )
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt
index 7573b28..57f97ec 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionParameterizedTest.kt
@@ -38,7 +38,6 @@
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -140,6 +139,7 @@
             launch { conn.carrierNetworkChangeActive.collect {} }
             launch { conn.isRoaming.collect {} }
             launch { conn.networkName.collect {} }
+            launch { conn.carrierName.collect {} }
             launch { conn.isEmergencyOnly.collect {} }
             launch { conn.dataConnectionState.collect {} }
         }
@@ -163,6 +163,8 @@
                 assertThat(conn.isRoaming.value).isEqualTo(model.roaming)
                 assertThat(conn.networkName.value)
                     .isEqualTo(NetworkNameModel.IntentDerived(model.name))
+                assertThat(conn.carrierName.value)
+                    .isEqualTo(NetworkNameModel.SubscriptionDerived("${model.name} ${model.subId}"))
 
                 // TODO(b/261029387): check these once we start handling them
                 assertThat(conn.isEmergencyOnly.value).isFalse()
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt
index efaf152..2712b70 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/demo/DemoMobileConnectionsRepositoryTest.kt
@@ -546,6 +546,7 @@
             launch { conn.carrierNetworkChangeActive.collect {} }
             launch { conn.isRoaming.collect {} }
             launch { conn.networkName.collect {} }
+            launch { conn.carrierName.collect {} }
             launch { conn.isEmergencyOnly.collect {} }
             launch { conn.dataConnectionState.collect {} }
         }
@@ -571,6 +572,8 @@
                 assertThat(conn.isRoaming.value).isEqualTo(model.roaming)
                 assertThat(conn.networkName.value)
                     .isEqualTo(NetworkNameModel.IntentDerived(model.name))
+                assertThat(conn.carrierName.value)
+                    .isEqualTo(NetworkNameModel.SubscriptionDerived("${model.name} ${model.subId}"))
 
                 // TODO(b/261029387) check these once we start handling them
                 assertThat(conn.isEmergencyOnly.value).isFalse()
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryTest.kt
index 3dd2eaf..9c0cb17 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/FullMobileConnectionRepositoryTest.kt
@@ -26,6 +26,7 @@
 import com.android.systemui.log.table.TableLogBuffer
 import com.android.systemui.log.table.TableLogBufferFactory
 import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
 import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
 import com.android.systemui.statusbar.pipeline.mobile.data.repository.MobileConnectionRepository
 import com.android.systemui.statusbar.pipeline.mobile.data.repository.prod.FullMobileConnectionRepository.Companion.COL_EMERGENCY
@@ -43,6 +44,7 @@
 import java.io.PrintWriter
 import java.io.StringWriter
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.test.TestScope
@@ -79,28 +81,51 @@
     private val mobileFactory = mock<MobileConnectionRepositoryImpl.Factory>()
     private val carrierMergedFactory = mock<CarrierMergedConnectionRepository.Factory>()
 
+    private val subscriptionModel =
+        MutableStateFlow(
+            SubscriptionModel(
+                subscriptionId = SUB_ID,
+                carrierName = DEFAULT_NAME,
+            )
+        )
+
     private lateinit var mobileRepo: FakeMobileConnectionRepository
     private lateinit var carrierMergedRepo: FakeMobileConnectionRepository
 
     @Before
     fun setUp() {
-        mobileRepo = FakeMobileConnectionRepository(SUB_ID, tableLogBuffer)
+        mobileRepo =
+            FakeMobileConnectionRepository(
+                SUB_ID,
+                tableLogBuffer,
+            )
         carrierMergedRepo =
-            FakeMobileConnectionRepository(SUB_ID, tableLogBuffer).apply {
-                // Mimicks the real carrier merged repository
-                this.isAllowedDuringAirplaneMode.value = true
-            }
+            FakeMobileConnectionRepository(
+                    SUB_ID,
+                    tableLogBuffer,
+                )
+                .apply {
+                    // Mimicks the real carrier merged repository
+                    this.isAllowedDuringAirplaneMode.value = true
+                }
 
         whenever(
                 mobileFactory.build(
                     eq(SUB_ID),
                     any(),
-                    eq(DEFAULT_NAME),
+                    any(),
+                    eq(DEFAULT_NAME_MODEL),
                     eq(SEP),
                 )
             )
             .thenReturn(mobileRepo)
-        whenever(carrierMergedFactory.build(eq(SUB_ID), any())).thenReturn(carrierMergedRepo)
+        whenever(
+                carrierMergedFactory.build(
+                    eq(SUB_ID),
+                    any(),
+                )
+            )
+            .thenReturn(carrierMergedRepo)
     }
 
     @Test
@@ -120,7 +145,8 @@
                 .build(
                     SUB_ID,
                     tableLogBuffer,
-                    DEFAULT_NAME,
+                    subscriptionModel,
+                    DEFAULT_NAME_MODEL,
                     SEP,
                 )
         }
@@ -138,7 +164,11 @@
 
             assertThat(underTest.activeRepo.value).isEqualTo(mobileRepo)
             assertThat(underTest.operatorAlphaShort.value).isEqualTo(nonCarrierMergedName)
-            verify(carrierMergedFactory, never()).build(SUB_ID, tableLogBuffer)
+            verify(carrierMergedFactory, never())
+                .build(
+                    SUB_ID,
+                    tableLogBuffer,
+                )
         }
 
     @Test
@@ -348,7 +378,8 @@
                 factory.build(
                     SUB_ID,
                     startingIsCarrierMerged = false,
-                    DEFAULT_NAME,
+                    subscriptionModel,
+                    DEFAULT_NAME_MODEL,
                     SEP,
                 )
 
@@ -356,7 +387,8 @@
                 factory.build(
                     SUB_ID,
                     startingIsCarrierMerged = false,
-                    DEFAULT_NAME,
+                    subscriptionModel,
+                    DEFAULT_NAME_MODEL,
                     SEP,
                 )
 
@@ -388,7 +420,8 @@
                 factory.build(
                     SUB_ID,
                     startingIsCarrierMerged = false,
-                    DEFAULT_NAME,
+                    subscriptionModel,
+                    DEFAULT_NAME_MODEL,
                     SEP,
                 )
 
@@ -397,7 +430,8 @@
                 factory.build(
                     SUB_ID,
                     startingIsCarrierMerged = true,
-                    DEFAULT_NAME,
+                    subscriptionModel,
+                    DEFAULT_NAME_MODEL,
                     SEP,
                 )
 
@@ -623,7 +657,8 @@
                 SUB_ID,
                 startingIsCarrierMerged,
                 tableLogBuffer,
-                DEFAULT_NAME,
+                subscriptionModel,
+                DEFAULT_NAME_MODEL,
                 SEP,
                 testScope.backgroundScope,
                 mobileFactory,
@@ -639,8 +674,9 @@
         val realRepo =
             MobileConnectionRepositoryImpl(
                 SUB_ID,
-                defaultNetworkName = NetworkNameModel.Default("default"),
-                networkNameSeparator = SEP,
+                subscriptionModel,
+                DEFAULT_NAME_MODEL,
+                SEP,
                 telephonyManager,
                 systemUiCarrierConfig = mock(),
                 fakeBroadcastDispatcher,
@@ -654,7 +690,8 @@
                 mobileFactory.build(
                     eq(SUB_ID),
                     any(),
-                    eq(DEFAULT_NAME),
+                    any(),
+                    eq(DEFAULT_NAME_MODEL),
                     eq(SEP),
                 )
             )
@@ -677,7 +714,13 @@
                 testScope.backgroundScope,
                 wifiRepository,
             )
-        whenever(carrierMergedFactory.build(eq(SUB_ID), any())).thenReturn(realRepo)
+        whenever(
+                carrierMergedFactory.build(
+                    eq(SUB_ID),
+                    any(),
+                )
+            )
+            .thenReturn(realRepo)
 
         return realRepo
     }
@@ -690,7 +733,8 @@
 
     private companion object {
         const val SUB_ID = 42
-        private val DEFAULT_NAME = NetworkNameModel.Default("default name")
+        private val DEFAULT_NAME = "default name"
+        private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
         private const val SEP = "-"
         private const val BUFFER_SEPARATOR = "|"
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
index 1ff737b..e50e5e3 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionRepositoryTest.kt
@@ -62,6 +62,7 @@
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.UnknownNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
 import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
 import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.configWithOverride
 import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest.Companion.createTestConfig
@@ -78,6 +79,7 @@
 import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.test.TestScope
@@ -109,6 +111,14 @@
     private val testDispatcher = UnconfinedTestDispatcher()
     private val testScope = TestScope(testDispatcher)
 
+    private val subscriptionModel: MutableStateFlow<SubscriptionModel?> =
+        MutableStateFlow(
+            SubscriptionModel(
+                subscriptionId = SUB_1_ID,
+                carrierName = DEFAULT_NAME,
+            )
+        )
+
     @Before
     fun setUp() {
         MockitoAnnotations.initMocks(this)
@@ -119,7 +129,8 @@
         underTest =
             MobileConnectionRepositoryImpl(
                 SUB_1_ID,
-                DEFAULT_NAME,
+                subscriptionModel,
+                DEFAULT_NAME_MODEL,
                 SEP,
                 telephonyManager,
                 systemUiCarrierConfig,
@@ -179,6 +190,7 @@
 
             // gsmLevel updates, no change to cdmaLevel
             strength = signalStrength(gsmLevel = 3, cdmaLevel = 2, isGsm = true)
+            callback.onSignalStrengthsChanged(strength)
 
             assertThat(latest).isEqualTo(2)
 
@@ -638,12 +650,51 @@
         }
 
     @Test
+    fun networkNameForSubId_updates() =
+        testScope.runTest {
+            var latest: NetworkNameModel? = null
+            val job = underTest.carrierName.onEach { latest = it }.launchIn(this)
+
+            subscriptionModel.value =
+                SubscriptionModel(
+                    subscriptionId = SUB_1_ID,
+                    carrierName = DEFAULT_NAME,
+                )
+
+            assertThat(latest?.name).isEqualTo(DEFAULT_NAME)
+
+            val updatedName = "Derived Carrier"
+            subscriptionModel.value =
+                SubscriptionModel(
+                    subscriptionId = SUB_1_ID,
+                    carrierName = updatedName,
+                )
+
+            assertThat(latest?.name).isEqualTo(updatedName)
+
+            job.cancel()
+        }
+
+    @Test
+    fun networkNameForSubId_defaultWhenSubscriptionModelNull() =
+        testScope.runTest {
+            var latest: NetworkNameModel? = null
+            val job = underTest.carrierName.onEach { latest = it }.launchIn(this)
+
+            subscriptionModel.value = null
+
+            assertThat(latest?.name).isEqualTo(DEFAULT_NAME)
+
+            job.cancel()
+        }
+
+    @Test
     fun networkName_default() =
         testScope.runTest {
             var latest: NetworkNameModel? = null
             val job = underTest.networkName.onEach { latest = it }.launchIn(this)
 
-            assertThat(latest).isEqualTo(DEFAULT_NAME)
+            assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
 
             job.cancel()
         }
@@ -701,7 +752,7 @@
 
             fakeBroadcastDispatcher.sendIntentToMatchingReceiversOnly(context, intentWithoutInfo)
 
-            assertThat(latest).isEqualTo(DEFAULT_NAME)
+            assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
 
             job.cancel()
         }
@@ -852,8 +903,9 @@
     companion object {
         private const val SUB_1_ID = 1
 
-        private val DEFAULT_NAME = NetworkNameModel.Default("default name")
-        private const val SEP = "-"
+        private val DEFAULT_NAME = "Fake Mobile Network"
+        private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+        private val SEP = "-"
 
         private const val SPN = "testSpn"
         private const val PLMN = "testPlmn"
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
index 4f15aed..ea60aa7 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionTelephonySmokeTests.kt
@@ -36,6 +36,7 @@
 import com.android.systemui.statusbar.pipeline.mobile.data.model.DataConnectionState
 import com.android.systemui.statusbar.pipeline.mobile.data.model.NetworkNameModel
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
 import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfig
 import com.android.systemui.statusbar.pipeline.mobile.data.model.SystemUiCarrierConfigTest
 import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionsRepository
@@ -47,6 +48,7 @@
 import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.test.TestScope
@@ -97,6 +99,7 @@
     @Mock private lateinit var telephonyManager: TelephonyManager
     @Mock private lateinit var logger: MobileInputLogger
     @Mock private lateinit var tableLogger: TableLogBuffer
+    @Mock private lateinit var subscriptionModel: StateFlow<SubscriptionModel?>
 
     private val mobileMappings = FakeMobileMappingsProxy()
     private val systemUiCarrierConfig =
@@ -113,11 +116,16 @@
         MockitoAnnotations.initMocks(this)
         whenever(telephonyManager.subscriptionId).thenReturn(SUB_1_ID)
 
-        connectionsRepo = FakeMobileConnectionsRepository(mobileMappings, tableLogger)
+        connectionsRepo =
+            FakeMobileConnectionsRepository(
+                mobileMappings,
+                tableLogger,
+            )
 
         underTest =
             MobileConnectionRepositoryImpl(
                 SUB_1_ID,
+                subscriptionModel,
                 DEFAULT_NAME,
                 SEP,
                 telephonyManager,
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt
index c8b6f13d..fd05cc4 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/data/repository/prod/MobileConnectionsRepositoryTest.kt
@@ -1190,30 +1190,36 @@
     companion object {
         // Subscription 1
         private const val SUB_1_ID = 1
+        private const val SUB_1_NAME = "Carrier $SUB_1_ID"
         private val GROUP_1 = ParcelUuid(UUID.randomUUID())
         private val SUB_1 =
             mock<SubscriptionInfo>().also {
                 whenever(it.subscriptionId).thenReturn(SUB_1_ID)
                 whenever(it.groupUuid).thenReturn(GROUP_1)
+                whenever(it.carrierName).thenReturn(SUB_1_NAME)
             }
         private val MODEL_1 =
             SubscriptionModel(
                 subscriptionId = SUB_1_ID,
                 groupUuid = GROUP_1,
+                carrierName = SUB_1_NAME,
             )
 
         // Subscription 2
         private const val SUB_2_ID = 2
+        private const val SUB_2_NAME = "Carrier $SUB_2_ID"
         private val GROUP_2 = ParcelUuid(UUID.randomUUID())
         private val SUB_2 =
             mock<SubscriptionInfo>().also {
                 whenever(it.subscriptionId).thenReturn(SUB_2_ID)
                 whenever(it.groupUuid).thenReturn(GROUP_2)
+                whenever(it.carrierName).thenReturn(SUB_2_NAME)
             }
         private val MODEL_2 =
             SubscriptionModel(
                 subscriptionId = SUB_2_ID,
                 groupUuid = GROUP_2,
+                carrierName = SUB_2_NAME,
             )
 
         // Subs 3 and 4 are considered to be in the same group ------------------------------------
@@ -1242,9 +1248,14 @@
 
         // Carrier merged subscription
         private const val SUB_CM_ID = 5
+        private const val SUB_CM_NAME = "Carrier $SUB_CM_ID"
         private val SUB_CM =
-            mock<SubscriptionInfo>().also { whenever(it.subscriptionId).thenReturn(SUB_CM_ID) }
-        private val MODEL_CM = SubscriptionModel(subscriptionId = SUB_CM_ID)
+            mock<SubscriptionInfo>().also {
+                whenever(it.subscriptionId).thenReturn(SUB_CM_ID)
+                whenever(it.carrierName).thenReturn(SUB_CM_NAME)
+            }
+        private val MODEL_CM =
+            SubscriptionModel(subscriptionId = SUB_CM_ID, carrierName = SUB_CM_NAME)
 
         private val WIFI_INFO_CM =
             mock<WifiInfo>().apply {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconInteractor.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconInteractor.kt
index 8d1da69..a3df785 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconInteractor.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconInteractor.kt
@@ -44,6 +44,8 @@
 
     override val mobileIsDefault = MutableStateFlow(true)
 
+    override val isSingleCarrier = MutableStateFlow(true)
+
     override val networkTypeIconGroup =
         MutableStateFlow<NetworkTypeIconModel>(
             NetworkTypeIconModel.DefaultIcon(TelephonyIcons.THREE_G)
@@ -51,6 +53,8 @@
 
     override val networkName = MutableStateFlow(NetworkNameModel.IntentDerived("demo mode"))
 
+    override val carrierName = MutableStateFlow("demo mode")
+
     private val _isEmergencyOnly = MutableStateFlow(false)
     override val isEmergencyOnly = _isEmergencyOnly
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt
index b2bbcfd..82b7ec4 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/FakeMobileIconsInteractor.kt
@@ -64,6 +64,8 @@
 
     override val mobileIsDefault = MutableStateFlow(false)
 
+    override val isSingleCarrier = MutableStateFlow(true)
+
     private val _defaultMobileIconMapping = MutableStateFlow(TEST_MAPPING)
     override val defaultMobileIconMapping = _defaultMobileIconMapping
 
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorTest.kt
index 58d3804..e3c59ad 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconInteractorTest.kt
@@ -29,6 +29,7 @@
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.CarrierMergedNetworkType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.DefaultNetworkType
 import com.android.systemui.statusbar.pipeline.mobile.data.model.ResolvedNetworkType.OverrideNetworkType
+import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel
 import com.android.systemui.statusbar.pipeline.mobile.data.repository.FakeMobileConnectionRepository
 import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.FIVE_G_OVERRIDE
 import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.FakeMobileIconsInteractor.Companion.FOUR_G
@@ -40,6 +41,7 @@
 import com.android.systemui.util.mockito.whenever
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.test.TestScope
@@ -56,6 +58,15 @@
     private lateinit var underTest: MobileIconInteractor
     private val mobileMappingsProxy = FakeMobileMappingsProxy()
     private val mobileIconsInteractor = FakeMobileIconsInteractor(mobileMappingsProxy, mock())
+
+    private val subscriptionModel =
+        MutableStateFlow(
+            SubscriptionModel(
+                subscriptionId = SUB_1_ID,
+                carrierName = DEFAULT_NAME,
+            )
+        )
+
     private val connectionRepository = FakeMobileConnectionRepository(SUB_1_ID, mock())
 
     private val testDispatcher = UnconfinedTestDispatcher()
@@ -432,7 +443,7 @@
         }
 
     @Test
-    fun networkName_usesOperatorAlphaShotWhenNonNullAndRepoIsDefault() =
+    fun networkName_usesOperatorAlphaShortWhenNonNullAndRepoIsDefault() =
         testScope.runTest {
             var latest: NetworkNameModel? = null
             val job = underTest.networkName.onEach { latest = it }.launchIn(this)
@@ -440,7 +451,7 @@
             val testOperatorName = "operatorAlphaShort"
 
             // Default network name, operator name is non-null, uses the operator name
-            connectionRepository.networkName.value = DEFAULT_NAME
+            connectionRepository.networkName.value = DEFAULT_NAME_MODEL
             connectionRepository.operatorAlphaShort.value = testOperatorName
 
             assertThat(latest).isEqualTo(NetworkNameModel.IntentDerived(testOperatorName))
@@ -448,10 +459,39 @@
             // Default network name, operator name is null, uses the default
             connectionRepository.operatorAlphaShort.value = null
 
+            assertThat(latest).isEqualTo(DEFAULT_NAME_MODEL)
+
+            // Derived network name, operator name non-null, uses the derived name
+            connectionRepository.networkName.value = DERIVED_NAME_MODEL
+            connectionRepository.operatorAlphaShort.value = testOperatorName
+
+            assertThat(latest).isEqualTo(DERIVED_NAME_MODEL)
+
+            job.cancel()
+        }
+
+    @Test
+    fun networkNameForSubId_usesOperatorAlphaShortWhenNonNullAndRepoIsDefault() =
+        testScope.runTest {
+            var latest: String? = null
+            val job = underTest.carrierName.onEach { latest = it }.launchIn(this)
+
+            val testOperatorName = "operatorAlphaShort"
+
+            // Default network name, operator name is non-null, uses the operator name
+            connectionRepository.carrierName.value = DEFAULT_NAME_MODEL
+            connectionRepository.operatorAlphaShort.value = testOperatorName
+
+            assertThat(latest).isEqualTo(testOperatorName)
+
+            // Default network name, operator name is null, uses the default
+            connectionRepository.operatorAlphaShort.value = null
+
             assertThat(latest).isEqualTo(DEFAULT_NAME)
 
             // Derived network name, operator name non-null, uses the derived name
-            connectionRepository.networkName.value = DERIVED_NAME
+            connectionRepository.carrierName.value =
+                NetworkNameModel.SubscriptionDerived(DERIVED_NAME)
             connectionRepository.operatorAlphaShort.value = testOperatorName
 
             assertThat(latest).isEqualTo(DERIVED_NAME)
@@ -460,6 +500,21 @@
         }
 
     @Test
+    fun isSingleCarrier_matchesParent() =
+        testScope.runTest {
+            var latest: Boolean? = null
+            val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+            mobileIconsInteractor.isSingleCarrier.value = true
+            assertThat(latest).isTrue()
+
+            mobileIconsInteractor.isSingleCarrier.value = false
+            assertThat(latest).isFalse()
+
+            job.cancel()
+        }
+
+    @Test
     fun isForceHidden_matchesParent() =
         testScope.runTest {
             var latest: Boolean? = null
@@ -494,6 +549,7 @@
             mobileIconsInteractor.activeDataConnectionHasDataEnabled,
             mobileIconsInteractor.alwaysShowDataRatIcon,
             mobileIconsInteractor.alwaysUseCdmaLevel,
+            mobileIconsInteractor.isSingleCarrier,
             mobileIconsInteractor.mobileIsDefault,
             mobileIconsInteractor.defaultMobileIconMapping,
             mobileIconsInteractor.defaultMobileIconGroup,
@@ -510,7 +566,9 @@
 
         private const val SUB_1_ID = 1
 
-        private val DEFAULT_NAME = NetworkNameModel.Default("test default name")
-        private val DERIVED_NAME = NetworkNameModel.IntentDerived("test derived name")
+        private val DEFAULT_NAME = "test default name"
+        private val DEFAULT_NAME_MODEL = NetworkNameModel.Default(DEFAULT_NAME)
+        private val DERIVED_NAME = "test derived name"
+        private val DERIVED_NAME_MODEL = NetworkNameModel.IntentDerived(DERIVED_NAME)
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt
index 1fb76b0..3e6f909 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractorTest.kt
@@ -527,6 +527,57 @@
         }
 
     @Test
+    fun isSingleCarrier_zeroSubscriptions_false() =
+        testScope.runTest {
+            var latest: Boolean? = true
+            val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+            connectionsRepository.setSubscriptions(emptyList())
+            assertThat(latest).isFalse()
+
+            job.cancel()
+        }
+
+    @Test
+    fun isSingleCarrier_oneSubscription_true() =
+        testScope.runTest {
+            var latest: Boolean? = false
+            val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+            connectionsRepository.setSubscriptions(listOf(SUB_1))
+            assertThat(latest).isTrue()
+
+            job.cancel()
+        }
+
+    @Test
+    fun isSingleCarrier_twoSubscriptions_false() =
+        testScope.runTest {
+            var latest: Boolean? = true
+            val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+            connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+            assertThat(latest).isFalse()
+
+            job.cancel()
+        }
+
+    @Test
+    fun isSingleCarrier_updates() =
+        testScope.runTest {
+            var latest: Boolean? = false
+            val job = underTest.isSingleCarrier.onEach { latest = it }.launchIn(this)
+
+            connectionsRepository.setSubscriptions(listOf(SUB_1))
+            assertThat(latest).isTrue()
+
+            connectionsRepository.setSubscriptions(listOf(SUB_1, SUB_2))
+            assertThat(latest).isFalse()
+
+            job.cancel()
+        }
+
+    @Test
     fun mobileIsDefault_mobileFalseAndCarrierMergedFalse_false() =
         testScope.runTest {
             var latest: Boolean? = null
@@ -745,6 +796,7 @@
                 subscriptionId = subscriptionIds.first,
                 isOpportunistic = opportunistic.first,
                 groupUuid = groupUuid,
+                carrierName = "Carrier ${subscriptionIds.first}"
             )
 
         val sub2 =
@@ -752,6 +804,7 @@
                 subscriptionId = subscriptionIds.second,
                 isOpportunistic = opportunistic.second,
                 groupUuid = groupUuid,
+                carrierName = "Carrier ${opportunistic.second}"
             )
 
         return Pair(sub1, sub2)
@@ -760,11 +813,13 @@
     companion object {
 
         private const val SUB_1_ID = 1
-        private val SUB_1 = SubscriptionModel(subscriptionId = SUB_1_ID)
+        private val SUB_1 =
+            SubscriptionModel(subscriptionId = SUB_1_ID, carrierName = "Carrier $SUB_1_ID")
         private val CONNECTION_1 = FakeMobileConnectionRepository(SUB_1_ID, mock())
 
         private const val SUB_2_ID = 2
-        private val SUB_2 = SubscriptionModel(subscriptionId = SUB_2_ID)
+        private val SUB_2 =
+            SubscriptionModel(subscriptionId = SUB_2_ID, carrierName = "Carrier $SUB_2_ID")
         private val CONNECTION_2 = FakeMobileConnectionRepository(SUB_2_ID, mock())
 
         private const val SUB_3_ID = 3
@@ -773,6 +828,7 @@
                 subscriptionId = SUB_3_ID,
                 isOpportunistic = true,
                 groupUuid = ParcelUuid(UUID.randomUUID()),
+                carrierName = "Carrier $SUB_3_ID"
             )
         private val CONNECTION_3 = FakeMobileConnectionRepository(SUB_3_ID, mock())
 
@@ -782,6 +838,7 @@
                 subscriptionId = SUB_4_ID,
                 isOpportunistic = true,
                 groupUuid = ParcelUuid(UUID.randomUUID()),
+                carrierName = "Carrier $SUB_4_ID"
             )
         private val CONNECTION_4 = FakeMobileConnectionRepository(SUB_4_ID, mock())
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModelTest.kt
index f0458fa..065dfba 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/mobile/ui/viewmodel/MobileIconsViewModelTest.kt
@@ -92,15 +92,31 @@
 
             interactor.filteredSubscriptions.value =
                 listOf(
-                    SubscriptionModel(subscriptionId = 1, isOpportunistic = false),
+                    SubscriptionModel(
+                        subscriptionId = 1,
+                        isOpportunistic = false,
+                        carrierName = "Carrier 1",
+                    ),
                 )
             assertThat(latest).isEqualTo(listOf(1))
 
             interactor.filteredSubscriptions.value =
                 listOf(
-                    SubscriptionModel(subscriptionId = 2, isOpportunistic = false),
-                    SubscriptionModel(subscriptionId = 5, isOpportunistic = true),
-                    SubscriptionModel(subscriptionId = 7, isOpportunistic = true),
+                    SubscriptionModel(
+                        subscriptionId = 2,
+                        isOpportunistic = false,
+                        carrierName = "Carrier 2",
+                    ),
+                    SubscriptionModel(
+                        subscriptionId = 5,
+                        isOpportunistic = true,
+                        carrierName = "Carrier 5",
+                    ),
+                    SubscriptionModel(
+                        subscriptionId = 7,
+                        isOpportunistic = true,
+                        carrierName = "Carrier 7",
+                    ),
                 )
             assertThat(latest).isEqualTo(listOf(2, 5, 7))
 
@@ -138,6 +154,33 @@
         }
 
     @Test
+    fun caching_mobileIconInteractorIsReusedForSameSubId() =
+        testScope.runTest {
+            val interactor1 = underTest.mobileIconInteractorForSub(1)
+            val interactor2 = underTest.mobileIconInteractorForSub(1)
+
+            assertThat(interactor1).isSameInstanceAs(interactor2)
+        }
+
+    @Test
+    fun caching_invalidInteractorssAreRemovedFromCacheWhenSubDisappears() =
+        testScope.runTest {
+            // Retrieve interactors to trigger caching
+            val interactor1 = underTest.mobileIconInteractorForSub(1)
+            val interactor2 = underTest.mobileIconInteractorForSub(2)
+
+            // Both impls are cached
+            assertThat(underTest.mobileIconInteractorSubIdCache)
+                .containsExactly(1, interactor1, 2, interactor2)
+
+            // SUB_1 is removed from the list...
+            interactor.filteredSubscriptions.value = listOf(SUB_2)
+
+            // ... and dropped from the cache
+            assertThat(underTest.mobileIconInteractorSubIdCache).containsExactly(2, interactor2)
+        }
+
+    @Test
     fun firstMobileSubShowingNetworkTypeIcon_noSubs_false() =
         testScope.runTest {
             var latest: Boolean? = null
@@ -308,8 +351,23 @@
         }
 
     companion object {
-        private val SUB_1 = SubscriptionModel(subscriptionId = 1, isOpportunistic = false)
-        private val SUB_2 = SubscriptionModel(subscriptionId = 2, isOpportunistic = false)
-        private val SUB_3 = SubscriptionModel(subscriptionId = 3, isOpportunistic = false)
+        private val SUB_1 =
+            SubscriptionModel(
+                subscriptionId = 1,
+                isOpportunistic = false,
+                carrierName = "Carrier 1",
+            )
+        private val SUB_2 =
+            SubscriptionModel(
+                subscriptionId = 2,
+                isOpportunistic = false,
+                carrierName = "Carrier 2",
+            )
+        private val SUB_3 =
+            SubscriptionModel(
+                subscriptionId = 3,
+                isOpportunistic = false,
+                carrierName = "Carrier 3",
+            )
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubbleEducationControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubbleEducationControllerTest.kt
new file mode 100644
index 0000000..94ed608
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubbleEducationControllerTest.kt
@@ -0,0 +1,118 @@
+/*
+ * 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.wmshell
+
+import android.content.ContentResolver
+import android.content.Context
+import android.content.SharedPreferences
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.systemui.model.SysUiStateTest
+import com.android.wm.shell.bubbles.Bubble
+import com.android.wm.shell.bubbles.BubbleEducationController
+import com.android.wm.shell.bubbles.PREF_MANAGED_EDUCATION
+import com.android.wm.shell.bubbles.PREF_STACK_EDUCATION
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mockito
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class BubbleEducationControllerTest : SysUiStateTest() {
+    private val sharedPrefsEditor = Mockito.mock(SharedPreferences.Editor::class.java)
+    private val sharedPrefs = Mockito.mock(SharedPreferences::class.java)
+    private val context = Mockito.mock(Context::class.java)
+    private lateinit var sut: BubbleEducationController
+
+    @Before
+    fun setUp() {
+        Mockito.`when`(context.packageName).thenReturn("packageName")
+        Mockito.`when`(context.getSharedPreferences(anyString(), anyInt())).thenReturn(sharedPrefs)
+        Mockito.`when`(context.contentResolver)
+            .thenReturn(Mockito.mock(ContentResolver::class.java))
+        Mockito.`when`(sharedPrefs.edit()).thenReturn(sharedPrefsEditor)
+        sut = BubbleEducationController(context)
+    }
+
+    @Test
+    fun testSeenStackEducation_read() {
+        Mockito.`when`(sharedPrefs.getBoolean(anyString(), anyBoolean())).thenReturn(true)
+        assertEquals(sut.hasSeenStackEducation, true)
+        Mockito.verify(sharedPrefs).getBoolean(PREF_STACK_EDUCATION, false)
+    }
+
+    @Test
+    fun testSeenStackEducation_write() {
+        sut.hasSeenStackEducation = true
+        Mockito.verify(sharedPrefsEditor).putBoolean(PREF_STACK_EDUCATION, true)
+    }
+
+    @Test
+    fun testSeenManageEducation_read() {
+        Mockito.`when`(sharedPrefs.getBoolean(anyString(), anyBoolean())).thenReturn(true)
+        assertEquals(sut.hasSeenManageEducation, true)
+        Mockito.verify(sharedPrefs).getBoolean(PREF_MANAGED_EDUCATION, false)
+    }
+
+    @Test
+    fun testSeenManageEducation_write() {
+        sut.hasSeenManageEducation = true
+        Mockito.verify(sharedPrefsEditor).putBoolean(PREF_MANAGED_EDUCATION, true)
+    }
+
+    @Test
+    fun testShouldShowStackEducation() {
+        val bubble = Mockito.mock(Bubble::class.java)
+        // When bubble is null
+        assertEquals(sut.shouldShowStackEducation(null), false)
+        // When bubble is not conversation
+        Mockito.`when`(bubble.isConversation).thenReturn(false)
+        assertEquals(sut.shouldShowStackEducation(bubble), false)
+        // When bubble is conversation and has seen stack edu
+        Mockito.`when`(bubble.isConversation).thenReturn(true)
+        Mockito.`when`(sharedPrefs.getBoolean(anyString(), anyBoolean())).thenReturn(true)
+        assertEquals(sut.shouldShowStackEducation(bubble), false)
+        // When bubble is conversation and has not seen stack edu
+        Mockito.`when`(bubble.isConversation).thenReturn(true)
+        Mockito.`when`(sharedPrefs.getBoolean(anyString(), anyBoolean())).thenReturn(false)
+        assertEquals(sut.shouldShowStackEducation(bubble), true)
+    }
+
+    @Test
+    fun testShouldShowManageEducation() {
+        val bubble = Mockito.mock(Bubble::class.java)
+        // When bubble is null
+        assertEquals(sut.shouldShowManageEducation(null), false)
+        // When bubble is not conversation
+        Mockito.`when`(bubble.isConversation).thenReturn(false)
+        assertEquals(sut.shouldShowManageEducation(bubble), false)
+        // When bubble is conversation and has seen stack edu
+        Mockito.`when`(bubble.isConversation).thenReturn(true)
+        Mockito.`when`(sharedPrefs.getBoolean(anyString(), anyBoolean())).thenReturn(true)
+        assertEquals(sut.shouldShowManageEducation(bubble), false)
+        // When bubble is conversation and has not seen stack edu
+        Mockito.`when`(bubble.isConversation).thenReturn(true)
+        Mockito.`when`(sharedPrefs.getBoolean(anyString(), anyBoolean())).thenReturn(false)
+        assertEquals(sut.shouldShowManageEducation(bubble), true)
+    }
+}
diff --git a/services/companion/java/com/android/server/companion/virtual/Android.bp b/services/companion/java/com/android/server/companion/virtual/Android.bp
new file mode 100644
index 0000000..50a09b9
--- /dev/null
+++ b/services/companion/java/com/android/server/companion/virtual/Android.bp
@@ -0,0 +1,12 @@
+java_aconfig_library {
+    name: "virtualdevice_flags_lib",
+    aconfig_declarations: "virtualdevice_flags",
+}
+
+aconfig_declarations {
+    name: "virtualdevice_flags",
+    package: "com.android.server.companion.virtual",
+    srcs: [
+        "flags.aconfig",
+    ],
+}
\ No newline at end of file
diff --git a/services/companion/java/com/android/server/companion/virtual/flags.aconfig b/services/companion/java/com/android/server/companion/virtual/flags.aconfig
new file mode 100644
index 0000000..4fe4c87
--- /dev/null
+++ b/services/companion/java/com/android/server/companion/virtual/flags.aconfig
@@ -0,0 +1,8 @@
+package: "com.android.server.companion.virtual"
+
+flag {
+  name: "dump_history"
+  namespace: "virtual_devices"
+  description: "This flag controls if a history of virtual devices is shown in dumpsys virtualdevices"
+  bug: "293114719"
+}
\ No newline at end of file
diff --git a/services/core/java/com/android/server/am/BatteryStatsService.java b/services/core/java/com/android/server/am/BatteryStatsService.java
index 04ebb2b..b0f30d6 100644
--- a/services/core/java/com/android/server/am/BatteryStatsService.java
+++ b/services/core/java/com/android/server/am/BatteryStatsService.java
@@ -156,6 +156,7 @@
 
     private final PowerProfile mPowerProfile;
     private final CpuScalingPolicies mCpuScalingPolicies;
+    private final BatteryStatsImpl.BatteryStatsConfig mBatteryStatsConfig;
     final BatteryStatsImpl mStats;
     final CpuWakeupStats mCpuWakeupStats;
     private final BatteryUsageStatsStore mBatteryUsageStatsStore;
@@ -374,22 +375,24 @@
         mPowerProfile = new PowerProfile(context);
         mCpuScalingPolicies = new CpuScalingPolicyReader().read();
 
-        mStats = new BatteryStatsImpl(systemDir, handler, this,
+        final boolean resetOnUnplugHighBatteryLevel = context.getResources().getBoolean(
+                com.android.internal.R.bool.config_batteryStatsResetOnUnplugHighBatteryLevel);
+        final boolean resetOnUnplugAfterSignificantCharge = context.getResources().getBoolean(
+                com.android.internal.R.bool.config_batteryStatsResetOnUnplugAfterSignificantCharge);
+        final long powerStatsThrottlePeriodCpu = context.getResources().getInteger(
+                com.android.internal.R.integer.config_defaultPowerStatsThrottlePeriodCpu);
+        mBatteryStatsConfig =
+                new BatteryStatsImpl.BatteryStatsConfig.Builder()
+                        .setResetOnUnplugHighBatteryLevel(resetOnUnplugHighBatteryLevel)
+                        .setResetOnUnplugAfterSignificantCharge(resetOnUnplugAfterSignificantCharge)
+                        .setPowerStatsThrottlePeriodCpu(powerStatsThrottlePeriodCpu)
+                        .build();
+        mStats = new BatteryStatsImpl(mBatteryStatsConfig, systemDir, handler, this,
                 this, mUserManagerUserInfoProvider, mPowerProfile, mCpuScalingPolicies);
         mWorker = new BatteryExternalStatsWorker(context, mStats);
         mStats.setExternalStatsSyncLocked(mWorker);
         mStats.setRadioScanningTimeoutLocked(mContext.getResources().getInteger(
                 com.android.internal.R.integer.config_radioScanningTimeout) * 1000L);
-
-        final boolean resetOnUnplugHighBatteryLevel = context.getResources().getBoolean(
-                com.android.internal.R.bool.config_batteryStatsResetOnUnplugHighBatteryLevel);
-        final boolean resetOnUnplugAfterSignificantCharge = context.getResources().getBoolean(
-                com.android.internal.R.bool.config_batteryStatsResetOnUnplugAfterSignificantCharge);
-        mStats.setBatteryStatsConfig(
-                new BatteryStatsImpl.BatteryStatsConfig.Builder()
-                        .setResetOnUnplugHighBatteryLevel(resetOnUnplugHighBatteryLevel)
-                        .setResetOnUnplugAfterSignificantCharge(resetOnUnplugAfterSignificantCharge)
-                        .build());
         mStats.startTrackingSystemServerCpuTime();
 
         if (BATTERY_USAGE_STORE_ENABLED) {
@@ -2591,6 +2594,7 @@
         pw.println("     --proto: output as a binary protobuffer");
         pw.println("     --model power-profile: use the power profile model"
                 + " even if measured energy is available");
+        pw.println("  --sample: collect and dump a sample of stats for debugging purpose");
         pw.println("  <package.name>: optional name of package to filter output by.");
         pw.println("  -h: print this help text.");
         pw.println("Battery stats (batterystats) commands:");
@@ -2623,6 +2627,10 @@
         }
     }
 
+    private void dumpStatsSample(PrintWriter pw) {
+        mStats.dumpStatsSample(pw);
+    }
+
     private void dumpMeasuredEnergyStats(PrintWriter pw) {
         // Wait for the completion of pending works if there is any
         awaitCompletion();
@@ -2864,6 +2872,9 @@
                     mCpuWakeupStats.dump(new IndentingPrintWriter(pw, "  "),
                             SystemClock.elapsedRealtime());
                     return;
+                } else if ("--sample".equals(arg)) {
+                    dumpStatsSample(pw);
+                    return;
                 } else if ("-a".equals(arg)) {
                     flags |= BatteryStats.DUMP_VERBOSE;
                 } else if (arg.length() > 0 && arg.charAt(0) == '-'){
@@ -2924,6 +2935,7 @@
                                 in.unmarshall(raw, 0, raw.length);
                                 in.setDataPosition(0);
                                 BatteryStatsImpl checkinStats = new BatteryStatsImpl(
+                                        mBatteryStatsConfig,
                                         null, mStats.mHandler, null, null,
                                         mUserManagerUserInfoProvider, mPowerProfile,
                                         mCpuScalingPolicies);
@@ -2965,6 +2977,7 @@
                                 in.unmarshall(raw, 0, raw.length);
                                 in.setDataPosition(0);
                                 BatteryStatsImpl checkinStats = new BatteryStatsImpl(
+                                        mBatteryStatsConfig,
                                         null, mStats.mHandler, null, null,
                                         mUserManagerUserInfoProvider, mPowerProfile,
                                         mCpuScalingPolicies);
diff --git a/services/core/java/com/android/server/audio/AudioDeviceBroker.java b/services/core/java/com/android/server/audio/AudioDeviceBroker.java
index 4e01997..4a523af 100644
--- a/services/core/java/com/android/server/audio/AudioDeviceBroker.java
+++ b/services/core/java/com/android/server/audio/AudioDeviceBroker.java
@@ -2409,7 +2409,11 @@
     }
 
     @GuardedBy("mDeviceStateLock")
-    private boolean communnicationDeviceCompatOn() {
+    // LE Audio: For system server (Telecom) and APKs targeting S and above, we let the audio
+    // policy routing rules select the default communication device.
+    // For older APKs, we force LE Audio headset when connected as those APKs cannot select a LE
+    // Audiodevice explicitly.
+    private boolean communnicationDeviceLeAudioCompatOn() {
         return mAudioModeOwner.mMode == AudioSystem.MODE_IN_COMMUNICATION
                 && !(CompatChanges.isChangeEnabled(
                         USE_SET_COMMUNICATION_DEVICE, mAudioModeOwner.mUid)
@@ -2417,19 +2421,25 @@
     }
 
     @GuardedBy("mDeviceStateLock")
+    // Hearing Aid: For system server (Telecom) and IN_CALL mode we let the audio
+    // policy routing rules select the default communication device.
+    // For 3p apps and IN_COMMUNICATION mode we force Hearing aid when connected to maintain
+    // backwards compatibility
+    private boolean communnicationDeviceHaCompatOn() {
+        return mAudioModeOwner.mMode == AudioSystem.MODE_IN_COMMUNICATION
+                && !(mAudioModeOwner.mUid == android.os.Process.SYSTEM_UID);
+    }
+
+    @GuardedBy("mDeviceStateLock")
     AudioDeviceAttributes getDefaultCommunicationDevice() {
-        // For system server (Telecom) and APKs targeting S and above, we let the audio
-        // policy routing rules select the default communication device.
-        // For older APKs, we force Hearing Aid or LE Audio headset when connected as
-        // those APKs cannot select a LE Audio or Hearing Aid device explicitly.
         AudioDeviceAttributes device = null;
-        if (communnicationDeviceCompatOn()) {
-            // If both LE and Hearing Aid are active (thie should not happen),
-            // priority to Hearing Aid.
+        // If both LE and Hearing Aid are active (thie should not happen),
+        // priority to Hearing Aid.
+        if (communnicationDeviceHaCompatOn()) {
             device = mDeviceInventory.getDeviceOfType(AudioSystem.DEVICE_OUT_HEARING_AID);
-            if (device == null) {
-                device = mDeviceInventory.getDeviceOfType(AudioSystem.DEVICE_OUT_BLE_HEADSET);
-            }
+        }
+        if (device == null && communnicationDeviceLeAudioCompatOn()) {
+            device = mDeviceInventory.getDeviceOfType(AudioSystem.DEVICE_OUT_BLE_HEADSET);
         }
         return device;
     }
diff --git a/services/core/java/com/android/server/input/FocusEventDebugView.java b/services/core/java/com/android/server/input/FocusEventDebugView.java
deleted file mode 100644
index 4b8fabde..0000000
--- a/services/core/java/com/android/server/input/FocusEventDebugView.java
+++ /dev/null
@@ -1,848 +0,0 @@
-/*
- * Copyright 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.server.input;
-
-import static android.util.TypedValue.COMPLEX_UNIT_DIP;
-import static android.util.TypedValue.COMPLEX_UNIT_SP;
-import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
-
-import android.animation.LayoutTransition;
-import android.annotation.AnyThread;
-import android.annotation.Nullable;
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.ColorFilter;
-import android.graphics.ColorMatrixColorFilter;
-import android.graphics.Paint;
-import android.graphics.Typeface;
-import android.util.DisplayMetrics;
-import android.util.Pair;
-import android.util.Slog;
-import android.util.TypedValue;
-import android.view.Gravity;
-import android.view.InputDevice;
-import android.view.KeyEvent;
-import android.view.MotionEvent;
-import android.view.RoundedCorner;
-import android.view.View;
-import android.view.ViewConfiguration;
-import android.view.WindowInsets;
-import android.view.animation.AccelerateInterpolator;
-import android.widget.HorizontalScrollView;
-import android.widget.LinearLayout;
-import android.widget.RelativeLayout;
-import android.widget.TextView;
-
-import com.android.internal.R;
-import com.android.internal.annotations.VisibleForTesting;
-
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Locale;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Supplier;
-
-/**
- *  Displays focus events, such as physical keyboard KeyEvents and non-pointer MotionEvents on
- *  the screen.
- */
-class FocusEventDebugView extends RelativeLayout {
-
-    private static final String TAG = FocusEventDebugView.class.getSimpleName();
-
-    private static final int KEY_FADEOUT_DURATION_MILLIS = 1000;
-    private static final int KEY_TRANSITION_DURATION_MILLIS = 100;
-
-    private static final int OUTER_PADDING_DP = 16;
-    private static final int KEY_SEPARATION_MARGIN_DP = 16;
-    private static final int KEY_VIEW_SIDE_PADDING_DP = 16;
-    private static final int KEY_VIEW_VERTICAL_PADDING_DP = 8;
-    private static final int KEY_VIEW_MIN_WIDTH_DP = 32;
-    private static final int KEY_VIEW_TEXT_SIZE_SP = 12;
-    private static final double ROTATY_GRAPH_HEIGHT_FRACTION = 0.5;
-
-    private final InputManagerService mService;
-    private final int mOuterPadding;
-    private final DisplayMetrics mDm;
-
-    // Tracks all keys that are currently pressed/down.
-    private final Map<Pair<Integer /*deviceId*/, Integer /*scanCode*/>, PressedKeyView>
-            mPressedKeys = new HashMap<>();
-
-    @Nullable
-    private FocusEventDebugGlobalMonitor mFocusEventDebugGlobalMonitor;
-    @Nullable
-    private PressedKeyContainer mPressedKeyContainer;
-    @Nullable
-    private PressedKeyContainer mPressedModifierContainer;
-    private final Supplier<RotaryInputValueView> mRotaryInputValueViewFactory;
-    @Nullable
-    private RotaryInputValueView mRotaryInputValueView;
-    private final Supplier<RotaryInputGraphView> mRotaryInputGraphViewFactory;
-    @Nullable
-    private RotaryInputGraphView mRotaryInputGraphView;
-
-    @VisibleForTesting
-    FocusEventDebugView(Context c, InputManagerService service,
-            Supplier<RotaryInputValueView> rotaryInputValueViewFactory,
-            Supplier<RotaryInputGraphView> rotaryInputGraphViewFactory) {
-        super(c);
-        setFocusableInTouchMode(true);
-
-        mService = service;
-        mRotaryInputValueViewFactory = rotaryInputValueViewFactory;
-        mRotaryInputGraphViewFactory = rotaryInputGraphViewFactory;
-        mDm = mContext.getResources().getDisplayMetrics();
-        mOuterPadding = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, OUTER_PADDING_DP, mDm);
-    }
-
-    FocusEventDebugView(Context c, InputManagerService service) {
-        this(c, service, () -> new RotaryInputValueView(c), () -> new RotaryInputGraphView(c));
-    }
-
-    @Override
-    public WindowInsets onApplyWindowInsets(WindowInsets insets) {
-        int paddingBottom = 0;
-
-        final RoundedCorner bottomLeft =
-                insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT);
-        if (bottomLeft != null && !insets.isRound()) {
-            paddingBottom = bottomLeft.getRadius();
-        }
-
-        final RoundedCorner bottomRight =
-                insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT);
-        if (bottomRight != null && !insets.isRound()) {
-            paddingBottom = Math.max(paddingBottom, bottomRight.getRadius());
-        }
-
-        if (insets.getDisplayCutout() != null) {
-            paddingBottom =
-                    Math.max(paddingBottom, insets.getDisplayCutout().getSafeInsetBottom());
-        }
-
-        setPadding(mOuterPadding, mOuterPadding, mOuterPadding, mOuterPadding + paddingBottom);
-        setClipToPadding(false);
-        invalidate();
-        return super.onApplyWindowInsets(insets);
-    }
-
-    @Override
-    public boolean dispatchKeyEvent(KeyEvent event) {
-        handleKeyEvent(event);
-        return super.dispatchKeyEvent(event);
-    }
-
-    @AnyThread
-    public void updateShowKeyPresses(boolean enabled) {
-        post(() -> handleUpdateShowKeyPresses(enabled));
-    }
-
-    @AnyThread
-    public void updateShowRotaryInput(boolean enabled) {
-        post(() -> handleUpdateShowRotaryInput(enabled));
-    }
-
-    private void handleUpdateShowKeyPresses(boolean enabled) {
-        if (enabled == showKeyPresses()) {
-            return;
-        }
-
-        if (!enabled) {
-            removeView(mPressedKeyContainer);
-            mPressedKeyContainer = null;
-            removeView(mPressedModifierContainer);
-            mPressedModifierContainer = null;
-            return;
-        }
-
-        mPressedKeyContainer = new PressedKeyContainer(mContext);
-        mPressedKeyContainer.setOrientation(LinearLayout.HORIZONTAL);
-        mPressedKeyContainer.setGravity(Gravity.RIGHT | Gravity.BOTTOM);
-        mPressedKeyContainer.setLayoutDirection(LAYOUT_DIRECTION_LTR);
-        final var scroller = new HorizontalScrollView(mContext);
-        scroller.addView(mPressedKeyContainer);
-        scroller.setHorizontalScrollBarEnabled(false);
-        scroller.addOnLayoutChangeListener(
-                (view, l, t, r, b, ol, ot, or, ob) -> scroller.fullScroll(View.FOCUS_RIGHT));
-        scroller.setHorizontalFadingEdgeEnabled(true);
-        LayoutParams scrollerLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
-        scrollerLayoutParams.addRule(ALIGN_PARENT_BOTTOM);
-        scrollerLayoutParams.addRule(ALIGN_PARENT_RIGHT);
-        addView(scroller, scrollerLayoutParams);
-
-        mPressedModifierContainer = new PressedKeyContainer(mContext);
-        mPressedModifierContainer.setOrientation(LinearLayout.VERTICAL);
-        mPressedModifierContainer.setGravity(Gravity.LEFT | Gravity.BOTTOM);
-        LayoutParams modifierLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
-        modifierLayoutParams.addRule(ALIGN_PARENT_BOTTOM);
-        modifierLayoutParams.addRule(ALIGN_PARENT_LEFT);
-        modifierLayoutParams.addRule(LEFT_OF, scroller.getId());
-        addView(mPressedModifierContainer, modifierLayoutParams);
-    }
-
-    @VisibleForTesting
-    void handleUpdateShowRotaryInput(boolean enabled) {
-        if (enabled == showRotaryInput()) {
-            return;
-        }
-
-        if (!enabled) {
-            mFocusEventDebugGlobalMonitor.dispose();
-            mFocusEventDebugGlobalMonitor = null;
-            removeView(mRotaryInputValueView);
-            mRotaryInputValueView = null;
-            removeView(mRotaryInputGraphView);
-            mRotaryInputGraphView = null;
-            return;
-        }
-
-        mFocusEventDebugGlobalMonitor = new FocusEventDebugGlobalMonitor(this, mService);
-
-        mRotaryInputValueView = mRotaryInputValueViewFactory.get();
-        LayoutParams valueLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
-        valueLayoutParams.addRule(CENTER_HORIZONTAL);
-        valueLayoutParams.addRule(ALIGN_PARENT_BOTTOM);
-        addView(mRotaryInputValueView, valueLayoutParams);
-
-        mRotaryInputGraphView = mRotaryInputGraphViewFactory.get();
-        LayoutParams graphLayoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
-                (int) (ROTATY_GRAPH_HEIGHT_FRACTION * mDm.heightPixels));
-        graphLayoutParams.addRule(CENTER_IN_PARENT);
-        addView(mRotaryInputGraphView, graphLayoutParams);
-    }
-
-    /** Report a key event to the debug view. */
-    @AnyThread
-    public void reportKeyEvent(KeyEvent event) {
-        post(() -> handleKeyEvent(KeyEvent.obtain((KeyEvent) event)));
-    }
-
-    /** Report a motion event to the debug view. */
-    @AnyThread
-    public void reportMotionEvent(MotionEvent event) {
-        if (event.getSource() != InputDevice.SOURCE_ROTARY_ENCODER) {
-            return;
-        }
-
-        post(() -> handleRotaryInput(MotionEvent.obtain((MotionEvent) event)));
-    }
-
-    private void handleKeyEvent(KeyEvent keyEvent) {
-        if (!showKeyPresses()) {
-            return;
-        }
-
-        final var identifier = new Pair<>(keyEvent.getDeviceId(), keyEvent.getScanCode());
-        final var container = KeyEvent.isModifierKey(keyEvent.getKeyCode())
-                ? mPressedModifierContainer
-                : mPressedKeyContainer;
-        PressedKeyView pressedKeyView = mPressedKeys.get(identifier);
-        switch (keyEvent.getAction()) {
-            case KeyEvent.ACTION_DOWN: {
-                if (pressedKeyView != null) {
-                    if (keyEvent.getRepeatCount() == 0) {
-                        Slog.w(TAG, "Got key down for "
-                                + KeyEvent.keyCodeToString(keyEvent.getKeyCode())
-                                + " that was already tracked as being down.");
-                        break;
-                    }
-                    container.handleKeyRepeat(pressedKeyView);
-                    break;
-                }
-
-                pressedKeyView = new PressedKeyView(mContext, getLabel(keyEvent));
-                mPressedKeys.put(identifier, pressedKeyView);
-                container.handleKeyPressed(pressedKeyView);
-                break;
-            }
-            case KeyEvent.ACTION_UP: {
-                if (pressedKeyView == null) {
-                    Slog.w(TAG, "Got key up for " + KeyEvent.keyCodeToString(keyEvent.getKeyCode())
-                            + " that was not tracked as being down.");
-                    break;
-                }
-                mPressedKeys.remove(identifier);
-                container.handleKeyRelease(pressedKeyView);
-                break;
-            }
-            default:
-                break;
-        }
-        keyEvent.recycle();
-    }
-
-    @VisibleForTesting
-    void handleRotaryInput(MotionEvent motionEvent) {
-        if (!showRotaryInput()) {
-            return;
-        }
-
-        float scrollAxisValue = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
-        mRotaryInputValueView.updateValue(scrollAxisValue);
-        mRotaryInputGraphView.addValue(scrollAxisValue, motionEvent.getEventTime());
-
-        motionEvent.recycle();
-    }
-
-    private static String getLabel(KeyEvent event) {
-        switch (event.getKeyCode()) {
-            case KeyEvent.KEYCODE_SPACE:
-                return "\u2423";
-            case KeyEvent.KEYCODE_TAB:
-                return "\u21e5";
-            case KeyEvent.KEYCODE_ENTER:
-            case KeyEvent.KEYCODE_NUMPAD_ENTER:
-                return "\u23CE";
-            case KeyEvent.KEYCODE_DEL:
-                return "\u232B";
-            case KeyEvent.KEYCODE_FORWARD_DEL:
-                return "\u2326";
-            case KeyEvent.KEYCODE_ESCAPE:
-                return "ESC";
-            case KeyEvent.KEYCODE_DPAD_UP:
-                return "\u2191";
-            case KeyEvent.KEYCODE_DPAD_DOWN:
-                return "\u2193";
-            case KeyEvent.KEYCODE_DPAD_LEFT:
-                return "\u2190";
-            case KeyEvent.KEYCODE_DPAD_RIGHT:
-                return "\u2192";
-            case KeyEvent.KEYCODE_DPAD_UP_RIGHT:
-                return "\u2197";
-            case KeyEvent.KEYCODE_DPAD_UP_LEFT:
-                return "\u2196";
-            case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT:
-                return "\u2198";
-            case KeyEvent.KEYCODE_DPAD_DOWN_LEFT:
-                return "\u2199";
-            default:
-                break;
-        }
-
-        final int unicodeChar = event.getUnicodeChar();
-        if (unicodeChar != 0) {
-            return new String(Character.toChars(unicodeChar));
-        }
-
-        final var label = KeyEvent.keyCodeToString(event.getKeyCode());
-        if (label.startsWith("KEYCODE_")) {
-            return label.substring(8);
-        }
-        return label;
-    }
-
-    /** Determine whether to show key presses by checking one of the key-related objects. */
-    private boolean showKeyPresses() {
-        return mPressedKeyContainer != null;
-    }
-
-    /** Determine whether to show rotary input by checking one of the rotary-related objects. */
-    private boolean showRotaryInput() {
-        return mRotaryInputValueView != null;
-    }
-
-    /**
-     * Converts a dimension in scaled pixel units to integer display pixels.
-     */
-    private static int applyDimensionSp(int dimensionSp, DisplayMetrics dm) {
-        return (int) TypedValue.applyDimension(COMPLEX_UNIT_SP, dimensionSp, dm);
-    }
-
-    private static class PressedKeyView extends TextView {
-
-        private static final ColorFilter sInvertColors = new ColorMatrixColorFilter(new float[]{
-                -1.0f,     0,     0,    0, 255, // red
-                0, -1.0f,     0,    0, 255, // green
-                0,     0, -1.0f,    0, 255, // blue
-                0,     0,     0, 1.0f, 0    // alpha
-        });
-
-        PressedKeyView(Context c, String label) {
-            super(c);
-
-            final var dm = c.getResources().getDisplayMetrics();
-            final int keyViewSidePadding =
-                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_SIDE_PADDING_DP, dm);
-            final int keyViewVerticalPadding =
-                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_VERTICAL_PADDING_DP,
-                            dm);
-            final int keyViewMinWidth =
-                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_MIN_WIDTH_DP, dm);
-            final int textSize =
-                    (int) TypedValue.applyDimension(COMPLEX_UNIT_SP, KEY_VIEW_TEXT_SIZE_SP, dm);
-
-            setText(label);
-            setGravity(Gravity.CENTER);
-            setMinimumWidth(keyViewMinWidth);
-            setTextSize(textSize);
-            setTypeface(Typeface.SANS_SERIF);
-            setBackgroundResource(R.drawable.focus_event_pressed_key_background);
-            setPaddingRelative(keyViewSidePadding, keyViewVerticalPadding, keyViewSidePadding,
-                    keyViewVerticalPadding);
-
-            setHighlighted(true);
-        }
-
-        void setHighlighted(boolean isHighlighted) {
-            if (isHighlighted) {
-                setTextColor(Color.BLACK);
-                getBackground().setColorFilter(sInvertColors);
-            } else {
-                setTextColor(Color.WHITE);
-                getBackground().clearColorFilter();
-            }
-            invalidate();
-        }
-    }
-
-    private static class PressedKeyContainer extends LinearLayout {
-
-        private final MarginLayoutParams mPressedKeyLayoutParams;
-
-        PressedKeyContainer(Context c) {
-            super(c);
-
-            final var dm = c.getResources().getDisplayMetrics();
-            final int keySeparationMargin =
-                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_SEPARATION_MARGIN_DP, dm);
-
-            final var transition = new LayoutTransition();
-            transition.disableTransitionType(LayoutTransition.APPEARING);
-            transition.disableTransitionType(LayoutTransition.DISAPPEARING);
-            transition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING);
-            transition.setDuration(KEY_TRANSITION_DURATION_MILLIS);
-            setLayoutTransition(transition);
-
-            mPressedKeyLayoutParams = new MarginLayoutParams(WRAP_CONTENT, WRAP_CONTENT);
-            if (getOrientation() == VERTICAL) {
-                mPressedKeyLayoutParams.setMargins(0, keySeparationMargin, 0, 0);
-            } else {
-                mPressedKeyLayoutParams.setMargins(keySeparationMargin, 0, 0, 0);
-            }
-        }
-
-        public void handleKeyPressed(PressedKeyView pressedKeyView) {
-            addView(pressedKeyView, getChildCount(), mPressedKeyLayoutParams);
-            invalidate();
-        }
-
-        public void handleKeyRepeat(PressedKeyView repeatedKeyView) {
-            // Do nothing for now.
-        }
-
-        public void handleKeyRelease(PressedKeyView releasedKeyView) {
-            releasedKeyView.setHighlighted(false);
-            releasedKeyView.clearAnimation();
-            releasedKeyView.animate()
-                    .alpha(0)
-                    .setDuration(KEY_FADEOUT_DURATION_MILLIS)
-                    .setInterpolator(new AccelerateInterpolator())
-                    .withEndAction(this::cleanUpPressedKeyViews)
-                    .start();
-        }
-
-        private void cleanUpPressedKeyViews() {
-            int numChildrenToRemove = 0;
-            for (int i = 0; i < getChildCount(); i++) {
-                final View child = getChildAt(i);
-                if (child.getAlpha() != 0) {
-                    break;
-                }
-                child.setVisibility(View.GONE);
-                child.clearAnimation();
-                numChildrenToRemove++;
-            }
-            removeViews(0, numChildrenToRemove);
-            invalidate();
-        }
-    }
-
-    // TODO(b/286086154): move RotaryInputGraphView and RotaryInputValueView to a subpackage.
-
-    /** Draws the most recent rotary input value and indicates whether the source is active. */
-    @VisibleForTesting
-    static class RotaryInputValueView extends TextView {
-
-        private static final int INACTIVE_TEXT_COLOR = 0xffff00ff;
-        private static final int ACTIVE_TEXT_COLOR = 0xff420f28;
-        private static final int TEXT_SIZE_SP = 8;
-        private static final int SIDE_PADDING_SP = 4;
-        /** Determines how long the active status lasts. */
-        private static final int ACTIVE_STATUS_DURATION = 250 /* milliseconds */;
-        private static final ColorFilter ACTIVE_BACKGROUND_FILTER =
-                new ColorMatrixColorFilter(new float[]{
-                        0, 0, 0, 0, 255, // red
-                        0, 0, 0, 0,   0, // green
-                        0, 0, 0, 0, 255, // blue
-                        0, 0, 0, 0, 200  // alpha
-                });
-
-        private final Runnable mUpdateActivityStatusCallback = () -> updateActivityStatus(false);
-        private final float mScaledVerticalScrollFactor;
-
-        @VisibleForTesting
-        RotaryInputValueView(Context c) {
-            super(c);
-
-            DisplayMetrics dm = mContext.getResources().getDisplayMetrics();
-            mScaledVerticalScrollFactor = ViewConfiguration.get(c).getScaledVerticalScrollFactor();
-
-            setText(getFormattedValue(0));
-            setTextColor(INACTIVE_TEXT_COLOR);
-            setTextSize(applyDimensionSp(TEXT_SIZE_SP, dm));
-            setPaddingRelative(applyDimensionSp(SIDE_PADDING_SP, dm), 0,
-                    applyDimensionSp(SIDE_PADDING_SP, dm), 0);
-            setTypeface(null, Typeface.BOLD);
-            setBackgroundResource(R.drawable.focus_event_rotary_input_background);
-        }
-
-        void updateValue(float value) {
-            removeCallbacks(mUpdateActivityStatusCallback);
-
-            setText(getFormattedValue(value * mScaledVerticalScrollFactor));
-
-            updateActivityStatus(true);
-            postDelayed(mUpdateActivityStatusCallback, ACTIVE_STATUS_DURATION);
-        }
-
-        @VisibleForTesting
-        void updateActivityStatus(boolean active) {
-            if (active) {
-                setTextColor(ACTIVE_TEXT_COLOR);
-                getBackground().setColorFilter(ACTIVE_BACKGROUND_FILTER);
-            } else {
-                setTextColor(INACTIVE_TEXT_COLOR);
-                getBackground().clearColorFilter();
-            }
-        }
-
-        private static String getFormattedValue(float value) {
-            return String.format("%s%.1f", value < 0 ? "-" : "+", Math.abs(value));
-        }
-    }
-
-    /**
-     * Shows a graph with the rotary input values as a function of time.
-     * The graph gets reset if no action is received for a certain amount of time.
-     */
-    @VisibleForTesting
-    static class RotaryInputGraphView extends View {
-
-        private static final int FRAME_COLOR = 0xbf741b47;
-        private static final int FRAME_WIDTH_SP = 2;
-        private static final int FRAME_BORDER_GAP_SP = 10;
-        private static final int FRAME_TEXT_SIZE_SP = 10;
-        private static final int FRAME_TEXT_OFFSET_SP = 2;
-        private static final int GRAPH_COLOR = 0xffff00ff;
-        private static final int GRAPH_LINE_WIDTH_SP = 1;
-        private static final int GRAPH_POINT_RADIUS_SP = 4;
-        private static final long MAX_SHOWN_TIME_INTERVAL = TimeUnit.SECONDS.toMillis(5);
-        private static final float DEFAULT_FRAME_CENTER_POSITION = 0;
-        private static final int MAX_GRAPH_VALUES_SIZE = 400;
-        /** Maximum time between values so that they are considered part of the same gesture. */
-        private static final long MAX_GESTURE_TIME = TimeUnit.SECONDS.toMillis(1);
-
-        private final DisplayMetrics mDm;
-        /**
-         * Distance in position units (amount scrolled in display pixels) from the center to the
-         * top/bottom frame lines.
-         */
-        private final float mFrameCenterToBorderDistance;
-        private final float mScaledVerticalScrollFactor;
-        private final Locale mDefaultLocale;
-        private final Paint mFramePaint = new Paint();
-        private final Paint mFrameTextPaint = new Paint();
-        private final Paint mGraphLinePaint = new Paint();
-        private final Paint mGraphPointPaint = new Paint();
-
-        private final CyclicBuffer mGraphValues = new CyclicBuffer(MAX_GRAPH_VALUES_SIZE);
-        /** Position at which graph values are placed at the center of the graph. */
-        private float mFrameCenterPosition = DEFAULT_FRAME_CENTER_POSITION;
-
-        @VisibleForTesting
-        RotaryInputGraphView(Context c) {
-            super(c);
-
-            mDm = mContext.getResources().getDisplayMetrics();
-            // This makes the center-to-border distance equivalent to the display height, meaning
-            // that the total height of the graph is equivalent to 2x the display height.
-            mFrameCenterToBorderDistance = mDm.heightPixels;
-            mScaledVerticalScrollFactor = ViewConfiguration.get(c).getScaledVerticalScrollFactor();
-            mDefaultLocale = Locale.getDefault();
-
-            mFramePaint.setColor(FRAME_COLOR);
-            mFramePaint.setStrokeWidth(applyDimensionSp(FRAME_WIDTH_SP, mDm));
-
-            mFrameTextPaint.setColor(GRAPH_COLOR);
-            mFrameTextPaint.setTextSize(applyDimensionSp(FRAME_TEXT_SIZE_SP, mDm));
-
-            mGraphLinePaint.setColor(GRAPH_COLOR);
-            mGraphLinePaint.setStrokeWidth(applyDimensionSp(GRAPH_LINE_WIDTH_SP, mDm));
-            mGraphLinePaint.setStrokeCap(Paint.Cap.ROUND);
-            mGraphLinePaint.setStrokeJoin(Paint.Join.ROUND);
-
-            mGraphPointPaint.setColor(GRAPH_COLOR);
-            mGraphPointPaint.setStrokeWidth(applyDimensionSp(GRAPH_POINT_RADIUS_SP, mDm));
-            mGraphPointPaint.setStrokeCap(Paint.Cap.ROUND);
-            mGraphPointPaint.setStrokeJoin(Paint.Join.ROUND);
-        }
-
-        /**
-         * Reads new scroll axis value and updates the list accordingly. Old positions are
-         * kept at the front (what you would get with getFirst), while the recent positions are
-         * kept at the back (what you would get with getLast). Also updates the frame center
-         * position to handle out-of-bounds cases.
-         */
-        void addValue(float scrollAxisValue, long eventTime) {
-            // Remove values that are too old.
-            while (mGraphValues.getSize() > 0
-                    && (eventTime - mGraphValues.getFirst().mTime) > MAX_SHOWN_TIME_INTERVAL) {
-                mGraphValues.removeFirst();
-            }
-
-            // If there are no recent values, reset the frame center.
-            if (mGraphValues.getSize() == 0) {
-                mFrameCenterPosition = DEFAULT_FRAME_CENTER_POSITION;
-            }
-
-            // Handle new value. We multiply the scroll axis value by the scaled scroll factor to
-            // get the amount of pixels to be scrolled. We also compute the accumulated position
-            // by adding the current value to the last one (if not empty).
-            final float displacement = scrollAxisValue * mScaledVerticalScrollFactor;
-            final float prevPos = (mGraphValues.getSize() == 0 ? 0 : mGraphValues.getLast().mPos);
-            final float pos = prevPos + displacement;
-
-            mGraphValues.add(pos, eventTime);
-
-            // The difference between the distance of the most recent position from the center
-            // frame (pos - mFrameCenterPosition) and the maximum allowed distance from the center
-            // frame (mFrameCenterToBorderDistance).
-            final float verticalDiff = Math.abs(pos - mFrameCenterPosition)
-                    - mFrameCenterToBorderDistance;
-            // If needed, translate frame.
-            if (verticalDiff > 0) {
-                final int sign = pos - mFrameCenterPosition < 0 ? -1 : 1;
-                // Here, we update the center frame position by the exact amount needed for us to
-                // stay within the maximum allowed distance from the center frame.
-                mFrameCenterPosition += sign * verticalDiff;
-            }
-
-            // Redraw canvas.
-            invalidate();
-        }
-
-        @Override
-        protected void onDraw(Canvas canvas) {
-            super.onDraw(canvas);
-
-            // Note: vertical coordinates in Canvas go from top to bottom,
-            // that is bottomY > middleY > topY.
-            final int verticalMargin = applyDimensionSp(FRAME_BORDER_GAP_SP, mDm);
-            final int topY = verticalMargin;
-            final int bottomY = getHeight() - verticalMargin;
-            final int middleY = (topY + bottomY) / 2;
-
-            // Note: horizontal coordinates in Canvas go from left to right,
-            // that is rightX > leftX.
-            final int leftX = 0;
-            final int rightX = getWidth();
-
-            // Draw the frame, which includes 3 lines that show the maximum,
-            // minimum and middle positions of the graph.
-            canvas.drawLine(leftX, topY, rightX, topY, mFramePaint);
-            canvas.drawLine(leftX, middleY, rightX, middleY, mFramePaint);
-            canvas.drawLine(leftX, bottomY, rightX, bottomY, mFramePaint);
-
-            // Draw the position that each frame line corresponds to.
-            final int frameTextOffset = applyDimensionSp(FRAME_TEXT_OFFSET_SP, mDm);
-            canvas.drawText(
-                    String.format(mDefaultLocale, "%.1f",
-                            mFrameCenterPosition + mFrameCenterToBorderDistance),
-                    leftX,
-                    topY - frameTextOffset, mFrameTextPaint
-            );
-            canvas.drawText(
-                    String.format(mDefaultLocale, "%.1f", mFrameCenterPosition),
-                    leftX,
-                    middleY - frameTextOffset, mFrameTextPaint
-            );
-            canvas.drawText(
-                    String.format(mDefaultLocale, "%.1f",
-                            mFrameCenterPosition - mFrameCenterToBorderDistance),
-                    leftX,
-                    bottomY - frameTextOffset, mFrameTextPaint
-            );
-
-            // If there are no graph values to be drawn, stop here.
-            if (mGraphValues.getSize() == 0) {
-                return;
-            }
-
-            // Draw the graph using the times and positions.
-            // We start at the most recent value (which should be drawn at the right) and move
-            // to the older values (which should be drawn to the left of more recent ones). Negative
-            // indices are handled by circuling back to the end of the buffer.
-            final long mostRecentTime = mGraphValues.getLast().mTime;
-            float prevCoordX = 0;
-            float prevCoordY = 0;
-            float prevAge = 0;
-            for (Iterator<GraphValue> iter = mGraphValues.reverseIterator(); iter.hasNext();) {
-                final GraphValue value = iter.next();
-
-                final int age = (int) (mostRecentTime - value.mTime);
-                final float pos = value.mPos;
-
-                // We get the horizontal coordinate in time units from left to right with
-                // (MAX_SHOWN_TIME_INTERVAL - age). Then, we rescale it to match the canvas
-                // units by dividing it by the time-domain length (MAX_SHOWN_TIME_INTERVAL)
-                // and by multiplying it by the canvas length (rightX - leftX). Finally, we
-                // offset the coordinate by adding it to leftX.
-                final float coordX = leftX + ((float) (MAX_SHOWN_TIME_INTERVAL - age)
-                        / MAX_SHOWN_TIME_INTERVAL) * (rightX - leftX);
-
-                // We get the vertical coordinate in position units from middle to top with
-                // (pos - mFrameCenterPosition). Then, we rescale it to match the canvas
-                // units by dividing it by half of the position-domain length
-                // (mFrameCenterToBorderDistance) and by multiplying it by half of the canvas
-                // length (middleY - topY). Finally, we offset the coordinate by subtracting
-                // it from middleY (we can't "add" here because the coordinate grows from top
-                // to bottom).
-                final float coordY = middleY - ((pos - mFrameCenterPosition)
-                        / mFrameCenterToBorderDistance) * (middleY - topY);
-
-                // Draw a point for this value.
-                canvas.drawPoint(coordX, coordY, mGraphPointPaint);
-
-                // If this value is part of the same gesture as the previous one, draw a line
-                // between them. We ignore the first value (with age = 0).
-                if (age != 0 && (age - prevAge) <= MAX_GESTURE_TIME) {
-                    canvas.drawLine(prevCoordX, prevCoordY, coordX, coordY, mGraphLinePaint);
-                }
-
-                prevCoordX = coordX;
-                prevCoordY = coordY;
-                prevAge = age;
-            }
-        }
-
-        @VisibleForTesting
-        float getFrameCenterPosition() {
-            return mFrameCenterPosition;
-        }
-
-        /**
-         * Holds data needed to draw each entry in the graph.
-         */
-        private static class GraphValue {
-            /** Position. */
-            float mPos;
-            /** Time when this value was added. */
-            long mTime;
-
-            GraphValue(float pos, long time) {
-                this.mPos = pos;
-                this.mTime = time;
-            }
-        }
-
-        /**
-         * Holds the graph values as a cyclic buffer. It has a fixed capacity, and it replaces the
-         * old values with new ones to avoid creating new objects.
-         */
-        private static class CyclicBuffer {
-            private final GraphValue[] mValues;
-            private final int mCapacity;
-            private int mSize = 0;
-            private int mLastIndex = 0;
-
-            // The iteration index and counter are here to make it easier to reset them.
-            /** Determines the value currently pointed by the iterator. */
-            private int mIteratorIndex;
-            /** Counts how many values have been iterated through. */
-            private int mIteratorCount;
-
-            /** Used traverse the values in reverse order. */
-            private final Iterator<GraphValue> mReverseIterator = new Iterator<GraphValue>() {
-                @Override
-                public boolean hasNext() {
-                    return mIteratorCount <= mSize;
-                }
-
-                @Override
-                public GraphValue next() {
-                    // Returns the value currently pointed by the iterator and moves the iterator to
-                    // the previous one.
-                    mIteratorCount++;
-                    return mValues[(mIteratorIndex-- + mCapacity) % mCapacity];
-                }
-            };
-
-            CyclicBuffer(int capacity) {
-                mCapacity = capacity;
-                mValues = new GraphValue[capacity];
-            }
-
-            /**
-             * Add new graph value. If there is an existing object, we replace its data with the
-             * new one. With this, we re-use old objects instead of creating new ones.
-             */
-            void add(float pos, long time) {
-                mLastIndex = (mLastIndex + 1) % mCapacity;
-                if (mValues[mLastIndex] == null) {
-                    mValues[mLastIndex] = new GraphValue(pos, time);
-                } else {
-                    final GraphValue oldValue = mValues[mLastIndex];
-                    oldValue.mPos = pos;
-                    oldValue.mTime = time;
-                }
-
-                // If needed, account for new value in the buffer size.
-                if (mSize != mCapacity) {
-                    mSize++;
-                }
-            }
-
-            int getSize() {
-                return mSize;
-            }
-
-            GraphValue getFirst() {
-                final int distanceBetweenLastAndFirst = (mCapacity - mSize) + 1;
-                final int firstIndex = (mLastIndex + distanceBetweenLastAndFirst) % mCapacity;
-                return mValues[firstIndex];
-            }
-
-            GraphValue getLast() {
-                return mValues[mLastIndex];
-            }
-
-            void removeFirst() {
-                mSize--;
-            }
-
-            /** Returns an iterator pointing at the last value. */
-            Iterator<GraphValue> reverseIterator() {
-                mIteratorIndex = mLastIndex;
-                mIteratorCount = 1;
-                return mReverseIterator;
-            }
-        }
-    }
-}
diff --git a/services/core/java/com/android/server/input/InputManagerService.java b/services/core/java/com/android/server/input/InputManagerService.java
index b8e9d5d..cc972c2 100644
--- a/services/core/java/com/android/server/input/InputManagerService.java
+++ b/services/core/java/com/android/server/input/InputManagerService.java
@@ -116,6 +116,7 @@
 import com.android.server.LocalServices;
 import com.android.server.Watchdog;
 import com.android.server.input.InputManagerInternal.LidSwitchCallback;
+import com.android.server.input.debug.FocusEventDebugView;
 import com.android.server.inputmethod.InputMethodManagerInternal;
 import com.android.server.policy.WindowManagerPolicy;
 
diff --git a/services/core/java/com/android/server/input/FocusEventDebugGlobalMonitor.java b/services/core/java/com/android/server/input/debug/FocusEventDebugGlobalMonitor.java
similarity index 94%
rename from services/core/java/com/android/server/input/FocusEventDebugGlobalMonitor.java
rename to services/core/java/com/android/server/input/debug/FocusEventDebugGlobalMonitor.java
index 67c221f..2b21e49 100644
--- a/services/core/java/com/android/server/input/FocusEventDebugGlobalMonitor.java
+++ b/services/core/java/com/android/server/input/debug/FocusEventDebugGlobalMonitor.java
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.android.server.input;
+package com.android.server.input.debug;
 
 import android.view.Display;
 import android.view.InputEvent;
@@ -22,6 +22,7 @@
 import android.view.MotionEvent;
 
 import com.android.server.UiThread;
+import com.android.server.input.InputManagerService;
 
 /**
  * Receives input events before they are dispatched and reports them to FocusEventDebugView.
diff --git a/services/core/java/com/android/server/input/debug/FocusEventDebugView.java b/services/core/java/com/android/server/input/debug/FocusEventDebugView.java
new file mode 100644
index 0000000..6eec0de
--- /dev/null
+++ b/services/core/java/com/android/server/input/debug/FocusEventDebugView.java
@@ -0,0 +1,466 @@
+/*
+ * Copyright 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.server.input.debug;
+
+import static android.util.TypedValue.COMPLEX_UNIT_DIP;
+import static android.util.TypedValue.COMPLEX_UNIT_SP;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+
+import android.animation.LayoutTransition;
+import android.annotation.AnyThread;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.ColorMatrixColorFilter;
+import android.graphics.Typeface;
+import android.util.DisplayMetrics;
+import android.util.Pair;
+import android.util.Slog;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.RoundedCorner;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.animation.AccelerateInterpolator;
+import android.widget.HorizontalScrollView;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.internal.R;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.input.InputManagerService;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ *  Displays focus events, such as physical keyboard KeyEvents and non-pointer MotionEvents on
+ *  the screen.
+ */
+public class FocusEventDebugView extends RelativeLayout {
+
+    private static final String TAG = FocusEventDebugView.class.getSimpleName();
+
+    private static final int KEY_FADEOUT_DURATION_MILLIS = 1000;
+    private static final int KEY_TRANSITION_DURATION_MILLIS = 100;
+
+    private static final int OUTER_PADDING_DP = 16;
+    private static final int KEY_SEPARATION_MARGIN_DP = 16;
+    private static final int KEY_VIEW_SIDE_PADDING_DP = 16;
+    private static final int KEY_VIEW_VERTICAL_PADDING_DP = 8;
+    private static final int KEY_VIEW_MIN_WIDTH_DP = 32;
+    private static final int KEY_VIEW_TEXT_SIZE_SP = 12;
+    private static final double ROTATY_GRAPH_HEIGHT_FRACTION = 0.5;
+
+    private final InputManagerService mService;
+    private final int mOuterPadding;
+    private final DisplayMetrics mDm;
+
+    // Tracks all keys that are currently pressed/down.
+    private final Map<Pair<Integer /*deviceId*/, Integer /*scanCode*/>, PressedKeyView>
+            mPressedKeys = new HashMap<>();
+
+    @Nullable
+    private FocusEventDebugGlobalMonitor mFocusEventDebugGlobalMonitor;
+    @Nullable
+    private PressedKeyContainer mPressedKeyContainer;
+    @Nullable
+    private PressedKeyContainer mPressedModifierContainer;
+    private final Supplier<RotaryInputValueView> mRotaryInputValueViewFactory;
+    @Nullable
+    private RotaryInputValueView mRotaryInputValueView;
+    private final Supplier<RotaryInputGraphView> mRotaryInputGraphViewFactory;
+    @Nullable
+    private RotaryInputGraphView mRotaryInputGraphView;
+
+    @VisibleForTesting
+    FocusEventDebugView(Context c, InputManagerService service,
+            Supplier<RotaryInputValueView> rotaryInputValueViewFactory,
+            Supplier<RotaryInputGraphView> rotaryInputGraphViewFactory) {
+        super(c);
+        setFocusableInTouchMode(true);
+
+        mService = service;
+        mRotaryInputValueViewFactory = rotaryInputValueViewFactory;
+        mRotaryInputGraphViewFactory = rotaryInputGraphViewFactory;
+        mDm = mContext.getResources().getDisplayMetrics();
+        mOuterPadding = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, OUTER_PADDING_DP, mDm);
+    }
+
+    public FocusEventDebugView(Context c, InputManagerService service) {
+        this(c, service, () -> new RotaryInputValueView(c), () -> new RotaryInputGraphView(c));
+    }
+
+    @Override
+    public WindowInsets onApplyWindowInsets(WindowInsets insets) {
+        int paddingBottom = 0;
+
+        final RoundedCorner bottomLeft =
+                insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT);
+        if (bottomLeft != null && !insets.isRound()) {
+            paddingBottom = bottomLeft.getRadius();
+        }
+
+        final RoundedCorner bottomRight =
+                insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT);
+        if (bottomRight != null && !insets.isRound()) {
+            paddingBottom = Math.max(paddingBottom, bottomRight.getRadius());
+        }
+
+        if (insets.getDisplayCutout() != null) {
+            paddingBottom =
+                    Math.max(paddingBottom, insets.getDisplayCutout().getSafeInsetBottom());
+        }
+
+        setPadding(mOuterPadding, mOuterPadding, mOuterPadding, mOuterPadding + paddingBottom);
+        setClipToPadding(false);
+        invalidate();
+        return super.onApplyWindowInsets(insets);
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        handleKeyEvent(event);
+        return super.dispatchKeyEvent(event);
+    }
+
+    /** Determines whether to show the key presses visualization. */
+    @AnyThread
+    public void updateShowKeyPresses(boolean enabled) {
+        post(() -> handleUpdateShowKeyPresses(enabled));
+    }
+
+    /** Determines whether to show the rotary input visualization. */
+    @AnyThread
+    public void updateShowRotaryInput(boolean enabled) {
+        post(() -> handleUpdateShowRotaryInput(enabled));
+    }
+
+    private void handleUpdateShowKeyPresses(boolean enabled) {
+        if (enabled == showKeyPresses()) {
+            return;
+        }
+
+        if (!enabled) {
+            removeView(mPressedKeyContainer);
+            mPressedKeyContainer = null;
+            removeView(mPressedModifierContainer);
+            mPressedModifierContainer = null;
+            return;
+        }
+
+        mPressedKeyContainer = new PressedKeyContainer(mContext);
+        mPressedKeyContainer.setOrientation(LinearLayout.HORIZONTAL);
+        mPressedKeyContainer.setGravity(Gravity.RIGHT | Gravity.BOTTOM);
+        mPressedKeyContainer.setLayoutDirection(LAYOUT_DIRECTION_LTR);
+        final var scroller = new HorizontalScrollView(mContext);
+        scroller.addView(mPressedKeyContainer);
+        scroller.setHorizontalScrollBarEnabled(false);
+        scroller.addOnLayoutChangeListener(
+                (view, l, t, r, b, ol, ot, or, ob) -> scroller.fullScroll(View.FOCUS_RIGHT));
+        scroller.setHorizontalFadingEdgeEnabled(true);
+        LayoutParams scrollerLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+        scrollerLayoutParams.addRule(ALIGN_PARENT_BOTTOM);
+        scrollerLayoutParams.addRule(ALIGN_PARENT_RIGHT);
+        addView(scroller, scrollerLayoutParams);
+
+        mPressedModifierContainer = new PressedKeyContainer(mContext);
+        mPressedModifierContainer.setOrientation(LinearLayout.VERTICAL);
+        mPressedModifierContainer.setGravity(Gravity.LEFT | Gravity.BOTTOM);
+        LayoutParams modifierLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+        modifierLayoutParams.addRule(ALIGN_PARENT_BOTTOM);
+        modifierLayoutParams.addRule(ALIGN_PARENT_LEFT);
+        modifierLayoutParams.addRule(LEFT_OF, scroller.getId());
+        addView(mPressedModifierContainer, modifierLayoutParams);
+    }
+
+    @VisibleForTesting
+    void handleUpdateShowRotaryInput(boolean enabled) {
+        if (enabled == showRotaryInput()) {
+            return;
+        }
+
+        if (!enabled) {
+            mFocusEventDebugGlobalMonitor.dispose();
+            mFocusEventDebugGlobalMonitor = null;
+            removeView(mRotaryInputValueView);
+            mRotaryInputValueView = null;
+            removeView(mRotaryInputGraphView);
+            mRotaryInputGraphView = null;
+            return;
+        }
+
+        mFocusEventDebugGlobalMonitor = new FocusEventDebugGlobalMonitor(this, mService);
+
+        mRotaryInputValueView = mRotaryInputValueViewFactory.get();
+        LayoutParams valueLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+        valueLayoutParams.addRule(CENTER_HORIZONTAL);
+        valueLayoutParams.addRule(ALIGN_PARENT_BOTTOM);
+        addView(mRotaryInputValueView, valueLayoutParams);
+
+        mRotaryInputGraphView = mRotaryInputGraphViewFactory.get();
+        LayoutParams graphLayoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
+                (int) (ROTATY_GRAPH_HEIGHT_FRACTION * mDm.heightPixels));
+        graphLayoutParams.addRule(CENTER_IN_PARENT);
+        addView(mRotaryInputGraphView, graphLayoutParams);
+    }
+
+    /** Report a key event to the debug view. */
+    @AnyThread
+    public void reportKeyEvent(KeyEvent event) {
+        post(() -> handleKeyEvent(KeyEvent.obtain((KeyEvent) event)));
+    }
+
+    /** Report a motion event to the debug view. */
+    @AnyThread
+    public void reportMotionEvent(MotionEvent event) {
+        if (event.getSource() != InputDevice.SOURCE_ROTARY_ENCODER) {
+            return;
+        }
+
+        post(() -> handleRotaryInput(MotionEvent.obtain((MotionEvent) event)));
+    }
+
+    private void handleKeyEvent(KeyEvent keyEvent) {
+        if (!showKeyPresses()) {
+            return;
+        }
+
+        final var identifier = new Pair<>(keyEvent.getDeviceId(), keyEvent.getScanCode());
+        final var container = KeyEvent.isModifierKey(keyEvent.getKeyCode())
+                ? mPressedModifierContainer
+                : mPressedKeyContainer;
+        PressedKeyView pressedKeyView = mPressedKeys.get(identifier);
+        switch (keyEvent.getAction()) {
+            case KeyEvent.ACTION_DOWN: {
+                if (pressedKeyView != null) {
+                    if (keyEvent.getRepeatCount() == 0) {
+                        Slog.w(TAG, "Got key down for "
+                                + KeyEvent.keyCodeToString(keyEvent.getKeyCode())
+                                + " that was already tracked as being down.");
+                        break;
+                    }
+                    container.handleKeyRepeat(pressedKeyView);
+                    break;
+                }
+
+                pressedKeyView = new PressedKeyView(mContext, getLabel(keyEvent));
+                mPressedKeys.put(identifier, pressedKeyView);
+                container.handleKeyPressed(pressedKeyView);
+                break;
+            }
+            case KeyEvent.ACTION_UP: {
+                if (pressedKeyView == null) {
+                    Slog.w(TAG, "Got key up for " + KeyEvent.keyCodeToString(keyEvent.getKeyCode())
+                            + " that was not tracked as being down.");
+                    break;
+                }
+                mPressedKeys.remove(identifier);
+                container.handleKeyRelease(pressedKeyView);
+                break;
+            }
+            default:
+                break;
+        }
+        keyEvent.recycle();
+    }
+
+    @VisibleForTesting
+    void handleRotaryInput(MotionEvent motionEvent) {
+        if (!showRotaryInput()) {
+            return;
+        }
+
+        float scrollAxisValue = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
+        mRotaryInputValueView.updateValue(scrollAxisValue);
+        mRotaryInputGraphView.addValue(scrollAxisValue, motionEvent.getEventTime());
+
+        motionEvent.recycle();
+    }
+
+    private static String getLabel(KeyEvent event) {
+        switch (event.getKeyCode()) {
+            case KeyEvent.KEYCODE_SPACE:
+                return "\u2423";
+            case KeyEvent.KEYCODE_TAB:
+                return "\u21e5";
+            case KeyEvent.KEYCODE_ENTER:
+            case KeyEvent.KEYCODE_NUMPAD_ENTER:
+                return "\u23CE";
+            case KeyEvent.KEYCODE_DEL:
+                return "\u232B";
+            case KeyEvent.KEYCODE_FORWARD_DEL:
+                return "\u2326";
+            case KeyEvent.KEYCODE_ESCAPE:
+                return "ESC";
+            case KeyEvent.KEYCODE_DPAD_UP:
+                return "\u2191";
+            case KeyEvent.KEYCODE_DPAD_DOWN:
+                return "\u2193";
+            case KeyEvent.KEYCODE_DPAD_LEFT:
+                return "\u2190";
+            case KeyEvent.KEYCODE_DPAD_RIGHT:
+                return "\u2192";
+            case KeyEvent.KEYCODE_DPAD_UP_RIGHT:
+                return "\u2197";
+            case KeyEvent.KEYCODE_DPAD_UP_LEFT:
+                return "\u2196";
+            case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT:
+                return "\u2198";
+            case KeyEvent.KEYCODE_DPAD_DOWN_LEFT:
+                return "\u2199";
+            default:
+                break;
+        }
+
+        final int unicodeChar = event.getUnicodeChar();
+        if (unicodeChar != 0) {
+            return new String(Character.toChars(unicodeChar));
+        }
+
+        final var label = KeyEvent.keyCodeToString(event.getKeyCode());
+        if (label.startsWith("KEYCODE_")) {
+            return label.substring(8);
+        }
+        return label;
+    }
+
+    /** Determine whether to show key presses by checking one of the key-related objects. */
+    private boolean showKeyPresses() {
+        return mPressedKeyContainer != null;
+    }
+
+    /** Determine whether to show rotary input by checking one of the rotary-related objects. */
+    private boolean showRotaryInput() {
+        return mRotaryInputValueView != null;
+    }
+
+    private static class PressedKeyView extends TextView {
+
+        private static final ColorFilter sInvertColors = new ColorMatrixColorFilter(new float[]{
+                -1.0f,     0,     0,    0, 255, // red
+                0, -1.0f,     0,    0, 255, // green
+                0,     0, -1.0f,    0, 255, // blue
+                0,     0,     0, 1.0f, 0    // alpha
+        });
+
+        PressedKeyView(Context c, String label) {
+            super(c);
+
+            final var dm = c.getResources().getDisplayMetrics();
+            final int keyViewSidePadding =
+                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_SIDE_PADDING_DP, dm);
+            final int keyViewVerticalPadding =
+                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_VERTICAL_PADDING_DP,
+                            dm);
+            final int keyViewMinWidth =
+                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_MIN_WIDTH_DP, dm);
+            final int textSize =
+                    (int) TypedValue.applyDimension(COMPLEX_UNIT_SP, KEY_VIEW_TEXT_SIZE_SP, dm);
+
+            setText(label);
+            setGravity(Gravity.CENTER);
+            setMinimumWidth(keyViewMinWidth);
+            setTextSize(textSize);
+            setTypeface(Typeface.SANS_SERIF);
+            setBackgroundResource(R.drawable.focus_event_pressed_key_background);
+            setPaddingRelative(keyViewSidePadding, keyViewVerticalPadding, keyViewSidePadding,
+                    keyViewVerticalPadding);
+
+            setHighlighted(true);
+        }
+
+        void setHighlighted(boolean isHighlighted) {
+            if (isHighlighted) {
+                setTextColor(Color.BLACK);
+                getBackground().setColorFilter(sInvertColors);
+            } else {
+                setTextColor(Color.WHITE);
+                getBackground().clearColorFilter();
+            }
+            invalidate();
+        }
+    }
+
+    private static class PressedKeyContainer extends LinearLayout {
+
+        private final MarginLayoutParams mPressedKeyLayoutParams;
+
+        PressedKeyContainer(Context c) {
+            super(c);
+
+            final var dm = c.getResources().getDisplayMetrics();
+            final int keySeparationMargin =
+                    (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_SEPARATION_MARGIN_DP, dm);
+
+            final var transition = new LayoutTransition();
+            transition.disableTransitionType(LayoutTransition.APPEARING);
+            transition.disableTransitionType(LayoutTransition.DISAPPEARING);
+            transition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING);
+            transition.setDuration(KEY_TRANSITION_DURATION_MILLIS);
+            setLayoutTransition(transition);
+
+            mPressedKeyLayoutParams = new MarginLayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+            if (getOrientation() == VERTICAL) {
+                mPressedKeyLayoutParams.setMargins(0, keySeparationMargin, 0, 0);
+            } else {
+                mPressedKeyLayoutParams.setMargins(keySeparationMargin, 0, 0, 0);
+            }
+        }
+
+        public void handleKeyPressed(PressedKeyView pressedKeyView) {
+            addView(pressedKeyView, getChildCount(), mPressedKeyLayoutParams);
+            invalidate();
+        }
+
+        public void handleKeyRepeat(PressedKeyView repeatedKeyView) {
+            // Do nothing for now.
+        }
+
+        public void handleKeyRelease(PressedKeyView releasedKeyView) {
+            releasedKeyView.setHighlighted(false);
+            releasedKeyView.clearAnimation();
+            releasedKeyView.animate()
+                    .alpha(0)
+                    .setDuration(KEY_FADEOUT_DURATION_MILLIS)
+                    .setInterpolator(new AccelerateInterpolator())
+                    .withEndAction(this::cleanUpPressedKeyViews)
+                    .start();
+        }
+
+        private void cleanUpPressedKeyViews() {
+            int numChildrenToRemove = 0;
+            for (int i = 0; i < getChildCount(); i++) {
+                final View child = getChildAt(i);
+                if (child.getAlpha() != 0) {
+                    break;
+                }
+                child.setVisibility(View.GONE);
+                child.clearAnimation();
+                numChildrenToRemove++;
+            }
+            removeViews(0, numChildrenToRemove);
+            invalidate();
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/input/debug/RotaryInputGraphView.java b/services/core/java/com/android/server/input/debug/RotaryInputGraphView.java
new file mode 100644
index 0000000..6a9fe2f
--- /dev/null
+++ b/services/core/java/com/android/server/input/debug/RotaryInputGraphView.java
@@ -0,0 +1,345 @@
+/*
+ * Copyright 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.server.input.debug;
+
+import static android.util.TypedValue.COMPLEX_UNIT_SP;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Shows a graph with the rotary input values as a function of time.
+ * The graph gets reset if no action is received for a certain amount of time.
+ */
+class RotaryInputGraphView extends View {
+
+    private static final int FRAME_COLOR = 0xbf741b47;
+    private static final int FRAME_WIDTH_SP = 2;
+    private static final int FRAME_BORDER_GAP_SP = 10;
+    private static final int FRAME_TEXT_SIZE_SP = 10;
+    private static final int FRAME_TEXT_OFFSET_SP = 2;
+    private static final int GRAPH_COLOR = 0xffff00ff;
+    private static final int GRAPH_LINE_WIDTH_SP = 1;
+    private static final int GRAPH_POINT_RADIUS_SP = 4;
+    private static final long MAX_SHOWN_TIME_INTERVAL = TimeUnit.SECONDS.toMillis(5);
+    private static final float DEFAULT_FRAME_CENTER_POSITION = 0;
+    private static final int MAX_GRAPH_VALUES_SIZE = 400;
+    /** Maximum time between values so that they are considered part of the same gesture. */
+    private static final long MAX_GESTURE_TIME = TimeUnit.SECONDS.toMillis(1);
+
+    private final DisplayMetrics mDm;
+    /**
+     * Distance in position units (amount scrolled in display pixels) from the center to the
+     * top/bottom frame lines.
+     */
+    private final float mFrameCenterToBorderDistance;
+    private final float mScaledVerticalScrollFactor;
+    private final Locale mDefaultLocale = Locale.getDefault();
+    private final Paint mFramePaint = new Paint();
+    private final Paint mFrameTextPaint = new Paint();
+    private final Paint mGraphLinePaint = new Paint();
+    private final Paint mGraphPointPaint = new Paint();
+
+    private final CyclicBuffer mGraphValues = new CyclicBuffer(MAX_GRAPH_VALUES_SIZE);
+    /** Position at which graph values are placed at the center of the graph. */
+    private float mFrameCenterPosition = DEFAULT_FRAME_CENTER_POSITION;
+
+    RotaryInputGraphView(Context c) {
+        super(c);
+
+        mDm = mContext.getResources().getDisplayMetrics();
+        // This makes the center-to-border distance equivalent to the display height, meaning
+        // that the total height of the graph is equivalent to 2x the display height.
+        mFrameCenterToBorderDistance = mDm.heightPixels;
+        mScaledVerticalScrollFactor = ViewConfiguration.get(c).getScaledVerticalScrollFactor();
+
+        mFramePaint.setColor(FRAME_COLOR);
+        mFramePaint.setStrokeWidth(applyDimensionSp(FRAME_WIDTH_SP, mDm));
+
+        mFrameTextPaint.setColor(GRAPH_COLOR);
+        mFrameTextPaint.setTextSize(applyDimensionSp(FRAME_TEXT_SIZE_SP, mDm));
+
+        mGraphLinePaint.setColor(GRAPH_COLOR);
+        mGraphLinePaint.setStrokeWidth(applyDimensionSp(GRAPH_LINE_WIDTH_SP, mDm));
+        mGraphLinePaint.setStrokeCap(Paint.Cap.ROUND);
+        mGraphLinePaint.setStrokeJoin(Paint.Join.ROUND);
+
+        mGraphPointPaint.setColor(GRAPH_COLOR);
+        mGraphPointPaint.setStrokeWidth(applyDimensionSp(GRAPH_POINT_RADIUS_SP, mDm));
+        mGraphPointPaint.setStrokeCap(Paint.Cap.ROUND);
+        mGraphPointPaint.setStrokeJoin(Paint.Join.ROUND);
+    }
+
+    /**
+     * Reads new scroll axis value and updates the list accordingly. Old positions are
+     * kept at the front (what you would get with getFirst), while the recent positions are
+     * kept at the back (what you would get with getLast). Also updates the frame center
+     * position to handle out-of-bounds cases.
+     */
+    void addValue(float scrollAxisValue, long eventTime) {
+        // Remove values that are too old.
+        while (mGraphValues.getSize() > 0
+                && (eventTime - mGraphValues.getFirst().mTime) > MAX_SHOWN_TIME_INTERVAL) {
+            mGraphValues.removeFirst();
+        }
+
+        // If there are no recent values, reset the frame center.
+        if (mGraphValues.getSize() == 0) {
+            mFrameCenterPosition = DEFAULT_FRAME_CENTER_POSITION;
+        }
+
+        // Handle new value. We multiply the scroll axis value by the scaled scroll factor to
+        // get the amount of pixels to be scrolled. We also compute the accumulated position
+        // by adding the current value to the last one (if not empty).
+        final float displacement = scrollAxisValue * mScaledVerticalScrollFactor;
+        final float prevPos = (mGraphValues.getSize() == 0 ? 0 : mGraphValues.getLast().mPos);
+        final float pos = prevPos + displacement;
+
+        mGraphValues.add(pos, eventTime);
+
+        // The difference between the distance of the most recent position from the center
+        // frame (pos - mFrameCenterPosition) and the maximum allowed distance from the center
+        // frame (mFrameCenterToBorderDistance).
+        final float verticalDiff = Math.abs(pos - mFrameCenterPosition)
+                - mFrameCenterToBorderDistance;
+        // If needed, translate frame.
+        if (verticalDiff > 0) {
+            final int sign = pos - mFrameCenterPosition < 0 ? -1 : 1;
+            // Here, we update the center frame position by the exact amount needed for us to
+            // stay within the maximum allowed distance from the center frame.
+            mFrameCenterPosition += sign * verticalDiff;
+        }
+
+        // Redraw canvas.
+        invalidate();
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas) {
+        super.onDraw(canvas);
+
+        // Note: vertical coordinates in Canvas go from top to bottom,
+        // that is bottomY > middleY > topY.
+        final int verticalMargin = applyDimensionSp(FRAME_BORDER_GAP_SP, mDm);
+        final int topY = verticalMargin;
+        final int bottomY = getHeight() - verticalMargin;
+        final int middleY = (topY + bottomY) / 2;
+
+        // Note: horizontal coordinates in Canvas go from left to right,
+        // that is rightX > leftX.
+        final int leftX = 0;
+        final int rightX = getWidth();
+
+        // Draw the frame, which includes 3 lines that show the maximum,
+        // minimum and middle positions of the graph.
+        canvas.drawLine(leftX, topY, rightX, topY, mFramePaint);
+        canvas.drawLine(leftX, middleY, rightX, middleY, mFramePaint);
+        canvas.drawLine(leftX, bottomY, rightX, bottomY, mFramePaint);
+
+        // Draw the position that each frame line corresponds to.
+        final int frameTextOffset = applyDimensionSp(FRAME_TEXT_OFFSET_SP, mDm);
+        canvas.drawText(
+                String.format(mDefaultLocale, "%.1f",
+                        mFrameCenterPosition + mFrameCenterToBorderDistance),
+                leftX,
+                topY - frameTextOffset, mFrameTextPaint
+        );
+        canvas.drawText(
+                String.format(mDefaultLocale, "%.1f", mFrameCenterPosition),
+                leftX,
+                middleY - frameTextOffset, mFrameTextPaint
+        );
+        canvas.drawText(
+                String.format(mDefaultLocale, "%.1f",
+                        mFrameCenterPosition - mFrameCenterToBorderDistance),
+                leftX,
+                bottomY - frameTextOffset, mFrameTextPaint
+        );
+
+        // If there are no graph values to be drawn, stop here.
+        if (mGraphValues.getSize() == 0) {
+            return;
+        }
+
+        // Draw the graph using the times and positions.
+        // We start at the most recent value (which should be drawn at the right) and move
+        // to the older values (which should be drawn to the left of more recent ones). Negative
+        // indices are handled by circuling back to the end of the buffer.
+        final long mostRecentTime = mGraphValues.getLast().mTime;
+        float prevCoordX = 0;
+        float prevCoordY = 0;
+        float prevAge = 0;
+        for (Iterator<GraphValue> iter = mGraphValues.reverseIterator(); iter.hasNext();) {
+            final GraphValue value = iter.next();
+
+            final int age = (int) (mostRecentTime - value.mTime);
+            final float pos = value.mPos;
+
+            // We get the horizontal coordinate in time units from left to right with
+            // (MAX_SHOWN_TIME_INTERVAL - age). Then, we rescale it to match the canvas
+            // units by dividing it by the time-domain length (MAX_SHOWN_TIME_INTERVAL)
+            // and by multiplying it by the canvas length (rightX - leftX). Finally, we
+            // offset the coordinate by adding it to leftX.
+            final float coordX = leftX + ((float) (MAX_SHOWN_TIME_INTERVAL - age)
+                    / MAX_SHOWN_TIME_INTERVAL) * (rightX - leftX);
+
+            // We get the vertical coordinate in position units from middle to top with
+            // (pos - mFrameCenterPosition). Then, we rescale it to match the canvas
+            // units by dividing it by half of the position-domain length
+            // (mFrameCenterToBorderDistance) and by multiplying it by half of the canvas
+            // length (middleY - topY). Finally, we offset the coordinate by subtracting
+            // it from middleY (we can't "add" here because the coordinate grows from top
+            // to bottom).
+            final float coordY = middleY - ((pos - mFrameCenterPosition)
+                    / mFrameCenterToBorderDistance) * (middleY - topY);
+
+            // Draw a point for this value.
+            canvas.drawPoint(coordX, coordY, mGraphPointPaint);
+
+            // If this value is part of the same gesture as the previous one, draw a line
+            // between them. We ignore the first value (with age = 0).
+            if (age != 0 && (age - prevAge) <= MAX_GESTURE_TIME) {
+                canvas.drawLine(prevCoordX, prevCoordY, coordX, coordY, mGraphLinePaint);
+            }
+
+            prevCoordX = coordX;
+            prevCoordY = coordY;
+            prevAge = age;
+        }
+    }
+
+    @VisibleForTesting
+    float getFrameCenterPosition() {
+        return mFrameCenterPosition;
+    }
+
+    /**
+     * Converts a dimension in scaled pixel units to integer display pixels.
+     */
+    private static int applyDimensionSp(int dimensionSp, DisplayMetrics dm) {
+        return (int) TypedValue.applyDimension(COMPLEX_UNIT_SP, dimensionSp, dm);
+    }
+
+    /**
+     * Holds data needed to draw each entry in the graph.
+     */
+    private static class GraphValue {
+        /** Position. */
+        float mPos;
+        /** Time when this value was added. */
+        long mTime;
+
+        GraphValue(float pos, long time) {
+            this.mPos = pos;
+            this.mTime = time;
+        }
+    }
+
+    /**
+     * Holds the graph values as a cyclic buffer. It has a fixed capacity, and it replaces the
+     * old values with new ones to avoid creating new objects.
+     */
+    private static class CyclicBuffer {
+        private final GraphValue[] mValues;
+        private final int mCapacity;
+        private int mSize = 0;
+        private int mLastIndex = 0;
+
+        // The iteration index and counter are here to make it easier to reset them.
+        /** Determines the value currently pointed by the iterator. */
+        private int mIteratorIndex;
+        /** Counts how many values have been iterated through. */
+        private int mIteratorCount;
+
+        /** Used traverse the values in reverse order. */
+        private final Iterator<GraphValue> mReverseIterator = new Iterator<GraphValue>() {
+            @Override
+            public boolean hasNext() {
+                return mIteratorCount <= mSize;
+            }
+
+            @Override
+            public GraphValue next() {
+                // Returns the value currently pointed by the iterator and moves the iterator to
+                // the previous one.
+                mIteratorCount++;
+                return mValues[(mIteratorIndex-- + mCapacity) % mCapacity];
+            }
+        };
+
+        CyclicBuffer(int capacity) {
+            mCapacity = capacity;
+            mValues = new GraphValue[capacity];
+        }
+
+        /**
+         * Add new graph value. If there is an existing object, we replace its data with the
+         * new one. With this, we re-use old objects instead of creating new ones.
+         */
+        void add(float pos, long time) {
+            mLastIndex = (mLastIndex + 1) % mCapacity;
+            if (mValues[mLastIndex] == null) {
+                mValues[mLastIndex] = new GraphValue(pos, time);
+            } else {
+                final GraphValue oldValue = mValues[mLastIndex];
+                oldValue.mPos = pos;
+                oldValue.mTime = time;
+            }
+
+            // If needed, account for new value in the buffer size.
+            if (mSize != mCapacity) {
+                mSize++;
+            }
+        }
+
+        int getSize() {
+            return mSize;
+        }
+
+        GraphValue getFirst() {
+            final int distanceBetweenLastAndFirst = (mCapacity - mSize) + 1;
+            final int firstIndex = (mLastIndex + distanceBetweenLastAndFirst) % mCapacity;
+            return mValues[firstIndex];
+        }
+
+        GraphValue getLast() {
+            return mValues[mLastIndex];
+        }
+
+        void removeFirst() {
+            mSize--;
+        }
+
+        /** Returns an iterator pointing at the last value. */
+        Iterator<GraphValue> reverseIterator() {
+            mIteratorIndex = mLastIndex;
+            mIteratorCount = 1;
+            return mReverseIterator;
+        }
+    }
+}
diff --git a/services/core/java/com/android/server/input/debug/RotaryInputValueView.java b/services/core/java/com/android/server/input/debug/RotaryInputValueView.java
new file mode 100644
index 0000000..38613eb
--- /dev/null
+++ b/services/core/java/com/android/server/input/debug/RotaryInputValueView.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 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.server.input.debug;
+
+import static android.util.TypedValue.COMPLEX_UNIT_SP;
+
+import android.content.Context;
+import android.graphics.ColorFilter;
+import android.graphics.ColorMatrixColorFilter;
+import android.graphics.Typeface;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+import android.view.ViewConfiguration;
+import android.widget.TextView;
+
+import com.android.internal.R;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Locale;
+
+/** Draws the most recent rotary input value and indicates whether the source is active. */
+class RotaryInputValueView extends TextView {
+
+    private static final int INACTIVE_TEXT_COLOR = 0xffff00ff;
+    private static final int ACTIVE_TEXT_COLOR = 0xff420f28;
+    private static final int TEXT_SIZE_SP = 8;
+    private static final int SIDE_PADDING_SP = 4;
+    /** Determines how long the active status lasts. */
+    private static final int ACTIVE_STATUS_DURATION = 250 /* milliseconds */;
+    private static final ColorFilter ACTIVE_BACKGROUND_FILTER =
+            new ColorMatrixColorFilter(new float[]{
+                    0, 0, 0, 0, 255, // red
+                    0, 0, 0, 0,   0, // green
+                    0, 0, 0, 0, 255, // blue
+                    0, 0, 0, 0, 200  // alpha
+            });
+
+    private final Runnable mUpdateActivityStatusCallback = () -> updateActivityStatus(false);
+    private final float mScaledVerticalScrollFactor;
+    private final Locale mDefaultLocale = Locale.getDefault();
+
+    RotaryInputValueView(Context c) {
+        super(c);
+
+        DisplayMetrics dm = mContext.getResources().getDisplayMetrics();
+        mScaledVerticalScrollFactor = ViewConfiguration.get(c).getScaledVerticalScrollFactor();
+
+        setText(getFormattedValue(0));
+        setTextColor(INACTIVE_TEXT_COLOR);
+        setTextSize(applyDimensionSp(TEXT_SIZE_SP, dm));
+        setPaddingRelative(applyDimensionSp(SIDE_PADDING_SP, dm), 0,
+                applyDimensionSp(SIDE_PADDING_SP, dm), 0);
+        setTypeface(null, Typeface.BOLD);
+        setBackgroundResource(R.drawable.focus_event_rotary_input_background);
+    }
+
+    /** Updates the shown text with the formatted value. */
+    void updateValue(float value) {
+        removeCallbacks(mUpdateActivityStatusCallback);
+
+        setText(getFormattedValue(value * mScaledVerticalScrollFactor));
+
+        updateActivityStatus(true);
+        postDelayed(mUpdateActivityStatusCallback, ACTIVE_STATUS_DURATION);
+    }
+
+    @VisibleForTesting
+    void updateActivityStatus(boolean active) {
+        if (active) {
+            setTextColor(ACTIVE_TEXT_COLOR);
+            getBackground().setColorFilter(ACTIVE_BACKGROUND_FILTER);
+        } else {
+            setTextColor(INACTIVE_TEXT_COLOR);
+            getBackground().clearColorFilter();
+        }
+    }
+
+    private String getFormattedValue(float value) {
+        return String.format(mDefaultLocale, "%s%.1f", value < 0 ? "-" : "+", Math.abs(value));
+    }
+
+    /**
+     * Converts a dimension in scaled pixel units to integer display pixels.
+     */
+    private static int applyDimensionSp(int dimensionSp, DisplayMetrics dm) {
+        return (int) TypedValue.applyDimension(COMPLEX_UNIT_SP, dimensionSp, dm);
+    }
+}
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 e7bd68e..515c7fb 100644
--- a/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java
+++ b/services/core/java/com/android/server/media/projection/MediaProjectionManagerService.java
@@ -735,12 +735,9 @@
         }
 
         @Override //Binder call
+        @EnforcePermission(MANAGE_MEDIA_PROJECTION)
         public void addCallback(final IMediaProjectionWatcherCallback callback) {
-            if (mContext.checkCallingPermission(MANAGE_MEDIA_PROJECTION)
-                        != PackageManager.PERMISSION_GRANTED) {
-                throw new SecurityException("Requires MANAGE_MEDIA_PROJECTION in order to add "
-                        + "projection callbacks");
-            }
+            addCallback_enforcePermission();
             final long token = Binder.clearCallingIdentity();
             try {
                 MediaProjectionManagerService.this.addCallback(callback);
diff --git a/services/core/java/com/android/server/power/stats/BatteryStatsImpl.java b/services/core/java/com/android/server/power/stats/BatteryStatsImpl.java
index cf4e845..5b10afa 100644
--- a/services/core/java/com/android/server/power/stats/BatteryStatsImpl.java
+++ b/services/core/java/com/android/server/power/stats/BatteryStatsImpl.java
@@ -281,6 +281,7 @@
     private final LongSparseArray<SamplingTimer> mKernelMemoryStats = new LongSparseArray<>();
     private int[] mCpuPowerBracketMap;
     private final CpuUsageDetails mCpuUsageDetails = new CpuUsageDetails();
+    private final CpuPowerStatsCollector mCpuPowerStatsCollector;
 
     public LongSparseArray<SamplingTimer> getKernelMemoryStats() {
         return mKernelMemoryStats;
@@ -439,6 +440,7 @@
         static final int RESET_ON_UNPLUG_AFTER_SIGNIFICANT_CHARGE_FLAG = 1 << 1;
 
         private final int mFlags;
+        private final long mPowerStatsThrottlePeriodCpu;
 
         private BatteryStatsConfig(Builder builder) {
             int flags = 0;
@@ -449,6 +451,7 @@
                 flags |= RESET_ON_UNPLUG_AFTER_SIGNIFICANT_CHARGE_FLAG;
             }
             mFlags = flags;
+            mPowerStatsThrottlePeriodCpu = builder.mPowerStatsThrottlePeriodCpu;
         }
 
         /**
@@ -469,15 +472,22 @@
                     == RESET_ON_UNPLUG_AFTER_SIGNIFICANT_CHARGE_FLAG;
         }
 
+        long getPowerStatsThrottlePeriodCpu() {
+            return mPowerStatsThrottlePeriodCpu;
+        }
+
         /**
          * Builder for BatteryStatsConfig
          */
         public static class Builder {
             private boolean mResetOnUnplugHighBatteryLevel;
             private boolean mResetOnUnplugAfterSignificantCharge;
+            private long mPowerStatsThrottlePeriodCpu;
+
             public Builder() {
                 mResetOnUnplugHighBatteryLevel = true;
                 mResetOnUnplugAfterSignificantCharge = true;
+                mPowerStatsThrottlePeriodCpu = 60000;
             }
 
             /**
@@ -504,8 +514,16 @@
                 mResetOnUnplugAfterSignificantCharge = reset;
                 return this;
             }
-        }
 
+            /**
+             * Sets the minimum amount of time (in millis) to wait between passes
+             * of CPU power stats collection.
+             */
+            public Builder setPowerStatsThrottlePeriodCpu(long periodMs) {
+                mPowerStatsThrottlePeriodCpu = periodMs;
+                return this;
+            }
+        }
     }
 
     private final PlatformIdleStateCallback mPlatformIdleStateCallback;
@@ -1556,7 +1574,7 @@
 
     @VisibleForTesting
     @GuardedBy("this")
-    protected BatteryStatsConfig mBatteryStatsConfig = new BatteryStatsConfig.Builder().build();
+    protected BatteryStatsConfig mBatteryStatsConfig;
 
     @GuardedBy("this")
     private AlarmManager mAlarmManager = null;
@@ -1733,6 +1751,7 @@
 
     public BatteryStatsImpl(Clock clock, File historyDirectory) {
         init(clock);
+        mBatteryStatsConfig = new BatteryStatsConfig.Builder().build();
         mHandler = null;
         mConstants = new Constants(mHandler);
         mStartClockTimeMs = clock.currentTimeMillis();
@@ -1751,6 +1770,7 @@
         mPlatformIdleStateCallback = null;
         mEnergyConsumerRetriever = null;
         mUserInfoProvider = null;
+        mCpuPowerStatsCollector = null;
     }
 
     private void init(Clock clock) {
@@ -10911,21 +10931,23 @@
         return mTmpCpuTimeInFreq;
     }
 
-    public BatteryStatsImpl(@Nullable File systemDir, @NonNull Handler handler,
-            @Nullable PlatformIdleStateCallback cb, @Nullable EnergyStatsRetriever energyStatsCb,
-            @NonNull UserInfoProvider userInfoProvider, @NonNull PowerProfile powerProfile,
-            @NonNull CpuScalingPolicies cpuScalingPolicies) {
-        this(Clock.SYSTEM_CLOCK, systemDir, handler, cb, energyStatsCb, userInfoProvider,
-                powerProfile, cpuScalingPolicies);
-    }
-
-    private BatteryStatsImpl(@NonNull Clock clock, @Nullable File systemDir,
+    public BatteryStatsImpl(@NonNull BatteryStatsConfig config, @Nullable File systemDir,
             @NonNull Handler handler, @Nullable PlatformIdleStateCallback cb,
             @Nullable EnergyStatsRetriever energyStatsCb,
             @NonNull UserInfoProvider userInfoProvider, @NonNull PowerProfile powerProfile,
             @NonNull CpuScalingPolicies cpuScalingPolicies) {
+        this(config, Clock.SYSTEM_CLOCK, systemDir, handler, cb, energyStatsCb, userInfoProvider,
+                powerProfile, cpuScalingPolicies);
+    }
+
+    private BatteryStatsImpl(@NonNull BatteryStatsConfig config, @NonNull Clock clock,
+            @Nullable File systemDir, @NonNull Handler handler,
+            @Nullable PlatformIdleStateCallback cb, @Nullable EnergyStatsRetriever energyStatsCb,
+            @NonNull UserInfoProvider userInfoProvider, @NonNull PowerProfile powerProfile,
+            @NonNull CpuScalingPolicies cpuScalingPolicies) {
         init(clock);
 
+        mBatteryStatsConfig = config;
         mHandler = new MyHandler(handler.getLooper());
         mConstants = new Constants(mHandler);
 
@@ -10947,6 +10969,10 @@
             mHistory = new BatteryStatsHistory(systemDir, mConstants.MAX_HISTORY_FILES,
                     mConstants.MAX_HISTORY_BUFFER, mStepDetailsCalculator, mClock);
         }
+
+        mCpuPowerStatsCollector = new CpuPowerStatsCollector(mCpuScalingPolicies, mPowerProfile,
+                mHandler, mBatteryStatsConfig.getPowerStatsThrottlePeriodCpu());
+
         mStartCount++;
         initTimersAndCounters();
         mOnBattery = mOnBatteryInternal = false;
@@ -11095,15 +11121,6 @@
     }
 
     /**
-     * Injects BatteryStatsConfig
-     */
-    public void setBatteryStatsConfig(BatteryStatsConfig config) {
-        synchronized (this) {
-            mBatteryStatsConfig = config;
-        }
-    }
-
-    /**
      * Starts tracking CPU time-in-state for threads of the system server process,
      * keeping a separate account of threads receiving incoming binder calls.
      */
@@ -14197,6 +14214,9 @@
         if (mCpuUidFreqTimeReader != null) {
             mCpuUidFreqTimeReader.onSystemReady();
         }
+        if (mCpuPowerStatsCollector != null) {
+            mCpuPowerStatsCollector.onSystemReady();
+        }
         mSystemReady = true;
     }
 
@@ -15705,6 +15725,13 @@
         iPw.decreaseIndent();
     }
 
+    /**
+     * Grabs one sample of PowerStats and prints it.
+     */
+    public void dumpStatsSample(PrintWriter pw) {
+        mCpuPowerStatsCollector.collectAndDump(pw);
+    }
+
     private final Runnable mWriteAsyncRunnable = () -> {
         synchronized (BatteryStatsImpl.this) {
             writeSyncLocked();
diff --git a/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java b/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java
new file mode 100644
index 0000000..1401746
--- /dev/null
+++ b/services/core/java/com/android/server/power/stats/CpuPowerStatsCollector.java
@@ -0,0 +1,134 @@
+/*
+ * 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.server.power.stats;
+
+import android.os.Handler;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.Keep;
+import com.android.internal.annotations.VisibleForNative;
+import com.android.internal.os.Clock;
+import com.android.internal.os.CpuScalingPolicies;
+import com.android.internal.os.PowerProfile;
+import com.android.internal.os.PowerStats;
+import com.android.server.power.optimization.Flags;
+
+/**
+ * Collects snapshots of power-related system statistics.
+ * <p>
+ * The class is intended to be used in a serialized fashion using the handler supplied in the
+ * constructor. Thus the object is not thread-safe except where noted.
+ */
+public class CpuPowerStatsCollector extends PowerStatsCollector {
+    private static final long NANOS_PER_MILLIS = 1000000;
+
+    private final KernelCpuStatsReader mKernelCpuStatsReader;
+    private final int[] mScalingStepToPowerBracketMap;
+    private final long[] mTempUidStats;
+    private final SparseArray<UidStats> mUidStats = new SparseArray<>();
+    private final int mUidStatsSize;
+    // Reusable instance
+    private final PowerStats mCpuPowerStats = new PowerStats();
+    private long mLastUpdateTimestampNanos;
+
+    public CpuPowerStatsCollector(CpuScalingPolicies cpuScalingPolicies, PowerProfile powerProfile,
+                                  Handler handler, long throttlePeriodMs) {
+        this(cpuScalingPolicies, powerProfile, handler, new KernelCpuStatsReader(),
+                throttlePeriodMs, Clock.SYSTEM_CLOCK);
+    }
+
+    public CpuPowerStatsCollector(CpuScalingPolicies cpuScalingPolicies, PowerProfile powerProfile,
+                                  Handler handler, KernelCpuStatsReader kernelCpuStatsReader,
+                                  long throttlePeriodMs, Clock clock) {
+        super(handler, throttlePeriodMs, clock);
+        mKernelCpuStatsReader = kernelCpuStatsReader;
+
+        int scalingStepCount = cpuScalingPolicies.getScalingStepCount();
+        mScalingStepToPowerBracketMap = new int[scalingStepCount];
+        int index = 0;
+        for (int policy : cpuScalingPolicies.getPolicies()) {
+            int[] frequencies = cpuScalingPolicies.getFrequencies(policy);
+            for (int step = 0; step < frequencies.length; step++) {
+                int bracket = powerProfile.getCpuPowerBracketForScalingStep(policy, step);
+                mScalingStepToPowerBracketMap[index++] = bracket;
+            }
+        }
+        mUidStatsSize = powerProfile.getCpuPowerBracketCount();
+        mTempUidStats = new long[mUidStatsSize];
+    }
+
+    /**
+     * Initializes the collector during the boot sequence.
+     */
+    public void onSystemReady() {
+        setEnabled(Flags.streamlinedBatteryStats());
+    }
+
+    @Override
+    protected PowerStats collectStats() {
+        mCpuPowerStats.uidStats.clear();
+        long newTimestampNanos = mKernelCpuStatsReader.nativeReadCpuStats(
+                this::processUidStats, mScalingStepToPowerBracketMap, mLastUpdateTimestampNanos,
+                mTempUidStats);
+        mCpuPowerStats.durationMs =
+                (newTimestampNanos - mLastUpdateTimestampNanos) / NANOS_PER_MILLIS;
+        mLastUpdateTimestampNanos = newTimestampNanos;
+        return mCpuPowerStats;
+    }
+
+    @VisibleForNative
+    interface KernelCpuStatsCallback {
+        @Keep // Called from native
+        void processUidStats(int uid, long[] stats);
+    }
+
+    private void processUidStats(int uid, long[] stats) {
+        UidStats uidStats = mUidStats.get(uid);
+        if (uidStats == null) {
+            uidStats = new UidStats();
+            uidStats.stats = new long[mUidStatsSize];
+            uidStats.delta = new long[mUidStatsSize];
+            mUidStats.put(uid, uidStats);
+        }
+
+        boolean nonzero = false;
+        for (int i = mUidStatsSize - 1; i >= 0; i--) {
+            long delta = uidStats.delta[i] = stats[i] - uidStats.stats[i];
+            if (delta != 0) {
+                nonzero = true;
+            }
+            uidStats.stats[i] = stats[i];
+        }
+        if (nonzero) {
+            mCpuPowerStats.uidStats.put(uid, uidStats.delta);
+        }
+    }
+
+    /**
+     * Native class that retrieves CPU stats from the kernel.
+     */
+    public static class KernelCpuStatsReader {
+        protected native long nativeReadCpuStats(KernelCpuStatsCallback callback,
+                int[] scalingStepToPowerBracketMap, long lastUpdateTimestampNanos,
+                long[] tempForUidStats);
+    }
+
+    private static class UidStats {
+        public long[] stats;
+        public long[] delta;
+    }
+}
diff --git a/services/core/java/com/android/server/power/stats/PowerStatsCollector.java b/services/core/java/com/android/server/power/stats/PowerStatsCollector.java
new file mode 100644
index 0000000..b49c89f
--- /dev/null
+++ b/services/core/java/com/android/server/power/stats/PowerStatsCollector.java
@@ -0,0 +1,183 @@
+/*
+ * 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.server.power.stats;
+
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.util.FastImmutableArraySet;
+import android.util.IndentingPrintWriter;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.os.Clock;
+import com.android.internal.os.PowerStats;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+/**
+ * Collects snapshots of power-related system statistics.
+ * <p>
+ * Instances of this class are intended to be used in a serialized fashion using
+ * the handler supplied in the constructor. Thus these objects are not thread-safe
+ * except where noted.
+ */
+public abstract class PowerStatsCollector {
+    private final Handler mHandler;
+    private final Clock mClock;
+    private final long mThrottlePeriodMs;
+    private final Runnable mCollectAndDeliverStats = this::collectAndDeliverStats;
+    private boolean mEnabled;
+    private long mLastScheduledUpdateMs = -1;
+
+    @GuardedBy("this")
+    @SuppressWarnings("unchecked")
+    private volatile FastImmutableArraySet<Consumer<PowerStats>> mConsumerList =
+            new FastImmutableArraySet<Consumer<PowerStats>>(new Consumer[0]);
+
+    public PowerStatsCollector(Handler handler, long throttlePeriodMs, Clock clock) {
+        mHandler = handler;
+        mThrottlePeriodMs = throttlePeriodMs;
+        mClock = clock;
+    }
+
+    /**
+     * Adds a consumer that will receive a callback every time a snapshot of stats is collected.
+     * The method is thread safe.
+     */
+    @SuppressWarnings("unchecked")
+    public void addConsumer(Consumer<PowerStats> consumer) {
+        synchronized (this) {
+            mConsumerList = new FastImmutableArraySet<Consumer<PowerStats>>(
+                    Stream.concat(mConsumerList.stream(), Stream.of(consumer))
+                            .toArray(Consumer[]::new));
+        }
+    }
+
+    /**
+     * Removes a consumer.
+     * The method is thread safe.
+     */
+    @SuppressWarnings("unchecked")
+    public void removeConsumer(Consumer<PowerStats> consumer) {
+        synchronized (this) {
+            mConsumerList = new FastImmutableArraySet<Consumer<PowerStats>>(
+                    mConsumerList.stream().filter(c -> c != consumer)
+                            .toArray(Consumer[]::new));
+        }
+    }
+
+    /**
+     * Should be called at most once, before the first invocation of {@link #schedule} or
+     * {@link #forceSchedule}
+     */
+    public void setEnabled(boolean enabled) {
+        mEnabled = enabled;
+    }
+
+    /**
+     * Returns true if the collector is enabled.
+     */
+    public boolean isEnabled() {
+        return mEnabled;
+    }
+
+    @SuppressWarnings("GuardedBy")  // Field is volatile
+    private void collectAndDeliverStats() {
+        PowerStats stats = collectStats();
+        for (Consumer<PowerStats> consumer : mConsumerList) {
+            consumer.accept(stats);
+        }
+    }
+
+    /**
+     * Schedules a stats snapshot collection, throttled in accordance with the
+     * {@link #mThrottlePeriodMs} parameter.
+     */
+    public boolean schedule() {
+        if (!mEnabled) {
+            return false;
+        }
+
+        long uptimeMillis = mClock.uptimeMillis();
+        if (uptimeMillis - mLastScheduledUpdateMs < mThrottlePeriodMs
+                && mLastScheduledUpdateMs >= 0) {
+            return false;
+        }
+        mLastScheduledUpdateMs = uptimeMillis;
+        mHandler.post(mCollectAndDeliverStats);
+        return true;
+    }
+
+    /**
+     * Schedules an immediate snapshot collection, foregoing throttling.
+     */
+    public boolean forceSchedule() {
+        if (!mEnabled) {
+            return false;
+        }
+
+        mHandler.removeCallbacks(mCollectAndDeliverStats);
+        mHandler.postAtFrontOfQueue(mCollectAndDeliverStats);
+        return true;
+    }
+
+    protected abstract PowerStats collectStats();
+
+    /**
+     * Collects a fresh stats snapshot and prints it to the supplied printer.
+     */
+    public void collectAndDump(PrintWriter pw) {
+        if (Thread.currentThread() == mHandler.getLooper().getThread()) {
+            throw new RuntimeException(
+                    "Calling this method from the handler thread would cause a deadlock");
+        }
+
+        IndentingPrintWriter out = new IndentingPrintWriter(pw);
+        out.print(getClass().getSimpleName());
+        if (!isEnabled()) {
+            out.println(": disabled");
+            return;
+        }
+        out.println();
+
+        ArrayList<PowerStats> collected = new ArrayList<>();
+        Consumer<PowerStats> consumer = collected::add;
+        addConsumer(consumer);
+
+        try {
+            if (forceSchedule()) {
+                awaitCompletion();
+            }
+        } finally {
+            removeConsumer(consumer);
+        }
+
+        out.increaseIndent();
+        for (PowerStats stats : collected) {
+            stats.dump(out);
+        }
+        out.decreaseIndent();
+    }
+
+    private void awaitCompletion() {
+        ConditionVariable done = new ConditionVariable();
+        mHandler.post(done::open);
+        done.block();
+    }
+}
diff --git a/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java b/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java
index ddc0519..aaf48fb 100644
--- a/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java
+++ b/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java
@@ -2000,12 +2000,7 @@
             WallpaperData wallpaper, IRemoteCallback reply, ServiceInfo serviceInfo) {
 
         if (serviceInfo == null) {
-            if (wallpaper.mWhich == (FLAG_LOCK | FLAG_SYSTEM)) {
-                clearWallpaperLocked(FLAG_SYSTEM, wallpaper.userId, null);
-                clearWallpaperLocked(FLAG_LOCK, wallpaper.userId, reply);
-            } else {
-                clearWallpaperLocked(wallpaper.mWhich, wallpaper.userId, reply);
-            }
+            clearWallpaperLocked(wallpaper.mWhich, wallpaper.userId, reply);
             return;
         }
         Slog.w(TAG, "Wallpaper isn't direct boot aware; using fallback until unlocked");
@@ -2037,7 +2032,7 @@
         WallpaperData data = null;
         synchronized (mLock) {
             if (mIsLockscreenLiveWallpaperEnabled) {
-                clearWallpaperLocked(callingPackage, which, userId);
+                clearWallpaperLocked(callingPackage, which, userId, null);
             } else {
                 clearWallpaperLocked(which, userId, null);
             }
@@ -2057,7 +2052,8 @@
         }
     }
 
-    private void clearWallpaperLocked(String callingPackage, int which, int userId) {
+    private void clearWallpaperLocked(String callingPackage, int which, int userId,
+            IRemoteCallback reply) {
 
         // Might need to bring it in the first time to establish our rewrite
         if (!mWallpaperMap.contains(userId)) {
@@ -2111,8 +2107,14 @@
         withCleanCallingIdentity(() -> clearWallpaperComponentLocked(wallpaper));
     }
 
-    // TODO(b/266818039) remove this version of the method
     private void clearWallpaperLocked(int which, int userId, IRemoteCallback reply) {
+
+        if (mIsLockscreenLiveWallpaperEnabled) {
+            String callingPackage = mPackageManagerInternal.getNameForUid(getCallingUid());
+            clearWallpaperLocked(callingPackage, which, userId, reply);
+            return;
+        }
+
         if (which != FLAG_SYSTEM && which != FLAG_LOCK) {
             throw new IllegalArgumentException("Must specify exactly one kind of wallpaper to clear");
         }
@@ -3284,15 +3286,21 @@
     boolean setWallpaperComponent(ComponentName name, String callingPackage,
             @SetWallpaperFlags int which, int userId) {
         if (mIsLockscreenLiveWallpaperEnabled) {
-            return setWallpaperComponentInternal(name, callingPackage, which, userId);
+            return setWallpaperComponentInternal(name, callingPackage, which, userId, null);
         } else {
             setWallpaperComponentInternalLegacy(name, callingPackage, which, userId);
             return true;
         }
     }
 
+    private boolean setWallpaperComponent(ComponentName name, @SetWallpaperFlags int which,
+            int userId) {
+        String callingPackage = mPackageManagerInternal.getNameForUid(getCallingUid());
+        return setWallpaperComponentInternal(name, callingPackage, which, userId, null);
+    }
+
     private boolean setWallpaperComponentInternal(ComponentName name, String callingPackage,
-            @SetWallpaperFlags int which, int userIdIn) {
+            @SetWallpaperFlags int which, int userIdIn, IRemoteCallback reply) {
         if (DEBUG) {
             Slog.v(TAG, "Setting new live wallpaper: which=" + which + ", component: " + name);
         }
@@ -3341,6 +3349,7 @@
                             Slog.d(TAG, "publish system wallpaper changed!");
                         }
                         liveSync.complete();
+                        if (reply != null) reply.sendResult(null);
                     }
                 };
 
diff --git a/services/core/java/com/android/server/wm/ActivityRecord.java b/services/core/java/com/android/server/wm/ActivityRecord.java
index cba215a..64c7c6f 100644
--- a/services/core/java/com/android/server/wm/ActivityRecord.java
+++ b/services/core/java/com/android/server/wm/ActivityRecord.java
@@ -4381,7 +4381,6 @@
         // Reset the last saved PiP snap fraction on removal.
         mDisplayContent.mPinnedTaskController.onActivityHidden(mActivityComponent);
         mDisplayContent.onRunningActivityChanged();
-        mWmService.mEmbeddedWindowController.onActivityRemoved(this);
         mRemovingFromDisplay = false;
     }
 
diff --git a/services/core/java/com/android/server/wm/AsyncRotationController.java b/services/core/java/com/android/server/wm/AsyncRotationController.java
index 2eceecc..0250475 100644
--- a/services/core/java/com/android/server/wm/AsyncRotationController.java
+++ b/services/core/java/com/android/server/wm/AsyncRotationController.java
@@ -172,10 +172,9 @@
                 if (recents != null && recents.isNavigationBarAttachedToApp()) {
                     return;
                 }
-            } else if (navigationBarCanMove || mTransitionOp == OP_CHANGE_MAY_SEAMLESS) {
+            } else if (navigationBarCanMove || mTransitionOp == OP_CHANGE_MAY_SEAMLESS
+                    || mDisplayContent.mTransitionController.mNavigationBarAttachedToApp) {
                 action = Operation.ACTION_SEAMLESS;
-            } else if (mDisplayContent.mTransitionController.mNavigationBarAttachedToApp) {
-                return;
             }
             mTargetWindowTokens.put(w.mToken, new Operation(action));
             return;
@@ -294,6 +293,11 @@
             finishOp(mTargetWindowTokens.keyAt(i));
         }
         mTargetWindowTokens.clear();
+        onAllCompleted();
+    }
+
+    private void onAllCompleted() {
+        if (DEBUG) Slog.d(TAG, "onAllCompleted");
         if (mTimeoutRunnable != null) {
             mService.mH.removeCallbacks(mTimeoutRunnable);
         }
@@ -333,7 +337,7 @@
             if (DEBUG) Slog.d(TAG, "Complete directly " + token.getTopChild());
             finishOp(token);
             if (mTargetWindowTokens.isEmpty()) {
-                if (mTimeoutRunnable != null) mService.mH.removeCallbacks(mTimeoutRunnable);
+                onAllCompleted();
                 return true;
             }
         }
@@ -411,14 +415,18 @@
         if (mDisplayContent.mInputMethodWindow == null) return;
         final WindowToken imeWindowToken = mDisplayContent.mInputMethodWindow.mToken;
         if (isTargetToken(imeWindowToken)) return;
+        hideImmediately(imeWindowToken, Operation.ACTION_TOGGLE_IME);
+        if (DEBUG) Slog.d(TAG, "hideImeImmediately " + imeWindowToken.getTopChild());
+    }
+
+    private void hideImmediately(WindowToken token, @Operation.Action int action) {
         final boolean original = mHideImmediately;
         mHideImmediately = true;
-        final Operation op = new Operation(Operation.ACTION_TOGGLE_IME);
-        mTargetWindowTokens.put(imeWindowToken, op);
-        fadeWindowToken(false /* show */, imeWindowToken, ANIMATION_TYPE_TOKEN_TRANSFORM);
-        op.mLeash = imeWindowToken.getAnimationLeash();
+        final Operation op = new Operation(action);
+        mTargetWindowTokens.put(token, op);
+        fadeWindowToken(false /* show */, token, ANIMATION_TYPE_TOKEN_TRANSFORM);
+        op.mLeash = token.getAnimationLeash();
         mHideImmediately = original;
-        if (DEBUG) Slog.d(TAG, "hideImeImmediately " + imeWindowToken.getTopChild());
     }
 
     /** Returns {@code true} if the window will rotate independently. */
@@ -428,11 +436,20 @@
                 || isTargetToken(w.mToken);
     }
 
-    /** Returns {@code true} if the controller will run fade animations on the window. */
+    /**
+     * Returns {@code true} if the rotation transition appearance of the window is currently
+     * managed by this controller.
+     */
     boolean isTargetToken(WindowToken token) {
         return mTargetWindowTokens.containsKey(token);
     }
 
+    /** Returns {@code true} if the controller will run fade animations on the window. */
+    boolean hasFadeOperation(WindowToken token) {
+        final Operation op = mTargetWindowTokens.get(token);
+        return op != null && op.mAction == Operation.ACTION_FADE;
+    }
+
     /**
      * Whether the insets animation leash should use previous position when running fade animation
      * or seamless transformation in a rotated display.
@@ -564,7 +581,18 @@
             return false;
         }
         final Operation op = mTargetWindowTokens.get(w.mToken);
-        if (op == null) return false;
+        if (op == null) {
+            // If a window becomes visible after the rotation transition is requested but before
+            // the transition is ready, hide it by an animation leash so it won't be flickering
+            // by drawing the rotated content before applying projection transaction of display.
+            // And it will fade in after the display transition is finished.
+            if (mTransitionOp == OP_APP_SWITCH && !mIsStartTransactionCommitted
+                    && canBeAsync(w.mToken)) {
+                hideImmediately(w.mToken, Operation.ACTION_FADE);
+                if (DEBUG) Slog.d(TAG, "Hide on finishDrawing " + w.mToken.getTopChild());
+            }
+            return false;
+        }
         if (DEBUG) Slog.d(TAG, "handleFinishDrawing " + w);
         if (postDrawTransaction == null || !mIsSyncDrawRequested
                 || canDrawBeforeStartTransaction(op)) {
diff --git a/services/core/java/com/android/server/wm/EmbeddedWindowController.java b/services/core/java/com/android/server/wm/EmbeddedWindowController.java
index 98027bb..c9bae12 100644
--- a/services/core/java/com/android/server/wm/EmbeddedWindowController.java
+++ b/services/core/java/com/android/server/wm/EmbeddedWindowController.java
@@ -135,19 +135,6 @@
         return mWindowsByWindowToken.get(windowToken);
     }
 
-    void onActivityRemoved(ActivityRecord activityRecord) {
-        for (int i = mWindows.size() - 1; i >= 0; i--) {
-            final EmbeddedWindow window = mWindows.valueAt(i);
-            if (window.mHostActivityRecord == activityRecord) {
-                final WindowProcessController processController =
-                        mAtmService.getProcessController(window.mOwnerPid, window.mOwnerUid);
-                if (processController != null) {
-                    processController.removeHostActivity(activityRecord);
-                }
-            }
-        }
-    }
-
     static class EmbeddedWindow implements InputTarget {
         final IWindow mClient;
         @Nullable final WindowState mHostWindowState;
@@ -230,6 +217,13 @@
                 mInputChannel.dispose();
                 mInputChannel = null;
             }
+            if (mHostActivityRecord != null) {
+                final WindowProcessController wpc =
+                        mWmService.mAtmService.getProcessController(mOwnerPid, mOwnerUid);
+                if (wpc != null) {
+                    wpc.removeHostActivity(mHostActivityRecord);
+                }
+            }
         }
 
         @Override
diff --git a/services/core/java/com/android/server/wm/NavBarFadeAnimationController.java b/services/core/java/com/android/server/wm/NavBarFadeAnimationController.java
index 2e5474e..79b26d2 100644
--- a/services/core/java/com/android/server/wm/NavBarFadeAnimationController.java
+++ b/services/core/java/com/android/server/wm/NavBarFadeAnimationController.java
@@ -86,7 +86,7 @@
                 ANIMATION_TYPE_TOKEN_TRANSFORM);
         if (controller == null) {
             fadeAnim.run();
-        } else if (!controller.isTargetToken(mNavigationBar.mToken)) {
+        } else if (!controller.hasFadeOperation(mNavigationBar.mToken)) {
             // If fade rotation animation is running and the nav bar is not controlled by it:
             // - For fade-in animation, defer the animation until fade rotation animation finishes.
             // - For fade-out animation, just play the animation.
diff --git a/services/core/java/com/android/server/wm/Transition.java b/services/core/java/com/android/server/wm/Transition.java
index 1566bb2c..e9af42b 100644
--- a/services/core/java/com/android/server/wm/Transition.java
+++ b/services/core/java/com/android/server/wm/Transition.java
@@ -1930,11 +1930,6 @@
             break;
         }
 
-        final AsyncRotationController asyncRotationController = dc.getAsyncRotationController();
-        if (asyncRotationController != null) {
-            asyncRotationController.accept(navWindow);
-        }
-
         if (animate) {
             final NavBarFadeAnimationController controller =
                     new NavBarFadeAnimationController(dc);
diff --git a/services/core/jni/Android.bp b/services/core/jni/Android.bp
index 405b133..2f150a1 100644
--- a/services/core/jni/Android.bp
+++ b/services/core/jni/Android.bp
@@ -50,6 +50,7 @@
         "com_android_server_locksettings_SyntheticPasswordManager.cpp",
         "com_android_server_power_PowerManagerService.cpp",
         "com_android_server_powerstats_PowerStatsService.cpp",
+        "com_android_server_power_stats_CpuPowerStatsCollector.cpp",
         "com_android_server_hint_HintManagerService.cpp",
         "com_android_server_SerialService.cpp",
         "com_android_server_soundtrigger_middleware_AudioSessionProviderImpl.cpp",
@@ -144,6 +145,7 @@
         "libsensorservicehidl",
         "libsensorserviceaidl",
         "libgui",
+        "libtimeinstate",
         "libtimestats_atoms_proto",
         "libusbhost",
         "libtinyalsa",
diff --git a/services/core/jni/OWNERS b/services/core/jni/OWNERS
index d9acf41..7e8ce60 100644
--- a/services/core/jni/OWNERS
+++ b/services/core/jni/OWNERS
@@ -23,6 +23,7 @@
 per-file com_android_server_pm_* = file:/services/core/java/com/android/server/pm/OWNERS
 per-file com_android_server_power_* = file:/services/core/java/com/android/server/power/OWNERS
 per-file com_android_server_powerstats_* = file:/services/core/java/com/android/server/powerstats/OWNERS
+per-file com_android_server_power_stats_* = file:/BATTERY_STATS_OWNERS
 per-file com_android_server_security_* = file:/core/java/android/security/OWNERS
 per-file com_android_server_tv_* = file:/media/java/android/media/tv/OWNERS
 per-file com_android_server_vibrator_* = file:/services/core/java/com/android/server/vibrator/OWNERS
diff --git a/services/core/jni/com_android_server_input_InputManagerService.cpp b/services/core/jni/com_android_server_input_InputManagerService.cpp
index 161943a..5ab8d36 100644
--- a/services/core/jni/com_android_server_input_InputManagerService.cpp
+++ b/services/core/jni/com_android_server_input_InputManagerService.cpp
@@ -400,7 +400,7 @@
         PointerCaptureRequest pointerCaptureRequest{};
 
         // Sprite controller singleton, created on first use.
-        sp<SpriteController> spriteController{};
+        std::shared_ptr<SpriteController> spriteController{};
 
         // Pointer controller singleton, created and destroyed as needed.
         std::weak_ptr<PointerController> pointerController{};
@@ -709,7 +709,7 @@
     if (controller == nullptr) {
         ensureSpriteControllerLocked();
 
-        controller = PointerController::create(this, mLooper, mLocked.spriteController);
+        controller = PointerController::create(this, mLooper, *mLocked.spriteController);
         mLocked.pointerController = controller;
         updateInactivityTimeoutLocked();
     }
@@ -738,17 +738,21 @@
 }
 
 void NativeInputManager::ensureSpriteControllerLocked() REQUIRES(mLock) {
-    if (mLocked.spriteController == nullptr) {
-        JNIEnv* env = jniEnv();
-        jint layer = env->CallIntMethod(mServiceObj, gServiceClassInfo.getPointerLayer);
-        if (checkAndClearExceptionFromCallback(env, "getPointerLayer")) {
-            layer = -1;
-        }
-        mLocked.spriteController = new SpriteController(mLooper, layer, [this](int displayId) {
-            return getParentSurfaceForPointers(displayId);
-        });
-        mLocked.spriteController->setHandlerController(mLocked.spriteController);
+    if (mLocked.spriteController) {
+        return;
     }
+    JNIEnv* env = jniEnv();
+    jint layer = env->CallIntMethod(mServiceObj, gServiceClassInfo.getPointerLayer);
+    if (checkAndClearExceptionFromCallback(env, "getPointerLayer")) {
+        layer = -1;
+    }
+    mLocked.spriteController =
+            std::make_shared<SpriteController>(mLooper, layer, [this](int displayId) {
+                return getParentSurfaceForPointers(displayId);
+            });
+    // The SpriteController needs to be shared pointer because the handler callback needs to hold
+    // a weak reference so that we can avoid racy conditions when the controller is being destroyed.
+    mLocked.spriteController->setHandlerController(mLocked.spriteController);
 }
 
 void NativeInputManager::notifyInputDevicesChanged(const std::vector<InputDeviceInfo>& inputDevices) {
diff --git a/services/core/jni/com_android_server_power_stats_CpuPowerStatsCollector.cpp b/services/core/jni/com_android_server_power_stats_CpuPowerStatsCollector.cpp
new file mode 100644
index 0000000..a6084ea
--- /dev/null
+++ b/services/core/jni/com_android_server_power_stats_CpuPowerStatsCollector.cpp
@@ -0,0 +1,135 @@
+/*
+ * 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.
+ */
+
+#define LOG_TAG "CpuPowerStatsCollector"
+
+#include <cputimeinstate.h>
+#include <log/log.h>
+#include <nativehelper/ScopedPrimitiveArray.h>
+
+#include "core_jni_helpers.h"
+
+#define EXCEPTION (-1)
+
+namespace android {
+
+#define JAVA_CLASS_CPU_POWER_STATS_COLLECTOR "com/android/server/power/stats/CpuPowerStatsCollector"
+#define JAVA_CLASS_KERNEL_CPU_STATS_READER \
+    JAVA_CLASS_CPU_POWER_STATS_COLLECTOR "$KernelCpuStatsReader"
+#define JAVA_CLASS_KERNEL_CPU_STATS_CALLBACK \
+    JAVA_CLASS_CPU_POWER_STATS_COLLECTOR "$KernelCpuStatsCallback"
+
+static constexpr uint64_t NSEC_PER_MSEC = 1000000;
+
+static int extractUidStats(JNIEnv *env, std::vector<std::vector<uint64_t>> &times,
+                           ScopedIntArrayRO &scopedScalingStepToPowerBracketMap,
+                           jlongArray tempForUidStats);
+
+static bool initialized = false;
+static jclass class_KernelCpuStatsCallback;
+static jmethodID method_KernelCpuStatsCallback_processUidStats;
+
+static int init(JNIEnv *env) {
+    jclass temp = env->FindClass(JAVA_CLASS_KERNEL_CPU_STATS_CALLBACK);
+    class_KernelCpuStatsCallback = (jclass)env->NewGlobalRef(temp);
+    if (!class_KernelCpuStatsCallback) {
+        jniThrowExceptionFmt(env, "java/lang/ClassNotFoundException",
+                             "Class not found: " JAVA_CLASS_KERNEL_CPU_STATS_CALLBACK);
+        return EXCEPTION;
+    }
+    method_KernelCpuStatsCallback_processUidStats =
+            env->GetMethodID(class_KernelCpuStatsCallback, "processUidStats", "(I[J)V");
+    if (!method_KernelCpuStatsCallback_processUidStats) {
+        jniThrowExceptionFmt(env, "java/lang/NoSuchMethodException",
+                             "Method not found: " JAVA_CLASS_KERNEL_CPU_STATS_CALLBACK
+                             ".processUidStats");
+        return EXCEPTION;
+    }
+    initialized = true;
+    return OK;
+}
+
+static jlong nativeReadCpuStats(JNIEnv *env, [[maybe_unused]] jobject zis, jobject callback,
+                                jintArray scalingStepToPowerBracketMap,
+                                jlong lastUpdateTimestampNanos, jlongArray tempForUidStats) {
+    if (!initialized) {
+        if (init(env) == EXCEPTION) {
+            return 0L;
+        }
+    }
+
+    uint64_t newLastUpdate = lastUpdateTimestampNanos;
+    auto data = android::bpf::getUidsUpdatedCpuFreqTimes(&newLastUpdate);
+    if (!data.has_value()) return lastUpdateTimestampNanos;
+
+    ScopedIntArrayRO scopedScalingStepToPowerBracketMap(env, scalingStepToPowerBracketMap);
+
+    for (auto &[uid, times] : *data) {
+        int status =
+                extractUidStats(env, times, scopedScalingStepToPowerBracketMap, tempForUidStats);
+        if (status == EXCEPTION) {
+            return 0L;
+        }
+        env->CallVoidMethod(callback, method_KernelCpuStatsCallback_processUidStats, (jint)uid,
+                            tempForUidStats);
+    }
+    return newLastUpdate;
+}
+
+static int extractUidStats(JNIEnv *env, std::vector<std::vector<uint64_t>> &times,
+                           ScopedIntArrayRO &scopedScalingStepToPowerBracketMap,
+                           jlongArray tempForUidStats) {
+    ScopedLongArrayRW scopedTempForStats(env, tempForUidStats);
+    uint64_t *arrayForStats = reinterpret_cast<uint64_t *>(scopedTempForStats.get());
+    const uint8_t statsSize = scopedTempForStats.size();
+    memset(arrayForStats, 0, statsSize * sizeof(uint64_t));
+    const uint8_t scalingStepCount = scopedScalingStepToPowerBracketMap.size();
+
+    uint32_t scalingStep = 0;
+    for (const auto &subVec : times) {
+        for (uint32_t i = 0; i < subVec.size(); ++i) {
+            if (scalingStep >= scalingStepCount) {
+                jniThrowExceptionFmt(env, "java/lang/IndexOutOfBoundsException",
+                                     "scalingStepToPowerBracketMap is too short, "
+                                     "size=%u, scalingStep=%u",
+                                     scalingStepCount, scalingStep);
+                return EXCEPTION;
+            }
+            uint32_t bucket = scopedScalingStepToPowerBracketMap[scalingStep];
+            if (bucket >= statsSize) {
+                jniThrowExceptionFmt(env, "java/lang/IndexOutOfBoundsException",
+                                     "UidStats array is too short, length=%u, bucket[%u]=%u",
+                                     statsSize, scalingStep, bucket);
+                return EXCEPTION;
+            }
+            arrayForStats[bucket] += subVec[i] / NSEC_PER_MSEC;
+            scalingStep++;
+        }
+    }
+    return OK;
+}
+
+static const JNINativeMethod method_table[] = {
+        {"nativeReadCpuStats", "(L" JAVA_CLASS_KERNEL_CPU_STATS_CALLBACK ";[IJ[J)J",
+         (void *)nativeReadCpuStats},
+};
+
+int register_android_server_power_stats_CpuPowerStatsCollector(JNIEnv *env) {
+    return jniRegisterNativeMethods(env, JAVA_CLASS_KERNEL_CPU_STATS_READER, method_table,
+                                    NELEM(method_table));
+}
+
+} // namespace android
diff --git a/services/core/jni/onload.cpp b/services/core/jni/onload.cpp
index 290ad8d..97d7be6 100644
--- a/services/core/jni/onload.cpp
+++ b/services/core/jni/onload.cpp
@@ -30,6 +30,7 @@
 int register_android_server_LightsService(JNIEnv* env);
 int register_android_server_PowerManagerService(JNIEnv* env);
 int register_android_server_PowerStatsService(JNIEnv* env);
+int register_android_server_power_stats_CpuPowerStatsCollector(JNIEnv* env);
 int register_android_server_HintManagerService(JNIEnv* env);
 int register_android_server_storage_AppFuse(JNIEnv* env);
 int register_android_server_SerialService(JNIEnv* env);
@@ -85,6 +86,7 @@
     register_android_server_broadcastradio_Tuner(vm, env);
     register_android_server_PowerManagerService(env);
     register_android_server_PowerStatsService(env);
+    register_android_server_power_stats_CpuPowerStatsCollector(env);
     register_android_server_HintManagerService(env);
     register_android_server_SerialService(env);
     register_android_server_InputManager(env);
diff --git a/services/tests/powerstatstests/Android.bp b/services/tests/powerstatstests/Android.bp
index 05acd9b..5ea1929 100644
--- a/services/tests/powerstatstests/Android.bp
+++ b/services/tests/powerstatstests/Android.bp
@@ -23,6 +23,7 @@
         "androidx.test.uiautomator_uiautomator",
         "mockito-target-minus-junit4",
         "servicestests-utils",
+        "flag-junit",
     ],
 
     libs: [
diff --git a/services/tests/powerstatstests/src/com/android/server/power/stats/CpuPowerStatsCollectorTest.java b/services/tests/powerstatstests/src/com/android/server/power/stats/CpuPowerStatsCollectorTest.java
new file mode 100644
index 0000000..f2ee6db
--- /dev/null
+++ b/services/tests/powerstatstests/src/com/android/server/power/stats/CpuPowerStatsCollectorTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.server.power.stats;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.SparseArray;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.internal.os.CpuScalingPolicies;
+import com.android.internal.os.PowerProfile;
+import com.android.internal.os.PowerStats;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class CpuPowerStatsCollectorTest {
+    private final MockClock mMockClock = new MockClock();
+    private final HandlerThread mHandlerThread = new HandlerThread("test");
+    private Handler mHandler;
+    private CpuPowerStatsCollector mCollector;
+    private PowerStats mCollectedStats;
+    @Mock
+    private PowerProfile mPowerProfile;
+    @Mock
+    private CpuPowerStatsCollector.KernelCpuStatsReader mMockKernelCpuStatsReader;
+
+    @Before
+    public void setup() {
+        MockitoAnnotations.initMocks(this);
+
+        mHandlerThread.start();
+        mHandler = mHandlerThread.getThreadHandler();
+        when(mPowerProfile.getCpuPowerBracketCount()).thenReturn(2);
+        when(mPowerProfile.getCpuPowerBracketForScalingStep(0, 0)).thenReturn(0);
+        when(mPowerProfile.getCpuPowerBracketForScalingStep(0, 1)).thenReturn(1);
+        mCollector = new CpuPowerStatsCollector(new CpuScalingPolicies(
+                new SparseArray<>() {{
+                    put(0, new int[]{0});
+                }},
+                new SparseArray<>() {{
+                    put(0, new int[]{1, 12});
+                }}),
+                mPowerProfile, mHandler, mMockKernelCpuStatsReader, 60_000, mMockClock);
+        mCollector.addConsumer(stats -> mCollectedStats = stats);
+        mCollector.setEnabled(true);
+    }
+
+    @Test
+    public void collectStats() {
+        mockKernelCpuStats(new SparseArray<>() {{
+                put(42, new long[]{100, 200});
+                put(99, new long[]{300, 600});
+            }}, 0, 1234);
+
+        mMockClock.uptime = 1000;
+        mCollector.forceSchedule();
+        waitForIdle();
+
+        assertThat(mCollectedStats.durationMs).isEqualTo(1234);
+        assertThat(mCollectedStats.uidStats.get(42)).isEqualTo(new long[]{100, 200});
+        assertThat(mCollectedStats.uidStats.get(99)).isEqualTo(new long[]{300, 600});
+
+        mockKernelCpuStats(new SparseArray<>() {{
+                put(42, new long[]{123, 234});
+                put(99, new long[]{345, 678});
+            }}, 1234, 3421);
+
+        mMockClock.uptime = 2000;
+        mCollector.forceSchedule();
+        waitForIdle();
+
+        assertThat(mCollectedStats.durationMs).isEqualTo(3421 - 1234);
+        assertThat(mCollectedStats.uidStats.get(42)).isEqualTo(new long[]{23, 34});
+        assertThat(mCollectedStats.uidStats.get(99)).isEqualTo(new long[]{45, 78});
+    }
+
+    private void mockKernelCpuStats(SparseArray<long[]> uidToCpuStats,
+            long expectedLastUpdateTimestampMs, long newLastUpdateTimestampMs) {
+        when(mMockKernelCpuStatsReader.nativeReadCpuStats(
+                any(CpuPowerStatsCollector.KernelCpuStatsCallback.class),
+                any(int[].class), anyLong(), any(long[].class)))
+                .thenAnswer(invocation -> {
+                    CpuPowerStatsCollector.KernelCpuStatsCallback callback =
+                            invocation.getArgument(0);
+                    int[] powerBucketIndexes = invocation.getArgument(1);
+                    long lastTimestamp = invocation.getArgument(2);
+                    long[] tempStats = invocation.getArgument(3);
+
+                    assertThat(powerBucketIndexes).isEqualTo(new int[]{0, 1});
+                    assertThat(lastTimestamp / 1000000L).isEqualTo(expectedLastUpdateTimestampMs);
+                    assertThat(tempStats).hasLength(2);
+
+                    for (int i = 0; i < uidToCpuStats.size(); i++) {
+                        int uid = uidToCpuStats.keyAt(i);
+                        long[] cpuStats = uidToCpuStats.valueAt(i);
+                        System.arraycopy(cpuStats, 0, tempStats, 0, tempStats.length);
+                        callback.processUidStats(uid, tempStats);
+                    }
+                    return newLastUpdateTimestampMs * 1000000L; // Nanoseconds
+                });
+    }
+
+    private void waitForIdle() {
+        ConditionVariable done = new ConditionVariable();
+        mHandler.post(done::open);
+        done.block();
+    }
+}
diff --git a/services/tests/powerstatstests/src/com/android/server/power/stats/CpuPowerStatsCollectorValidationTest.java b/services/tests/powerstatstests/src/com/android/server/power/stats/CpuPowerStatsCollectorValidationTest.java
new file mode 100644
index 0000000..38a5d19
--- /dev/null
+++ b/services/tests/powerstatstests/src/com/android/server/power/stats/CpuPowerStatsCollectorValidationTest.java
@@ -0,0 +1,165 @@
+/*
+ * 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.server.power.stats;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static junit.framework.Assert.fail;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
+import android.provider.DeviceConfig;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+import androidx.test.uiautomator.UiDevice;
+
+import com.android.frameworks.coretests.aidl.ICmdCallback;
+import com.android.frameworks.coretests.aidl.ICmdReceiver;
+import com.android.server.power.optimization.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class CpuPowerStatsCollectorValidationTest {
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule =
+            DeviceFlagsValueProvider.createCheckFlagsRule();
+
+    private static final int WORK_DURATION_MS = 2000;
+    private static final String TEST_PKG = "com.android.coretests.apps.bstatstestapp";
+    private static final String TEST_ACTIVITY = TEST_PKG + ".TestActivity";
+    private static final String EXTRA_KEY_CMD_RECEIVER = "cmd_receiver";
+    private static final int START_ACTIVITY_TIMEOUT_MS = 2000;
+
+    private Context mContext;
+    private UiDevice mUiDevice;
+    private DeviceConfig.Properties mBackupFlags;
+    private int mTestPkgUid;
+
+    @Before
+    public void setup() throws Exception {
+        mContext = InstrumentationRegistry.getContext();
+        mUiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
+        mTestPkgUid = mContext.getPackageManager().getPackageUid(TEST_PKG, 0);
+    }
+
+    @Test
+    @RequiresFlagsEnabled(Flags.FLAG_STREAMLINED_BATTERY_STATS)
+    public void totalTimeInPowerBrackets() throws Exception {
+        dumpCpuStats();     // For the side effect of capturing the baseline.
+
+        doSomeWork();
+
+        long duration = 0;
+        long[] stats = null;
+
+        String[] cpuStatsDump = dumpCpuStats();
+        Pattern durationPattern = Pattern.compile("duration=([0-9]*)");
+        Pattern uidPattern = Pattern.compile("UID " + mTestPkgUid + ": \\[([0-9,\\s]*)]");
+        for (String line : cpuStatsDump) {
+            Matcher durationMatcher = durationPattern.matcher(line);
+            if (durationMatcher.find()) {
+                duration = Long.parseLong(durationMatcher.group(1));
+            }
+            Matcher uidMatcher = uidPattern.matcher(line);
+            if (uidMatcher.find()) {
+                String[] strings = uidMatcher.group(1).split(", ");
+                stats = new long[strings.length];
+                for (int i = 0; i < strings.length; i++) {
+                    stats[i] = Long.parseLong(strings[i]);
+                }
+            }
+        }
+        if (stats == null) {
+            fail("No CPU stats for " + mTestPkgUid + " (" + TEST_PKG + ")");
+        }
+
+        assertThat(duration).isAtLeast(WORK_DURATION_MS);
+
+        long total = Arrays.stream(stats).sum();
+        assertThat(total).isAtLeast((long) (WORK_DURATION_MS * 0.8));
+    }
+
+    private String[] dumpCpuStats() throws Exception {
+        String dump = executeCmdSilent("dumpsys batterystats --sample");
+        String[] lines = dump.split("\n");
+        for (int i = 0; i < lines.length; i++) {
+            if (lines[i].startsWith("CpuPowerStatsCollector")) {
+                return Arrays.copyOfRange(lines, i + 1, lines.length);
+            }
+        }
+        return new String[0];
+    }
+
+    private void doSomeWork() throws Exception {
+        final ICmdReceiver receiver;
+        receiver = ICmdReceiver.Stub.asInterface(startActivity());
+        try {
+            receiver.doSomeWork(WORK_DURATION_MS);
+        } finally {
+            receiver.finishHost();
+        }
+    }
+
+    private IBinder startActivity() throws Exception {
+        final CountDownLatch latch = new CountDownLatch(1);
+        final Intent launchIntent = new Intent().setComponent(
+                new ComponentName(TEST_PKG, TEST_ACTIVITY));
+        final Bundle extras = new Bundle();
+        final IBinder[] binders = new IBinder[1];
+        extras.putBinder(EXTRA_KEY_CMD_RECEIVER, new ICmdCallback.Stub() {
+            @Override
+            public void onLaunched(IBinder receiver) {
+                binders[0] = receiver;
+                latch.countDown();
+            }
+        });
+        launchIntent.putExtras(extras).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        mContext.startActivity(launchIntent);
+        if (latch.await(START_ACTIVITY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+            if (binders[0] == null) {
+                fail("Receiver binder should not be null");
+            }
+            return binders[0];
+        } else {
+            fail("Timed out waiting for the test activity to start; testUid=" + mTestPkgUid);
+        }
+        return null;
+    }
+
+    private String executeCmdSilent(String cmd) throws Exception {
+        return mUiDevice.executeShellCommand(cmd).trim();
+    }
+}
diff --git a/services/tests/powerstatstests/src/com/android/server/power/stats/MockBatteryStatsImpl.java b/services/tests/powerstatstests/src/com/android/server/power/stats/MockBatteryStatsImpl.java
index 6d3f1f2..4150972a 100644
--- a/services/tests/powerstatstests/src/com/android/server/power/stats/MockBatteryStatsImpl.java
+++ b/services/tests/powerstatstests/src/com/android/server/power/stats/MockBatteryStatsImpl.java
@@ -119,6 +119,13 @@
         return MOBILE_RADIO_POWER_STATE_UPDATE_FREQ_MS;
     }
 
+    public MockBatteryStatsImpl setBatteryStatsConfig(BatteryStatsConfig config) {
+        synchronized (this) {
+            mBatteryStatsConfig = config;
+        }
+        return this;
+    }
+
     public MockBatteryStatsImpl setNetworkStats(NetworkStats networkStats) {
         mNetworkStats = networkStats;
         return this;
diff --git a/services/tests/powerstatstests/src/com/android/server/power/stats/PowerStatsCollectorTest.java b/services/tests/powerstatstests/src/com/android/server/power/stats/PowerStatsCollectorTest.java
new file mode 100644
index 0000000..08c8213
--- /dev/null
+++ b/services/tests/powerstatstests/src/com/android/server/power/stats/PowerStatsCollectorTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.server.power.stats;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.internal.os.PowerStats;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class PowerStatsCollectorTest {
+    private final MockClock mMockClock = new MockClock();
+    private final HandlerThread mHandlerThread = new HandlerThread("test");
+    private Handler mHandler;
+    private PowerStatsCollector mCollector;
+    private PowerStats mCollectedStats;
+
+    @Before
+    public void setup() {
+        mHandlerThread.start();
+        mHandler = mHandlerThread.getThreadHandler();
+        mCollector = new PowerStatsCollector(mHandler,
+                60000,
+                mMockClock) {
+            @Override
+            protected PowerStats collectStats() {
+                return new PowerStats();
+            }
+        };
+        mCollector.addConsumer(stats -> mCollectedStats = stats);
+        mCollector.setEnabled(true);
+    }
+
+    @Test
+    public void throttlePeriod() {
+        mMockClock.uptime = 1000;
+        mCollector.schedule();
+        waitForIdle();
+
+        assertThat(mCollectedStats).isNotNull();
+
+        mMockClock.uptime += 1000;
+        mCollectedStats = null;
+        mCollector.schedule();      // Should be throttled
+        waitForIdle();
+
+        assertThat(mCollectedStats).isNull();
+
+        // Should be allowed to run
+        mMockClock.uptime += 100_000;
+        mCollector.schedule();
+        waitForIdle();
+
+        assertThat(mCollectedStats).isNotNull();
+    }
+
+    private void waitForIdle() {
+        ConditionVariable done = new ConditionVariable();
+        mHandler.post(done::open);
+        done.block();
+    }
+}
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index 92ff7ab..20d8a5d 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -68,6 +68,7 @@
         "ActivityContext",
         "coretests-aidl",
         "securebox",
+        "flag-junit",
     ],
 
     libs: [
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 57aa0b9..579bbc8 100755
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationManagerServiceTest.java
@@ -6044,6 +6044,49 @@
     }
 
     @Test
+    public void testVisitUris_styleExtrasWithoutStyle() {
+        Notification notification = new Notification.Builder(mContext, "a")
+                .setSmallIcon(android.R.drawable.sym_def_app_icon)
+                .build();
+
+        Notification.MessagingStyle messagingStyle = new Notification.MessagingStyle(
+                personWithIcon("content://user"))
+                .addHistoricMessage(new Notification.MessagingStyle.Message("Heyhey!",
+                                System.currentTimeMillis(),
+                                personWithIcon("content://historicalMessenger")))
+                .addMessage(new Notification.MessagingStyle.Message("Are you there",
+                                System.currentTimeMillis(),
+                                personWithIcon("content://messenger")))
+                        .setShortcutIcon(
+                                Icon.createWithContentUri("content://conversationShortcut"));
+        messagingStyle.addExtras(notification.extras); // Instead of Builder.setStyle(style).
+
+        Notification.CallStyle callStyle = Notification.CallStyle.forOngoingCall(
+                        personWithIcon("content://caller"),
+                        PendingIntent.getActivity(mContext, 0, new Intent(),
+                                PendingIntent.FLAG_IMMUTABLE))
+                .setVerificationIcon(Icon.createWithContentUri("content://callVerification"));
+        callStyle.addExtras(notification.extras); // Same.
+
+        Consumer<Uri> visitor = (Consumer<Uri>) spy(Consumer.class);
+        notification.visitUris(visitor);
+
+        verify(visitor).accept(eq(Uri.parse("content://user")));
+        verify(visitor).accept(eq(Uri.parse("content://historicalMessenger")));
+        verify(visitor).accept(eq(Uri.parse("content://messenger")));
+        verify(visitor).accept(eq(Uri.parse("content://conversationShortcut")));
+        verify(visitor).accept(eq(Uri.parse("content://caller")));
+        verify(visitor).accept(eq(Uri.parse("content://callVerification")));
+    }
+
+    private static Person personWithIcon(String iconUri) {
+        return new Person.Builder()
+                .setName("Mr " + iconUri)
+                .setIcon(Icon.createWithContentUri(iconUri))
+                .build();
+    }
+
+    @Test
     public void testVisitUris_wearableExtender() {
         Icon actionIcon = Icon.createWithContentUri("content://media/action");
         Icon wearActionIcon = Icon.createWithContentUri("content://media/wearAction");
diff --git a/services/tests/wmtests/src/com/android/server/wm/TransitionTests.java b/services/tests/wmtests/src/com/android/server/wm/TransitionTests.java
index 9b6d4e2..873d09b 100644
--- a/services/tests/wmtests/src/com/android/server/wm/TransitionTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/TransitionTests.java
@@ -1191,6 +1191,7 @@
         final WindowState statusBar = createWindow(null, TYPE_STATUS_BAR, "statusBar");
         makeWindowVisible(statusBar);
         mDisplayContent.getDisplayPolicy().addWindowLw(statusBar, statusBar.mAttrs);
+        final WindowState navBar = createWindow(null, TYPE_NAVIGATION_BAR, "navBar");
         final ActivityRecord app = createActivityRecord(mDisplayContent);
         final Transition transition = app.mTransitionController.createTransition(TRANSIT_OPEN);
         app.mTransitionController.requestStartTransition(transition, app.getTask(),
@@ -1220,9 +1221,17 @@
         mDisplayContent.mTransitionController.dispatchLegacyAppTransitionFinished(app);
         assertTrue(mDisplayContent.hasTopFixedRotationLaunchingApp());
 
+        // The bar was invisible so it is not handled by the controller. But if it becomes visible
+        // and drawn before the transition starts,
+        assertFalse(asyncRotationController.isTargetToken(navBar.mToken));
+        navBar.finishDrawing(null /* postDrawTransaction */, Integer.MAX_VALUE);
+        assertTrue(asyncRotationController.isTargetToken(navBar.mToken));
+
         player.startTransition();
         // Non-app windows should not be collected.
         assertFalse(mDisplayContent.mTransitionController.isCollecting(statusBar.mToken));
+        // Avoid DeviceStateController disturbing the test by triggering another rotation change.
+        doReturn(false).when(mDisplayContent).updateRotationUnchecked();
 
         onRotationTransactionReady(player, mWm.mTransactionFactory.get()).onTransactionCommitted();
         assertEquals(ROTATION_ANIMATION_SEAMLESS, player.mLastReady.getChange(
diff --git a/tests/Input/src/com/android/server/input/FocusEventDebugViewTest.java b/tests/Input/src/com/android/server/input/FocusEventDebugViewTest.java
deleted file mode 100644
index 1b98887..0000000
--- a/tests/Input/src/com/android/server/input/FocusEventDebugViewTest.java
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
- * Copyright 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.server.input;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.anyInt;
-import static org.mockito.Mockito.anyString;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-import android.content.Context;
-import android.view.InputChannel;
-import android.view.InputDevice;
-import android.view.MotionEvent;
-import android.view.MotionEvent.PointerCoords;
-import android.view.MotionEvent.PointerProperties;
-import android.view.ViewConfiguration;
-
-import androidx.test.InstrumentationRegistry;
-import androidx.test.runner.AndroidJUnit4;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-/**
- * Build/Install/Run:
- * atest FocusEventDebugViewTest
- */
-@RunWith(AndroidJUnit4.class)
-public class FocusEventDebugViewTest {
-
-    private FocusEventDebugView mFocusEventDebugView;
-    private FocusEventDebugView.RotaryInputValueView mRotaryInputValueView;
-    private FocusEventDebugView.RotaryInputGraphView mRotaryInputGraphView;
-    private float mScaledVerticalScrollFactor;
-
-    @Before
-    public void setUp() throws Exception {
-        Context context = InstrumentationRegistry.getContext();
-        mScaledVerticalScrollFactor =
-                ViewConfiguration.get(context).getScaledVerticalScrollFactor();
-        InputManagerService mockService = mock(InputManagerService.class);
-        when(mockService.monitorInput(anyString(), anyInt()))
-                .thenReturn(InputChannel.openInputChannelPair("FocusEventDebugViewTest")[1]);
-
-        mRotaryInputValueView = new FocusEventDebugView.RotaryInputValueView(context);
-        mRotaryInputGraphView = new FocusEventDebugView.RotaryInputGraphView(context);
-        mFocusEventDebugView = new FocusEventDebugView(context, mockService,
-                () -> mRotaryInputValueView, () -> mRotaryInputGraphView);
-    }
-
-    @Test
-    public void startsRotaryInputValueViewWithDefaultValue() {
-        assertEquals("+0.0", mRotaryInputValueView.getText());
-    }
-
-    @Test
-    public void startsRotaryInputGraphViewWithDefaultFrameCenter() {
-        assertEquals(0, mRotaryInputGraphView.getFrameCenterPosition(), 0.01);
-    }
-
-    @Test
-    public void handleRotaryInput_updatesRotaryInputValueViewWithScrollValue() {
-        mFocusEventDebugView.handleUpdateShowRotaryInput(true);
-
-        mFocusEventDebugView.handleRotaryInput(createRotaryMotionEvent(0.5f));
-
-        assertEquals(String.format("+%.1f", 0.5f * mScaledVerticalScrollFactor),
-                mRotaryInputValueView.getText());
-    }
-
-    @Test
-    public void handleRotaryInput_translatesRotaryInputGraphViewWithHighScrollValue() {
-        mFocusEventDebugView.handleUpdateShowRotaryInput(true);
-
-        mFocusEventDebugView.handleRotaryInput(createRotaryMotionEvent(1000f));
-
-        assertTrue(mRotaryInputGraphView.getFrameCenterPosition() > 0);
-    }
-
-    @Test
-    public void updateActivityStatus_setsAndRemovesColorFilter() {
-        // It should not be active initially.
-        assertNull(mRotaryInputValueView.getBackground().getColorFilter());
-
-        mRotaryInputValueView.updateActivityStatus(true);
-        // It should be active after rotary input.
-        assertNotNull(mRotaryInputValueView.getBackground().getColorFilter());
-
-        mRotaryInputValueView.updateActivityStatus(false);
-        // It should not be active after waiting for mUpdateActivityStatusCallback.
-        assertNull(mRotaryInputValueView.getBackground().getColorFilter());
-    }
-
-    private MotionEvent createRotaryMotionEvent(float scrollAxisValue) {
-        PointerCoords pointerCoords = new PointerCoords();
-        pointerCoords.setAxisValue(MotionEvent.AXIS_SCROLL, scrollAxisValue);
-        PointerProperties pointerProperties = new PointerProperties();
-
-        return MotionEvent.obtain(
-                /* downTime */ 0,
-                /* eventTime */ 0,
-                /* action */ MotionEvent.ACTION_SCROLL,
-                /* pointerCount */ 1,
-                /* pointerProperties */ new PointerProperties[] {pointerProperties},
-                /* pointerCoords */ new PointerCoords[] {pointerCoords},
-                /* metaState */ 0,
-                /* buttonState */ 0,
-                /* xPrecision */ 0,
-                /* yPrecision */ 0,
-                /* deviceId */ 0,
-                /* edgeFlags */ 0,
-                /* source */ InputDevice.SOURCE_ROTARY_ENCODER,
-                /* flags */ 0
-        );
-    }
-}
diff --git a/tests/Input/src/com/android/server/input/debug/FocusEventDebugViewTest.java b/tests/Input/src/com/android/server/input/debug/FocusEventDebugViewTest.java
new file mode 100644
index 0000000..ae7fb3b
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/debug/FocusEventDebugViewTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 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.server.input.debug;
+
+import static org.mockito.Mockito.anyFloat;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.anyLong;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.view.InputChannel;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.MotionEvent.PointerCoords;
+import android.view.MotionEvent.PointerProperties;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.input.InputManagerService;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Build/Install/Run:
+ * atest FocusEventDebugViewTest
+ */
+@RunWith(AndroidJUnit4.class)
+public class FocusEventDebugViewTest {
+
+    private FocusEventDebugView mFocusEventDebugView;
+    private RotaryInputValueView mRotaryInputValueView;
+    private RotaryInputGraphView mRotaryInputGraphView;
+
+    @Before
+    public void setUp() throws Exception {
+        Context context = InstrumentationRegistry.getContext();
+        InputManagerService mockService = mock(InputManagerService.class);
+        when(mockService.monitorInput(anyString(), anyInt()))
+                .thenReturn(InputChannel.openInputChannelPair("FocusEventDebugViewTest")[1]);
+
+        mRotaryInputValueView = spy(new RotaryInputValueView(context));
+        mRotaryInputGraphView = spy(new RotaryInputGraphView(context));
+        mFocusEventDebugView = new FocusEventDebugView(context, mockService,
+                () -> mRotaryInputValueView, () -> mRotaryInputGraphView);
+    }
+
+    @Test
+    public void handleRotaryInput_sendsMotionEventWhenEnabled() {
+        mFocusEventDebugView.handleUpdateShowRotaryInput(true);
+
+        mFocusEventDebugView.handleRotaryInput(createRotaryMotionEvent(0.5f,  10L));
+
+        verify(mRotaryInputGraphView).addValue(0.5f, 10L);
+        verify(mRotaryInputValueView).updateValue(0.5f);
+    }
+
+    @Test
+    public void handleRotaryInput_doesNotSendMotionEventWhenDisabled() {
+        mFocusEventDebugView.handleUpdateShowRotaryInput(false);
+
+        mFocusEventDebugView.handleRotaryInput(createRotaryMotionEvent(0.5f, 10L));
+
+        verify(mRotaryInputGraphView, never()).addValue(anyFloat(), anyLong());
+        verify(mRotaryInputValueView, never()).updateValue(anyFloat());
+    }
+
+    private MotionEvent createRotaryMotionEvent(float scrollAxisValue, long eventTime) {
+        PointerCoords pointerCoords = new PointerCoords();
+        pointerCoords.setAxisValue(MotionEvent.AXIS_SCROLL, scrollAxisValue);
+        PointerProperties pointerProperties = new PointerProperties();
+
+        return MotionEvent.obtain(
+                /* downTime */ 0,
+                /* eventTime */ eventTime,
+                /* action */ MotionEvent.ACTION_SCROLL,
+                /* pointerCount */ 1,
+                /* pointerProperties */ new PointerProperties[] {pointerProperties},
+                /* pointerCoords */ new PointerCoords[] {pointerCoords},
+                /* metaState */ 0,
+                /* buttonState */ 0,
+                /* xPrecision */ 0,
+                /* yPrecision */ 0,
+                /* deviceId */ 0,
+                /* edgeFlags */ 0,
+                /* source */ InputDevice.SOURCE_ROTARY_ENCODER,
+                /* flags */ 0
+        );
+    }
+}
diff --git a/tests/Input/src/com/android/server/input/debug/RotaryInputGraphViewTest.java b/tests/Input/src/com/android/server/input/debug/RotaryInputGraphViewTest.java
new file mode 100644
index 0000000..af6ece4
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/debug/RotaryInputGraphViewTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 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.server.input.debug;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Build/Install/Run:
+ * atest RotaryInputGraphViewTest
+ */
+@RunWith(AndroidJUnit4.class)
+public class RotaryInputGraphViewTest {
+
+    private RotaryInputGraphView mRotaryInputGraphView;
+
+    @Before
+    public void setUp() throws Exception {
+        Context context = InstrumentationRegistry.getContext();
+
+        mRotaryInputGraphView = new RotaryInputGraphView(context);
+    }
+
+    @Test
+    public void startsWithDefaultFrameCenter() {
+        assertEquals(0, mRotaryInputGraphView.getFrameCenterPosition(), 0.01);
+    }
+
+    @Test
+    public void addValue_translatesRotaryInputGraphViewWithHighScrollValue() {
+        final float scrollAxisValue = 1000f;
+        final long eventTime = 0;
+
+        mRotaryInputGraphView.addValue(scrollAxisValue, eventTime);
+
+        assertTrue(mRotaryInputGraphView.getFrameCenterPosition() > 0);
+    }
+}
diff --git a/tests/Input/src/com/android/server/input/debug/RotaryInputValueViewTest.java b/tests/Input/src/com/android/server/input/debug/RotaryInputValueViewTest.java
new file mode 100644
index 0000000..e5e3852
--- /dev/null
+++ b/tests/Input/src/com/android/server/input/debug/RotaryInputValueViewTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 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.server.input.debug;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import android.content.Context;
+import android.view.ViewConfiguration;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Locale;
+
+/**
+ * Build/Install/Run:
+ * atest RotaryInputValueViewTest
+ */
+@RunWith(AndroidJUnit4.class)
+public class RotaryInputValueViewTest {
+
+    private final Locale mDefaultLocale = Locale.getDefault();
+
+    private RotaryInputValueView mRotaryInputValueView;
+    private float mScaledVerticalScrollFactor;
+
+    @Before
+    public void setUp() throws Exception {
+        Context context = InstrumentationRegistry.getContext();
+        mScaledVerticalScrollFactor =
+                ViewConfiguration.get(context).getScaledVerticalScrollFactor();
+
+        mRotaryInputValueView = new RotaryInputValueView(context);
+    }
+
+    @Test
+    public void startsWithDefaultValue() {
+        assertEquals("+0.0", mRotaryInputValueView.getText().toString());
+    }
+
+    @Test
+    public void updateValue_updatesTextWithScrollValue() {
+        final float scrollAxisValue = 1000f;
+        final String expectedText = String.format(mDefaultLocale, "+%.1f",
+                scrollAxisValue * mScaledVerticalScrollFactor);
+
+        mRotaryInputValueView.updateValue(scrollAxisValue);
+
+        assertEquals(expectedText, mRotaryInputValueView.getText().toString());
+    }
+
+    @Test
+    public void updateActivityStatus_setsAndRemovesColorFilter() {
+        // It should not be active initially.
+        assertNull(mRotaryInputValueView.getBackground().getColorFilter());
+
+        mRotaryInputValueView.updateActivityStatus(true);
+        // It should be active after rotary input.
+        assertNotNull(mRotaryInputValueView.getBackground().getColorFilter());
+
+        mRotaryInputValueView.updateActivityStatus(false);
+        // It should not be active after waiting for mUpdateActivityStatusCallback.
+        assertNull(mRotaryInputValueView.getBackground().getColorFilter());
+    }
+}
diff --git a/tests/InputMethodStressTest/src/com/android/inputmethod/stresstest/ImeStressTestUtil.java b/tests/InputMethodStressTest/src/com/android/inputmethod/stresstest/ImeStressTestUtil.java
index c9b5c96..12556bc 100644
--- a/tests/InputMethodStressTest/src/com/android/inputmethod/stresstest/ImeStressTestUtil.java
+++ b/tests/InputMethodStressTest/src/com/android/inputmethod/stresstest/ImeStressTestUtil.java
@@ -154,7 +154,7 @@
      * <p>The given {@code pred} will be called on the main thread.
      */
     public static void waitOnMainUntil(String message, Callable<Boolean> pred) {
-        eventually(() -> assertWithMessage(message).that(pred.call()).isTrue(), TIMEOUT);
+        eventually(() -> assertWithMessage(message).that(callOnMainSync(pred)).isTrue(), TIMEOUT);
     }
 
     /** Waits until IME is shown, or throws on timeout. */