Merge "Allowing state logic to progress from terminal states on touch down." into main
diff --git a/core/java/android/inputmethodservice/InputMethodService.java b/core/java/android/inputmethodservice/InputMethodService.java
index e79b8f3..de39847 100644
--- a/core/java/android/inputmethodservice/InputMethodService.java
+++ b/core/java/android/inputmethodservice/InputMethodService.java
@@ -524,19 +524,12 @@
 
     /**
      * @hide
-     * The IME is active and ready with views but set invisible.
-     * This flag cannot be combined with {@link #IME_VISIBLE}.
-     */
-    public static final int IME_INVISIBLE = 0x4;
-
-    /**
-     * @hide
      * The IME is visible, but not yet perceptible to the user (e.g. fading in)
      * by {@link android.view.WindowInsetsController}.
      *
      * @see InputMethodManager#reportPerceptible
      */
-    public static final int IME_VISIBLE_IMPERCEPTIBLE = 0x8;
+    public static final int IME_VISIBLE_IMPERCEPTIBLE = 0x4;
 
     // Min and max values for back disposition.
     private static final int BACK_DISPOSITION_MIN = BACK_DISPOSITION_DEFAULT;
@@ -3125,7 +3118,7 @@
         mInShowWindow = true;
         final int previousImeWindowStatus =
                 (mDecorViewVisible ? IME_ACTIVE : 0) | (isInputViewShown()
-                        ? (!mWindowVisible ? IME_INVISIBLE : IME_VISIBLE) : 0);
+                        ? (!mWindowVisible ? -1 : IME_VISIBLE) : 0);
         startViews(prepareWindow(showInput));
         final int nextImeWindowStatus = mapToImeWindowStatus();
         if (previousImeWindowStatus != nextImeWindowStatus) {
diff --git a/core/java/android/widget/Chronometer.java b/core/java/android/widget/Chronometer.java
index 0b67cad..9931aea 100644
--- a/core/java/android/widget/Chronometer.java
+++ b/core/java/android/widget/Chronometer.java
@@ -328,7 +328,7 @@
             if (running) {
                 updateText(SystemClock.elapsedRealtime());
                 dispatchChronometerTick();
-                postDelayed(mTickRunnable, 1000);
+                postTickOnNextSecond();
             } else {
                 removeCallbacks(mTickRunnable);
             }
@@ -342,11 +342,17 @@
             if (mRunning) {
                 updateText(SystemClock.elapsedRealtime());
                 dispatchChronometerTick();
-                postDelayed(mTickRunnable, 1000);
+                postTickOnNextSecond();
             }
         }
     };
 
+    private void postTickOnNextSecond() {
+        long nowMillis = SystemClock.elapsedRealtime();
+        int millis = (int) ((nowMillis - mBase) % 1000);
+        postDelayed(mTickRunnable, 1000 - millis);
+    }
+
     void dispatchChronometerTick() {
         if (mOnChronometerTickListener != null) {
             mOnChronometerTickListener.onChronometerTick(this);
diff --git a/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java b/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java
index 2daf0fd..921363c 100644
--- a/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java
+++ b/core/java/com/android/internal/inputmethod/InputMethodPrivilegedOperations.java
@@ -108,7 +108,6 @@
      * @param backDisposition disposition flags
      * @see android.inputmethodservice.InputMethodService#IME_ACTIVE
      * @see android.inputmethodservice.InputMethodService#IME_VISIBLE
-     * @see android.inputmethodservice.InputMethodService#IME_INVISIBLE
      * @see android.inputmethodservice.InputMethodService#BACK_DISPOSITION_DEFAULT
      * @see android.inputmethodservice.InputMethodService#BACK_DISPOSITION_ADJUST_NOTHING
      */
diff --git a/core/java/com/android/internal/protolog/ProtoLogCommandHandler.java b/core/java/com/android/internal/protolog/ProtoLogCommandHandler.java
new file mode 100644
index 0000000..3dab2e3
--- /dev/null
+++ b/core/java/com/android/internal/protolog/ProtoLogCommandHandler.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.protolog;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.ShellCommand;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+public class ProtoLogCommandHandler extends ShellCommand {
+    @NonNull
+    private final ProtoLogService mProtoLogService;
+    @Nullable
+    private final PrintWriter mPrintWriter;
+
+    public ProtoLogCommandHandler(@NonNull ProtoLogService protoLogService) {
+        this(protoLogService, null);
+    }
+
+    @VisibleForTesting
+    public ProtoLogCommandHandler(
+            @NonNull ProtoLogService protoLogService, @Nullable PrintWriter printWriter) {
+        this.mProtoLogService = protoLogService;
+        this.mPrintWriter = printWriter;
+    }
+
+    @Override
+    public int onCommand(String cmd) {
+        if (cmd == null) {
+            onHelp();
+            return 0;
+        }
+
+        return switch (cmd) {
+            case "groups" -> handleGroupsCommands(getNextArg());
+            case "logcat" -> handleLogcatCommands(getNextArg());
+            default -> handleDefaultCommands(cmd);
+        };
+    }
+
+    @Override
+    public void onHelp() {
+        PrintWriter pw = getOutPrintWriter();
+        pw.println("ProtoLog commands:");
+        pw.println("  help");
+        pw.println("    Print this help text.");
+        pw.println();
+        pw.println("  groups (list | status)");
+        pw.println("    list - lists all ProtoLog groups registered with ProtoLog service");
+        pw.println("    status <group> - print the status of a ProtoLog group");
+        pw.println();
+        pw.println("  logcat (enable | disable) <group>");
+        pw.println("    enable or disable ProtoLog to logcat");
+        pw.println();
+    }
+
+    @NonNull
+    @Override
+    public PrintWriter getOutPrintWriter() {
+        if (mPrintWriter != null) {
+            return mPrintWriter;
+        }
+
+        return super.getOutPrintWriter();
+    }
+
+    private int handleGroupsCommands(@Nullable String cmd) {
+        PrintWriter pw = getOutPrintWriter();
+
+        if (cmd == null) {
+            pw.println("Incomplete command. Use 'cmd protolog help' for guidance.");
+            return 0;
+        }
+
+        switch (cmd) {
+            case "list": {
+                final String[] availableGroups = mProtoLogService.getGroups();
+                if (availableGroups.length == 0) {
+                    pw.println("No ProtoLog groups registered with ProtoLog service.");
+                    return 0;
+                }
+
+                pw.println("ProtoLog groups registered with service:");
+                for (String group : availableGroups) {
+                    pw.println("- " + group);
+                }
+
+                return 0;
+            }
+            case "status": {
+                final String group = getNextArg();
+
+                if (group == null) {
+                    pw.println("Incomplete command. Use 'cmd protolog help' for guidance.");
+                    return 0;
+                }
+
+                pw.println("ProtoLog group " + group + "'s status:");
+
+                if (!Set.of(mProtoLogService.getGroups()).contains(group)) {
+                    pw.println("UNREGISTERED");
+                    return 0;
+                }
+
+                pw.println("LOG_TO_LOGCAT = " + mProtoLogService.isLoggingToLogcat(group));
+                return 0;
+            }
+            default: {
+                pw.println("Unknown command: " + cmd);
+                return -1;
+            }
+        }
+    }
+
+    private int handleLogcatCommands(@Nullable String cmd) {
+        PrintWriter pw = getOutPrintWriter();
+
+        if (cmd == null || peekNextArg() == null) {
+            pw.println("Incomplete command. Use 'cmd protolog help' for guidance.");
+            return 0;
+        }
+
+        switch (cmd) {
+            case "enable" -> {
+                mProtoLogService.enableProtoLogToLogcat(processGroups());
+                return 0;
+            }
+            case "disable" -> {
+                mProtoLogService.disableProtoLogToLogcat(processGroups());
+                return 0;
+            }
+            default -> {
+                pw.println("Unknown command: " + cmd);
+                return -1;
+            }
+        }
+    }
+
+    @NonNull
+    private String[] processGroups() {
+        if (getRemainingArgsCount() == 0) {
+            return mProtoLogService.getGroups();
+        }
+
+        final List<String> groups = new ArrayList<>();
+        while (getRemainingArgsCount() > 0) {
+            groups.add(getNextArg());
+        }
+
+        return groups.toArray(new String[0]);
+    }
+}
diff --git a/core/java/com/android/internal/protolog/ProtoLogService.java b/core/java/com/android/internal/protolog/ProtoLogService.java
index 0b13a1a..2333a06 100644
--- a/core/java/com/android/internal/protolog/ProtoLogService.java
+++ b/core/java/com/android/internal/protolog/ProtoLogService.java
@@ -33,6 +33,8 @@
 import android.annotation.SystemService;
 import android.content.Context;
 import android.os.RemoteException;
+import android.os.ResultReceiver;
+import android.os.ShellCallback;
 import android.os.SystemClock;
 import android.tracing.perfetto.DataSourceParams;
 import android.tracing.perfetto.InitArguments;
@@ -43,6 +45,7 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 
+import java.io.FileDescriptor;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
@@ -224,6 +227,14 @@
         registerGroups(client, args.getGroups(), args.getGroupsDefaultLogcatStatus());
     }
 
+    @Override
+    public void onShellCommand(@Nullable FileDescriptor in, @Nullable FileDescriptor out,
+            @Nullable FileDescriptor err, @NonNull String[] args, @Nullable ShellCallback callback,
+            @NonNull ResultReceiver resultReceiver) throws RemoteException {
+        new ProtoLogCommandHandler(this)
+                .exec(this, in, out, err, args, callback, resultReceiver);
+    }
+
     /**
      * Get the list of groups clients have registered to the protolog service.
      * @return The list of ProtoLog groups registered with this service.
diff --git a/core/tests/coretests/AndroidManifest.xml b/core/tests/coretests/AndroidManifest.xml
index c05ea3d..fc3c2f3 100644
--- a/core/tests/coretests/AndroidManifest.xml
+++ b/core/tests/coretests/AndroidManifest.xml
@@ -265,6 +265,17 @@
             </intent-filter>
         </activity>
 
+        <activity android:name="android.widget.ChronometerActivity"
+                android:label="ChronometerActivity"
+                android:screenOrientation="portrait"
+                android:exported="true"
+                android:theme="@android:style/Theme.Material.Light">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" />
+            </intent-filter>
+        </activity>
+
         <activity android:name="android.widget.DatePickerActivity"
                 android:label="DatePickerActivity"
                 android:screenOrientation="portrait"
diff --git a/core/tests/coretests/res/layout/chronometer_layout.xml b/core/tests/coretests/res/layout/chronometer_layout.xml
new file mode 100644
index 0000000..f209c41
--- /dev/null
+++ b/core/tests/coretests/res/layout/chronometer_layout.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 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.
+-->
+
+<FrameLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+    <Chronometer
+            android:id="@+id/chronometer"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"/>
+</FrameLayout>
diff --git a/core/tests/coretests/src/android/widget/ChronometerActivity.java b/core/tests/coretests/src/android/widget/ChronometerActivity.java
new file mode 100644
index 0000000..aaed430
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/ChronometerActivity.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2008 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.widget;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+import com.android.frameworks.coretests.R;
+
+/**
+ * A minimal application for DatePickerFocusTest.
+ */
+public class ChronometerActivity extends Activity {
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.chronometer_layout);
+    }
+}
diff --git a/core/tests/coretests/src/android/widget/ChronometerTest.java b/core/tests/coretests/src/android/widget/ChronometerTest.java
new file mode 100644
index 0000000..3c73837
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/ChronometerTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2008 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.widget;
+
+import android.app.Activity;
+import android.test.ActivityInstrumentationTestCase2;
+
+import androidx.test.filters.LargeTest;
+
+import com.android.frameworks.coretests.R;
+
+import java.util.ArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Test {@link DatePicker} focus changes.
+ */
+@SuppressWarnings("deprecation")
+@LargeTest
+public class ChronometerTest extends ActivityInstrumentationTestCase2<ChronometerActivity> {
+
+    private Activity mActivity;
+    private Chronometer mChronometer;
+
+    public ChronometerTest() {
+        super(ChronometerActivity.class);
+    }
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mActivity = getActivity();
+        mChronometer = mActivity.findViewById(R.id.chronometer);
+    }
+
+    public void testChronometerTicksSequentially() throws Throwable {
+        final CountDownLatch latch = new CountDownLatch(5);
+        ArrayList<String> ticks = new ArrayList<>();
+        runOnUiThread(() -> {
+            mChronometer.setOnChronometerTickListener((chronometer) -> {
+                ticks.add(chronometer.getText().toString());
+                latch.countDown();
+                try {
+                    Thread.sleep(500);
+                } catch (InterruptedException e) {
+                }
+            });
+            mChronometer.start();
+        });
+        assertTrue(latch.await(6, TimeUnit.SECONDS));
+        assertTrue(ticks.size() >= 5);
+        assertEquals("00:00", ticks.get(0));
+        assertEquals("00:01", ticks.get(1));
+        assertEquals("00:02", ticks.get(2));
+        assertEquals("00:03", ticks.get(3));
+        assertEquals("00:04", ticks.get(4));
+    }
+
+    private void runOnUiThread(Runnable runnable) throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        mActivity.runOnUiThread(() -> {
+            runnable.run();
+            latch.countDown();
+        });
+        latch.await();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTaskPosition.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTaskPosition.kt
index 97abda8..65f12cf 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTaskPosition.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTaskPosition.kt
@@ -116,10 +116,10 @@
 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
 fun Rect.getDesktopTaskPosition(bounds: Rect): DesktopTaskPosition {
     return when {
-        top == bounds.top && left == bounds.left -> TopLeft
-        top == bounds.top && right == bounds.right -> TopRight
-        bottom == bounds.bottom && left == bounds.left -> BottomLeft
-        bottom == bounds.bottom && right == bounds.right -> BottomRight
+        top == bounds.top && left == bounds.left && bottom != bounds.bottom -> TopLeft
+        top == bounds.top && right == bounds.right && bottom != bounds.bottom -> TopRight
+        bottom == bounds.bottom && left == bounds.left && top != bounds.top -> BottomLeft
+        bottom == bounds.bottom && right == bounds.right && top != bounds.top -> BottomRight
         else -> Center
     }
 }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
index 78d41b2..f54b44b 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/desktopmode/DesktopTasksController.kt
@@ -1070,6 +1070,11 @@
                 // In some launches home task is moved behind new task being launched. Make sure
                 // that's not the case for launches in desktop.
                 moveHomeTask(wct, toTop = false)
+                // Move existing minimized tasks behind Home
+                taskRepository.getFreeformTasksInZOrder(task.displayId)
+                    .filter { taskId -> taskRepository.isMinimizedTask(taskId) }
+                    .mapNotNull { taskId -> shellTaskOrganizer.getRunningTaskInfo(taskId) }
+                    .forEach { taskInfo -> wct.reorder(taskInfo.token, /* onTop= */ false) }
                 // Desktop Mode is already showing and we're launching a new Task - we might need to
                 // minimize another Task.
                 val taskToMinimize = addAndGetMinimizeChangesIfNeeded(task.displayId, wct, task)
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
index 3b85859..7bb5449 100644
--- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/desktopmode/DesktopTasksControllerTest.kt
@@ -731,6 +731,64 @@
 
   @Test
   @EnableFlags(Flags.FLAG_ENABLE_CASCADING_WINDOWS)
+  fun addMoveToDesktopChanges_lastWindowSnapLeft_positionResetsToCenter() {
+    setUpLandscapeDisplay()
+    val stableBounds = Rect()
+    displayLayout.getStableBoundsForDesktopMode(stableBounds)
+
+    // Add freeform task with half display size snap bounds at left side.
+    setUpFreeformTask(bounds = Rect(stableBounds.left, stableBounds.top, 500, stableBounds.bottom))
+
+    val task = setUpFullscreenTask()
+    val wct = WindowContainerTransaction()
+    controller.addMoveToDesktopChanges(wct, task)
+
+    val finalBounds = findBoundsChange(wct, task)
+    assertThat(stableBounds.getDesktopTaskPosition(finalBounds!!))
+      .isEqualTo(DesktopTaskPosition.Center)
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_CASCADING_WINDOWS)
+  fun addMoveToDesktopChanges_lastWindowSnapRight_positionResetsToCenter() {
+    setUpLandscapeDisplay()
+    val stableBounds = Rect()
+    displayLayout.getStableBoundsForDesktopMode(stableBounds)
+
+    // Add freeform task with half display size snap bounds at right side.
+    setUpFreeformTask(bounds = Rect(
+      stableBounds.right - 500, stableBounds.top, stableBounds.right, stableBounds.bottom))
+
+    val task = setUpFullscreenTask()
+    val wct = WindowContainerTransaction()
+    controller.addMoveToDesktopChanges(wct, task)
+
+    val finalBounds = findBoundsChange(wct, task)
+    assertThat(stableBounds.getDesktopTaskPosition(finalBounds!!))
+      .isEqualTo(DesktopTaskPosition.Center)
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_CASCADING_WINDOWS)
+  fun addMoveToDesktopChanges_lastWindowMaximised_positionResetsToCenter() {
+    setUpLandscapeDisplay()
+    val stableBounds = Rect()
+    displayLayout.getStableBoundsForDesktopMode(stableBounds)
+
+    // Add maximised freeform task.
+    setUpFreeformTask(bounds = Rect(stableBounds))
+
+    val task = setUpFullscreenTask()
+    val wct = WindowContainerTransaction()
+    controller.addMoveToDesktopChanges(wct, task)
+
+    val finalBounds = findBoundsChange(wct, task)
+    assertThat(stableBounds.getDesktopTaskPosition(finalBounds!!))
+      .isEqualTo(DesktopTaskPosition.Center)
+  }
+
+  @Test
+  @EnableFlags(Flags.FLAG_ENABLE_CASCADING_WINDOWS)
   fun addMoveToDesktopChanges_defaultToCenterIfFree() {
     setUpLandscapeDisplay()
     val stableBounds = Rect()
@@ -1349,13 +1407,36 @@
     val freeformTasks = (1..MAX_TASK_LIMIT).map { _ -> setUpFreeformTask() }
     freeformTasks.forEach { markTaskVisible(it) }
     val fullscreenTask = createFullscreenTask()
+    val homeTask = setUpHomeTask(DEFAULT_DISPLAY)
 
     val wct = controller.handleRequest(Binder(), createTransition(fullscreenTask))
 
     // Make sure we reorder the new task to top, and the back task to the bottom
-    assertThat(wct!!.hierarchyOps.size).isEqualTo(2)
+    assertThat(wct!!.hierarchyOps.size).isEqualTo(3)
     wct.assertReorderAt(0, fullscreenTask, toTop = true)
-    wct.assertReorderAt(1, freeformTasks[0], toTop = false)
+    wct.assertReorderAt(1, homeTask, toTop = false)
+    wct.assertReorderAt(2, freeformTasks[0], toTop = false)
+  }
+
+  @Test
+  fun handleRequest_fullscreenTaskToFreeform_alreadyBeyondLimit_existingAndNewTasksAreMinimized() {
+    assumeTrue(ENABLE_SHELL_TRANSITIONS)
+
+    val minimizedTask = setUpFreeformTask()
+    taskRepository.minimizeTask(displayId = DEFAULT_DISPLAY, taskId = minimizedTask.taskId)
+    val freeformTasks = (1..MAX_TASK_LIMIT).map { _ -> setUpFreeformTask() }
+    freeformTasks.forEach { markTaskVisible(it) }
+    val homeTask = setUpHomeTask()
+    val fullscreenTask = createFullscreenTask()
+
+    val wct = controller.handleRequest(Binder(), createTransition(fullscreenTask))
+
+    assertThat(wct!!.hierarchyOps.size).isEqualTo(4)
+    wct.assertReorderAt(0, fullscreenTask, toTop = true)
+    // Make sure we reorder the home task to the bottom, and minimized tasks below the home task.
+    wct.assertReorderAt(1, homeTask, toTop = false)
+    wct.assertReorderAt(2, minimizedTask, toTop = false)
+    wct.assertReorderAt(3, freeformTasks[0], toTop = false)
   }
 
   @Test
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryTest.kt
index f82a7b8..5dd6c22 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryTest.kt
@@ -85,7 +85,9 @@
                 GestureEduModel(
                     signalCount = 2,
                     educationShownCount = 1,
-                    lastShortcutTriggeredTime = kosmos.fakeEduClock.instant()
+                    lastShortcutTriggeredTime = kosmos.fakeEduClock.instant(),
+                    lastEducationTime = kosmos.fakeEduClock.instant(),
+                    usageSessionStartTime = kosmos.fakeEduClock.instant(),
                 )
             underTest.updateGestureEduModel(BACK) { newModel }
             val model by collectLastValue(underTest.readGestureEduModelFlow(BACK))
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorTest.kt
index 1b4632a..6867089 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorTest.kt
@@ -22,9 +22,15 @@
 import com.android.systemui.contextualeducation.GestureType
 import com.android.systemui.contextualeducation.GestureType.BACK
 import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.education.data.model.GestureEduModel
+import com.android.systemui.education.data.repository.contextualEducationRepository
+import com.android.systemui.education.data.repository.fakeEduClock
+import com.android.systemui.education.shared.model.EducationUiType
 import com.android.systemui.kosmos.testScope
 import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
 import org.junit.Before
 import org.junit.Test
@@ -37,6 +43,7 @@
     private val testScope = kosmos.testScope
     private val contextualEduInteractor = kosmos.contextualEducationInteractor
     private val underTest: KeyboardTouchpadEduInteractor = kosmos.keyboardTouchpadEduInteractor
+    private val eduClock = kosmos.fakeEduClock
 
     @Before
     fun setup() {
@@ -46,12 +53,32 @@
     @Test
     fun newEducationInfoOnMaxSignalCountReached() =
         testScope.runTest {
-            tryTriggeringEducation(BACK)
+            triggerMaxEducationSignals(BACK)
             val model by collectLastValue(underTest.educationTriggered)
             assertThat(model?.gestureType).isEqualTo(BACK)
         }
 
     @Test
+    fun newEducationToastOn1stEducation() =
+        testScope.runTest {
+            val model by collectLastValue(underTest.educationTriggered)
+            triggerMaxEducationSignals(BACK)
+            assertThat(model?.educationUiType).isEqualTo(EducationUiType.Toast)
+        }
+
+    @Test
+    @kotlinx.coroutines.ExperimentalCoroutinesApi
+    fun newEducationNotificationOn2ndEducation() =
+        testScope.runTest {
+            val model by collectLastValue(underTest.educationTriggered)
+            triggerMaxEducationSignals(BACK)
+            // runCurrent() to trigger 1st education
+            runCurrent()
+            triggerMaxEducationSignals(BACK)
+            assertThat(model?.educationUiType).isEqualTo(EducationUiType.Notification)
+        }
+
+    @Test
     fun noEducationInfoBeforeMaxSignalCountReached() =
         testScope.runTest {
             contextualEduInteractor.incrementSignalCount(BACK)
@@ -64,11 +91,30 @@
         testScope.runTest {
             val model by collectLastValue(underTest.educationTriggered)
             contextualEduInteractor.updateShortcutTriggerTime(BACK)
-            tryTriggeringEducation(BACK)
+            triggerMaxEducationSignals(BACK)
             assertThat(model).isNull()
         }
 
-    private suspend fun tryTriggeringEducation(gestureType: GestureType) {
+    @Test
+    fun startNewUsageSessionWhen2ndSignalReceivedAfterSessionDeadline() =
+        testScope.runTest {
+            val model by
+                collectLastValue(kosmos.contextualEducationRepository.readGestureEduModelFlow(BACK))
+            contextualEduInteractor.incrementSignalCount(BACK)
+            eduClock.offset(KeyboardTouchpadEduInteractor.usageSessionDuration.plus(1.seconds))
+            val secondSignalReceivedTime = eduClock.instant()
+            contextualEduInteractor.incrementSignalCount(BACK)
+
+            assertThat(model)
+                .isEqualTo(
+                    GestureEduModel(
+                        signalCount = 1,
+                        usageSessionStartTime = secondSignalReceivedTime
+                    )
+                )
+        }
+
+    private suspend fun triggerMaxEducationSignals(gestureType: GestureType) {
         // Increment max number of signal to try triggering education
         for (i in 1..KeyboardTouchpadEduInteractor.MAX_SIGNAL_COUNT) {
             contextualEduInteractor.incrementSignalCount(gestureType)
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt
index 46b370f..d1f908d 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt
@@ -16,27 +16,16 @@
 
 package com.android.systemui.lifecycle
 
-import android.view.View
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.android.systemui.SysuiTestCase
-import com.android.systemui.util.Assert
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.awaitCancellation
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.test.runCurrent
-import kotlinx.coroutines.test.runTest
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.mockito.kotlin.argumentCaptor
-import org.mockito.kotlin.doReturn
-import org.mockito.kotlin.mock
-import org.mockito.kotlin.stub
-import org.mockito.kotlin.verify
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
@@ -121,45 +110,4 @@
 
         assertThat(isActive).isFalse()
     }
-
-    @Test
-    fun viewModel_viewBinder() = runTest {
-        Assert.setTestThread(Thread.currentThread())
-
-        val view: View = mock { on { isAttachedToWindow } doReturn false }
-        val viewModel = FakeViewModel()
-        backgroundScope.launch {
-            view.viewModel(
-                minWindowLifecycleState = WindowLifecycleState.ATTACHED,
-                factory = { viewModel },
-            ) {
-                awaitCancellation()
-            }
-        }
-        runCurrent()
-
-        assertThat(viewModel.isActivated).isFalse()
-
-        view.stub { on { isAttachedToWindow } doReturn true }
-        argumentCaptor<View.OnAttachStateChangeListener>()
-            .apply { verify(view).addOnAttachStateChangeListener(capture()) }
-            .allValues
-            .forEach { it.onViewAttachedToWindow(view) }
-        runCurrent()
-
-        assertThat(viewModel.isActivated).isTrue()
-    }
-}
-
-private class FakeViewModel : SysUiViewModel() {
-    var isActivated = false
-
-    override suspend fun onActivated() {
-        isActivated = true
-        try {
-            awaitCancellation()
-        } finally {
-            isActivated = false
-        }
-    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/education/data/model/GestureEduModel.kt b/packages/SystemUI/src/com/android/systemui/education/data/model/GestureEduModel.kt
index 9f6cb4d..a171f87 100644
--- a/packages/SystemUI/src/com/android/systemui/education/data/model/GestureEduModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/education/data/model/GestureEduModel.kt
@@ -26,4 +26,6 @@
     val signalCount: Int = 0,
     val educationShownCount: Int = 0,
     val lastShortcutTriggeredTime: Instant? = null,
+    val usageSessionStartTime: Instant? = null,
+    val lastEducationTime: Instant? = null,
 )
diff --git a/packages/SystemUI/src/com/android/systemui/education/data/repository/UserContextualEducationRepository.kt b/packages/SystemUI/src/com/android/systemui/education/data/repository/UserContextualEducationRepository.kt
index 22ba4ad..7c3d6338 100644
--- a/packages/SystemUI/src/com/android/systemui/education/data/repository/UserContextualEducationRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/education/data/repository/UserContextualEducationRepository.kt
@@ -73,6 +73,8 @@
         const val SIGNAL_COUNT_SUFFIX = "_SIGNAL_COUNT"
         const val NUMBER_OF_EDU_SHOWN_SUFFIX = "_NUMBER_OF_EDU_SHOWN"
         const val LAST_SHORTCUT_TRIGGERED_TIME_SUFFIX = "_LAST_SHORTCUT_TRIGGERED_TIME"
+        const val USAGE_SESSION_START_TIME_SUFFIX = "_USAGE_SESSION_START_TIME"
+        const val LAST_EDUCATION_TIME_SUFFIX = "_LAST_EDUCATION_TIME"
 
         const val DATASTORE_DIR = "education/USER%s_ContextualEducation"
     }
@@ -113,6 +115,14 @@
                 preferences[getLastShortcutTriggeredTimeKey(gestureType)]?.let {
                     Instant.ofEpochSecond(it)
                 },
+            usageSessionStartTime =
+                preferences[getUsageSessionStartTimeKey(gestureType)]?.let {
+                    Instant.ofEpochSecond(it)
+                },
+            lastEducationTime =
+                preferences[getLastEducationTimeKey(gestureType)]?.let {
+                    Instant.ofEpochSecond(it)
+                },
         )
     }
 
@@ -125,11 +135,21 @@
             val updatedModel = transform(currentModel)
             preferences[getSignalCountKey(gestureType)] = updatedModel.signalCount
             preferences[getEducationShownCountKey(gestureType)] = updatedModel.educationShownCount
-            updateTimeByInstant(
+            setInstant(
                 preferences,
                 updatedModel.lastShortcutTriggeredTime,
                 getLastShortcutTriggeredTimeKey(gestureType)
             )
+            setInstant(
+                preferences,
+                updatedModel.usageSessionStartTime,
+                getUsageSessionStartTimeKey(gestureType)
+            )
+            setInstant(
+                preferences,
+                updatedModel.lastEducationTime,
+                getLastEducationTimeKey(gestureType)
+            )
         }
     }
 
@@ -142,7 +162,13 @@
     private fun getLastShortcutTriggeredTimeKey(gestureType: GestureType): Preferences.Key<Long> =
         longPreferencesKey(gestureType.name + LAST_SHORTCUT_TRIGGERED_TIME_SUFFIX)
 
-    private fun updateTimeByInstant(
+    private fun getUsageSessionStartTimeKey(gestureType: GestureType): Preferences.Key<Long> =
+        longPreferencesKey(gestureType.name + USAGE_SESSION_START_TIME_SUFFIX)
+
+    private fun getLastEducationTimeKey(gestureType: GestureType): Preferences.Key<Long> =
+        longPreferencesKey(gestureType.name + LAST_EDUCATION_TIME_SUFFIX)
+
+    private fun setInstant(
         preferences: MutablePreferences,
         instant: Instant?,
         key: Preferences.Key<Long>
diff --git a/packages/SystemUI/src/com/android/systemui/education/domain/interactor/ContextualEducationInteractor.kt b/packages/SystemUI/src/com/android/systemui/education/domain/interactor/ContextualEducationInteractor.kt
index 5ec1006..db5c386 100644
--- a/packages/SystemUI/src/com/android/systemui/education/domain/interactor/ContextualEducationInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/education/domain/interactor/ContextualEducationInteractor.kt
@@ -68,7 +68,13 @@
     }
 
     suspend fun incrementSignalCount(gestureType: GestureType) {
-        repository.updateGestureEduModel(gestureType) { it.copy(signalCount = it.signalCount + 1) }
+        repository.updateGestureEduModel(gestureType) {
+            it.copy(
+                signalCount = it.signalCount + 1,
+                usageSessionStartTime =
+                    if (it.signalCount == 0) clock.instant() else it.usageSessionStartTime
+            )
+        }
     }
 
     suspend fun updateShortcutTriggerTime(gestureType: GestureType) {
@@ -76,4 +82,22 @@
             it.copy(lastShortcutTriggeredTime = clock.instant())
         }
     }
+
+    suspend fun updateOnEduTriggered(gestureType: GestureType) {
+        repository.updateGestureEduModel(gestureType) {
+            it.copy(
+                // Reset signal counter and usageSessionStartTime after edu triggered
+                signalCount = 0,
+                lastEducationTime = clock.instant(),
+                educationShownCount = it.educationShownCount + 1,
+                usageSessionStartTime = null
+            )
+        }
+    }
+
+    suspend fun startNewUsageSession(gestureType: GestureType) {
+        repository.updateGestureEduModel(gestureType) {
+            it.copy(usageSessionStartTime = clock.instant(), signalCount = 1)
+        }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractor.kt b/packages/SystemUI/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractor.kt
index 9016c73..3a3fb8c 100644
--- a/packages/SystemUI/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractor.kt
@@ -17,17 +17,19 @@
 package com.android.systemui.education.domain.interactor
 
 import com.android.systemui.CoreStartable
+import com.android.systemui.contextualeducation.GestureType.BACK
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Background
-import com.android.systemui.contextualeducation.GestureType.BACK
+import com.android.systemui.education.dagger.ContextualEducationModule.EduClock
 import com.android.systemui.education.data.model.GestureEduModel
 import com.android.systemui.education.shared.model.EducationInfo
 import com.android.systemui.education.shared.model.EducationUiType
+import java.time.Clock
 import javax.inject.Inject
+import kotlin.time.Duration.Companion.hours
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.launch
 
 /** Allow listening to new contextual education triggered */
@@ -36,11 +38,13 @@
 @Inject
 constructor(
     @Background private val backgroundScope: CoroutineScope,
-    private val contextualEducationInteractor: ContextualEducationInteractor
+    private val contextualEducationInteractor: ContextualEducationInteractor,
+    @EduClock private val clock: Clock,
 ) : CoreStartable {
 
     companion object {
         const val MAX_SIGNAL_COUNT: Int = 2
+        val usageSessionDuration = 72.hours
     }
 
     private val _educationTriggered = MutableStateFlow<EducationInfo?>(null)
@@ -48,25 +52,30 @@
 
     override fun start() {
         backgroundScope.launch {
-            contextualEducationInteractor.backGestureModelFlow
-                .mapNotNull { getEduType(it) }
-                .collect { _educationTriggered.value = EducationInfo(BACK, it) }
-        }
-    }
-
-    private fun getEduType(model: GestureEduModel): EducationUiType? {
-        if (isEducationNeeded(model)) {
-            return EducationUiType.Toast
-        } else {
-            return null
+            contextualEducationInteractor.backGestureModelFlow.collect {
+                if (isUsageSessionExpired(it)) {
+                    contextualEducationInteractor.startNewUsageSession(BACK)
+                } else if (isEducationNeeded(it)) {
+                    _educationTriggered.value = EducationInfo(BACK, getEduType(it))
+                    contextualEducationInteractor.updateOnEduTriggered(BACK)
+                }
+            }
         }
     }
 
     private fun isEducationNeeded(model: GestureEduModel): Boolean {
         // Todo: b/354884305 - add complete education logic to show education in correct scenarios
-        val shortcutWasTriggered = model.lastShortcutTriggeredTime == null
+        val noShortcutTriggered = model.lastShortcutTriggeredTime == null
         val signalCountReached = model.signalCount >= MAX_SIGNAL_COUNT
-
-        return shortcutWasTriggered && signalCountReached
+        return noShortcutTriggered && signalCountReached
     }
+
+    private fun isUsageSessionExpired(model: GestureEduModel): Boolean {
+        return model.usageSessionStartTime
+            ?.plusSeconds(usageSessionDuration.inWholeSeconds)
+            ?.isBefore(clock.instant()) ?: false
+    }
+
+    private fun getEduType(model: GestureEduModel) =
+        if (model.educationShownCount > 0) EducationUiType.Notification else EducationUiType.Toast
 }
diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt
index c2b5d98..661da6d 100644
--- a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt
+++ b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt
@@ -227,33 +227,13 @@
 }
 
 /**
- * Runs the given [block] in a new coroutine when `this` [View]'s Window's [WindowLifecycleState] is
- * at least at [state] (or immediately after calling this function if the window is already at least
- * at [state]), automatically canceling the work when the window is no longer at least at that
- * state.
- *
- * [block] may be run multiple times, running once per every time this` [View]'s Window's
- * [WindowLifecycleState] becomes at least at [state].
- */
-suspend fun View.repeatOnWindowLifecycle(
-    state: WindowLifecycleState,
-    block: suspend CoroutineScope.() -> Unit,
-): Nothing {
-    when (state) {
-        WindowLifecycleState.ATTACHED -> repeatWhenAttachedToWindow(block)
-        WindowLifecycleState.VISIBLE -> repeatWhenWindowIsVisible(block)
-        WindowLifecycleState.FOCUSED -> repeatWhenWindowHasFocus(block)
-    }
-}
-
-/**
  * Runs the given [block] every time the [View] becomes attached (or immediately after calling this
  * function, if the view was already attached), automatically canceling the work when the view
  * becomes detached.
  *
  * Only use from the main thread.
  *
- * [block] may be run multiple times, running once per every time the view is attached.
+ * The [block] may be run multiple times, running once per every time the view is attached.
  */
 @MainThread
 suspend fun View.repeatWhenAttachedToWindow(block: suspend CoroutineScope.() -> Unit): Nothing {
@@ -269,7 +249,7 @@
  *
  * Only use from the main thread.
  *
- * [block] may be run multiple times, running once per every time the window becomes visible.
+ * The [block] may be run multiple times, running once per every time the window becomes visible.
  */
 @MainThread
 suspend fun View.repeatWhenWindowIsVisible(block: suspend CoroutineScope.() -> Unit): Nothing {
@@ -285,7 +265,7 @@
  *
  * Only use from the main thread.
  *
- * [block] may be run multiple times, running once per every time the window is focused.
+ * The [block] may be run multiple times, running once per every time the window is focused.
  */
 @MainThread
 suspend fun View.repeatWhenWindowHasFocus(block: suspend CoroutineScope.() -> Unit): Nothing {
@@ -294,21 +274,6 @@
     awaitCancellation() // satisfies return type of Nothing
 }
 
-/** Lifecycle states for a [View]'s interaction with a [android.view.Window]. */
-enum class WindowLifecycleState {
-    /** Indicates that the [View] is attached to a [android.view.Window]. */
-    ATTACHED,
-    /**
-     * Indicates that the [View] is attached to a [android.view.Window], and the window is visible.
-     */
-    VISIBLE,
-    /**
-     * Indicates that the [View] is attached to a [android.view.Window], and the window is visible
-     * and focused.
-     */
-    FOCUSED
-}
-
 private val View.isAttached
     get() = conflatedCallbackFlow {
         val onAttachListener =
diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt
index 7731481..0af5fea 100644
--- a/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt
@@ -16,10 +16,9 @@
 
 package com.android.systemui.lifecycle
 
-import android.view.View
 import androidx.compose.runtime.Composable
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
 
 /** Base class for all System UI view-models. */
 abstract class SysUiViewModel : SafeActivatable() {
@@ -38,20 +37,8 @@
 fun <T : SysUiViewModel> rememberViewModel(
     key: Any = Unit,
     factory: () -> T,
-): T = rememberActivated(key, factory)
-
-/**
- * Invokes [block] in a new coroutine with a new [SysUiViewModel] that is automatically activated
- * whenever `this` [View]'s Window's [WindowLifecycleState] is at least at
- * [minWindowLifecycleState], and is automatically canceled once that is no longer the case.
- */
-suspend fun <T : SysUiViewModel> View.viewModel(
-    minWindowLifecycleState: WindowLifecycleState,
-    factory: () -> T,
-    block: suspend CoroutineScope.(T) -> Unit,
-): Nothing =
-    repeatOnWindowLifecycle(minWindowLifecycleState) {
-        val instance = factory()
-        launch { instance.activate() }
-        block(instance)
-    }
+): T {
+    val instance = remember(key) { factory() }
+    LaunchedEffect(instance) { instance.activate() }
+    return instance
+}
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
index 540d4c4..7b802a2 100644
--- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
@@ -17,7 +17,6 @@
 package com.android.systemui.screenshot;
 
 import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
-import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT;
 
 import static com.android.systemui.Flags.screenshotSaveImageExporter;
 import static com.android.systemui.screenshot.LogConfig.DEBUG_ANIM;
@@ -31,7 +30,6 @@
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
-import android.annotation.MainThread;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.BroadcastReceiver;
@@ -52,17 +50,12 @@
 import android.util.Log;
 import android.view.Display;
 import android.view.ScrollCaptureResponse;
-import android.view.View;
-import android.view.ViewGroup;
 import android.view.ViewRootImpl;
-import android.view.ViewTreeObserver;
-import android.view.WindowInsets;
 import android.view.WindowManager;
 import android.widget.Toast;
 import android.window.WindowContext;
 
 import com.android.internal.logging.UiEventLogger;
-import com.android.internal.policy.PhoneWindow;
 import com.android.settingslib.applications.InterestingConfigChanges;
 import com.android.systemui.broadcast.BroadcastDispatcher;
 import com.android.systemui.broadcast.BroadcastSender;
@@ -115,11 +108,9 @@
     private final BroadcastDispatcher mBroadcastDispatcher;
     private final ScreenshotActionsController mActionsController;
 
-    private final WindowManager mWindowManager;
-    private final WindowManager.LayoutParams mWindowLayoutParams;
     @Nullable
     private final ScreenshotSoundController mScreenshotSoundController;
-    private final PhoneWindow mWindow;
+    private final ScreenshotWindow mWindow;
     private final Display mDisplay;
     private final ScrollCaptureExecutor mScrollCaptureExecutor;
     private final ScreenshotNotificationSmartActionsProvider
@@ -135,8 +126,6 @@
     private Bitmap mScreenBitmap;
     private SaveImageInBackgroundTask mSaveInBgTask;
     private boolean mScreenshotTakenInPortrait;
-    private boolean mAttachRequested;
-    private boolean mDetachRequested;
     private Animator mScreenshotAnimation;
     private RequestCallback mCurrentRequestCallback;
     private String mPackageName = "";
@@ -155,7 +144,7 @@
     @AssistedInject
     ScreenshotController(
             Context context,
-            WindowManager windowManager,
+            ScreenshotWindow.Factory screenshotWindowFactory,
             FeatureFlags flags,
             ScreenshotShelfViewProxy.Factory viewProxyFactory,
             ScreenshotSmartActions screenshotSmartActions,
@@ -195,9 +184,8 @@
         mScreenshotHandler.setDefaultTimeoutMillis(SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS);
 
         mDisplay = display;
-        mWindowManager = windowManager;
-        final Context displayContext = context.createDisplayContext(display);
-        mContext = (WindowContext) displayContext.createWindowContext(TYPE_SCREENSHOT, null);
+        mWindow = screenshotWindowFactory.create(mDisplay);
+        mContext = mWindow.getContext();
         mFlags = flags;
         mUserManager = userManager;
         mMessageContainerController = messageContainerController;
@@ -213,17 +201,10 @@
             mViewProxy.requestDismissal(SCREENSHOT_INTERACTION_TIMEOUT);
         });
 
-        // Setup the window that we are going to use
-        mWindowLayoutParams = FloatingWindowUtil.getFloatingWindowParams();
-        mWindowLayoutParams.setTitle("ScreenshotAnimation");
-
-        mWindow = FloatingWindowUtil.getFloatingWindow(mContext);
-        mWindow.setWindowManager(mWindowManager, null, null);
-
         mConfigChanges.applyNewConfig(context.getResources());
         reloadAssets();
 
-        mActionExecutor = actionExecutorFactory.create(mWindow, mViewProxy,
+        mActionExecutor = actionExecutorFactory.create(mWindow.getWindow(), mViewProxy,
                 () -> {
                     finishDismiss();
                     return Unit.INSTANCE;
@@ -318,12 +299,12 @@
         }
 
         // The window is focusable by default
-        setWindowFocusable(true);
+        mWindow.setFocusable(true);
         mViewProxy.requestFocus();
 
         enqueueScrollCaptureRequest(requestId, screenshot.getUserHandle());
 
-        attachWindow();
+        mWindow.attachWindow();
 
         boolean showFlash;
         if (screenshot.getType() == WindowManager.TAKE_SCREENSHOT_PROVIDED_IMAGE) {
@@ -347,13 +328,10 @@
 
         mViewProxy.setScreenshot(screenshot);
 
-        // ignore system bar insets for the purpose of window layout
-        mWindow.getDecorView().setOnApplyWindowInsetsListener(
-                (v, insets) -> WindowInsets.CONSUMED);
     }
 
     void prepareViewForNewScreenshot(@NonNull ScreenshotData screenshot, String oldPackageName) {
-        withWindowAttached(() -> {
+        mWindow.whenWindowAttached(() -> {
             mAnnouncementResolver.getScreenshotAnnouncement(
                     screenshot.getUserHandle().getIdentifier(),
                     announcement -> {
@@ -444,7 +422,7 @@
             @Override
             public void onTouchOutside() {
                 // TODO(159460485): Remove this when focus is handled properly in the system
-                setWindowFocusable(false);
+                mWindow.setFocusable(false);
             }
         });
 
@@ -457,9 +435,9 @@
     private void enqueueScrollCaptureRequest(UUID requestId, UserHandle owner) {
         // Wait until this window is attached to request because it is
         // the reference used to locate the target window (below).
-        withWindowAttached(() -> {
+        mWindow.whenWindowAttached(() -> {
             requestScrollCapture(requestId, owner);
-            mWindow.peekDecorView().getViewRootImpl().setActivityConfigCallback(
+            mWindow.setActivityConfigCallback(
                     new ViewRootImpl.ActivityConfigCallback() {
                         @Override
                         public void onConfigurationChanged(Configuration overrideConfig,
@@ -472,8 +450,7 @@
                                 // to set up in the new orientation.
                                 mScreenshotHandler.postDelayed(
                                         () -> requestScrollCapture(requestId, owner), 150);
-                                mViewProxy.updateInsets(
-                                        mWindowManager.getCurrentWindowMetrics().getWindowInsets());
+                                mViewProxy.updateInsets(mWindow.getWindowInsets());
                                 // Screenshot animation calculations won't be valid anymore,
                                 // so just end
                                 if (mScreenshotAnimation != null
@@ -489,7 +466,7 @@
     private void requestScrollCapture(UUID requestId, UserHandle owner) {
         mScrollCaptureExecutor.requestScrollCapture(
                 mDisplay.getDisplayId(),
-                mWindow.getDecorView().getWindowToken(),
+                mWindow.getWindowToken(),
                 (response) -> {
                     mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_IMPRESSION,
                             0, response.getPackageName());
@@ -528,61 +505,9 @@
                 mViewProxy::startLongScreenshotTransition);
     }
 
-    private void withWindowAttached(Runnable action) {
-        View decorView = mWindow.getDecorView();
-        if (decorView.isAttachedToWindow()) {
-            action.run();
-        } else {
-            decorView.getViewTreeObserver().addOnWindowAttachListener(
-                    new ViewTreeObserver.OnWindowAttachListener() {
-                        @Override
-                        public void onWindowAttached() {
-                            mAttachRequested = false;
-                            decorView.getViewTreeObserver().removeOnWindowAttachListener(this);
-                            action.run();
-                        }
-
-                        @Override
-                        public void onWindowDetached() {
-                        }
-                    });
-
-        }
-    }
-
-    @MainThread
-    private void attachWindow() {
-        View decorView = mWindow.getDecorView();
-        if (decorView.isAttachedToWindow() || mAttachRequested) {
-            return;
-        }
-        if (DEBUG_WINDOW) {
-            Log.d(TAG, "attachWindow");
-        }
-        mAttachRequested = true;
-        mWindowManager.addView(decorView, mWindowLayoutParams);
-        decorView.requestApplyInsets();
-
-        ViewGroup layout = decorView.requireViewById(android.R.id.content);
-        layout.setClipChildren(false);
-        layout.setClipToPadding(false);
-    }
-
     @Override
     public void removeWindow() {
-        final View decorView = mWindow.peekDecorView();
-        if (decorView != null && decorView.isAttachedToWindow()) {
-            if (DEBUG_WINDOW) {
-                Log.d(TAG, "Removing screenshot window");
-            }
-            mWindowManager.removeViewImmediate(decorView);
-            mDetachRequested = false;
-        }
-        if (mAttachRequested && !mDetachRequested) {
-            mDetachRequested = true;
-            withWindowAttached(this::removeWindow);
-        }
-
+        mWindow.removeWindow();
         mViewProxy.stopInputListening();
     }
 
@@ -759,33 +684,6 @@
                 .getContentResolver(), SETTINGS_SECURE_USER_SETUP_COMPLETE, 0) == 1;
     }
 
-    /**
-     * Updates the window focusability.  If the window is already showing, then it updates the
-     * window immediately, otherwise the layout params will be applied when the window is next
-     * shown.
-     */
-    private void setWindowFocusable(boolean focusable) {
-        if (DEBUG_WINDOW) {
-            Log.d(TAG, "setWindowFocusable: " + focusable);
-        }
-        int flags = mWindowLayoutParams.flags;
-        if (focusable) {
-            mWindowLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
-        } else {
-            mWindowLayoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
-        }
-        if (mWindowLayoutParams.flags == flags) {
-            if (DEBUG_WINDOW) {
-                Log.d(TAG, "setWindowFocusable: skipping, already " + focusable);
-            }
-            return;
-        }
-        final View decorView = mWindow.peekDecorView();
-        if (decorView != null && decorView.isAttachedToWindow()) {
-            mWindowManager.updateViewLayout(decorView, mWindowLayoutParams);
-        }
-    }
-
     private Rect getFullScreenRect() {
         DisplayMetrics displayMetrics = new DisplayMetrics();
         mDisplay.getRealMetrics(displayMetrics);
@@ -826,6 +724,6 @@
          *
          * @param display                 display to capture
          */
-        LegacyScreenshotController create(Display display);
+        ScreenshotController create(Display display);
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotWindow.kt b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotWindow.kt
new file mode 100644
index 0000000..644e12c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotWindow.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.screenshot
+
+import android.R
+import android.annotation.MainThread
+import android.content.Context
+import android.graphics.PixelFormat
+import android.os.IBinder
+import android.util.Log
+import android.view.Display
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewRootImpl
+import android.view.ViewTreeObserver.OnWindowAttachListener
+import android.view.Window
+import android.view.WindowInsets
+import android.view.WindowManager
+import android.window.WindowContext
+import com.android.internal.policy.PhoneWindow
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+
+/** Creates and manages the window in which the screenshot UI is displayed. */
+class ScreenshotWindow
+@AssistedInject
+constructor(
+    private val windowManager: WindowManager,
+    private val context: Context,
+    @Assisted private val display: Display,
+) {
+
+    val window: PhoneWindow =
+        PhoneWindow(
+            context
+                .createDisplayContext(display)
+                .createWindowContext(WindowManager.LayoutParams.TYPE_SCREENSHOT, null)
+        )
+    private val params =
+        WindowManager.LayoutParams(
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                ViewGroup.LayoutParams.MATCH_PARENT,
+                0, /* xpos */
+                0, /* ypos */
+                WindowManager.LayoutParams.TYPE_SCREENSHOT,
+                WindowManager.LayoutParams.FLAG_FULLSCREEN or
+                    WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+                    WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
+                    WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or
+                    WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
+                    WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
+                PixelFormat.TRANSLUCENT
+            )
+            .apply {
+                layoutInDisplayCutoutMode =
+                    WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
+                setFitInsetsTypes(0)
+                // This is needed to let touches pass through outside the touchable areas
+                privateFlags =
+                    privateFlags or WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY
+                title = "ScreenshotUI"
+            }
+    private var attachRequested: Boolean = false
+    private var detachRequested: Boolean = false
+
+    init {
+        window.requestFeature(Window.FEATURE_NO_TITLE)
+        window.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS)
+        window.setBackgroundDrawableResource(R.color.transparent)
+        window.setWindowManager(windowManager, null, null)
+    }
+
+    @MainThread
+    fun attachWindow() {
+        val decorView: View = window.getDecorView()
+        if (decorView.isAttachedToWindow || attachRequested) {
+            return
+        }
+        if (LogConfig.DEBUG_WINDOW) {
+            Log.d(TAG, "attachWindow")
+        }
+        attachRequested = true
+        windowManager.addView(decorView, params)
+
+        decorView.requestApplyInsets()
+        decorView.requireViewById<ViewGroup>(R.id.content).apply {
+            clipChildren = false
+            clipToPadding = false
+            // ignore system bar insets for the purpose of window layout
+            setOnApplyWindowInsetsListener { _, _ -> WindowInsets.CONSUMED }
+        }
+    }
+
+    fun whenWindowAttached(action: Runnable) {
+        val decorView: View = window.getDecorView()
+        if (decorView.isAttachedToWindow) {
+            action.run()
+        } else {
+            decorView
+                .getViewTreeObserver()
+                .addOnWindowAttachListener(
+                    object : OnWindowAttachListener {
+                        override fun onWindowAttached() {
+                            attachRequested = false
+                            decorView.getViewTreeObserver().removeOnWindowAttachListener(this)
+                            action.run()
+                        }
+
+                        override fun onWindowDetached() {}
+                    }
+                )
+        }
+    }
+
+    fun removeWindow() {
+        val decorView: View? = window.peekDecorView()
+        if (decorView != null && decorView.isAttachedToWindow) {
+            if (LogConfig.DEBUG_WINDOW) {
+                Log.d(TAG, "Removing screenshot window")
+            }
+            windowManager.removeViewImmediate(decorView)
+            detachRequested = false
+        }
+        if (attachRequested && !detachRequested) {
+            detachRequested = true
+            whenWindowAttached { removeWindow() }
+        }
+    }
+
+    /**
+     * Updates the window focusability. If the window is already showing, then it updates the window
+     * immediately, otherwise the layout params will be applied when the window is next shown.
+     */
+    fun setFocusable(focusable: Boolean) {
+        if (LogConfig.DEBUG_WINDOW) {
+            Log.d(TAG, "setWindowFocusable: $focusable")
+        }
+        val flags: Int = params.flags
+        if (focusable) {
+            params.flags = params.flags and WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv()
+        } else {
+            params.flags = params.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+        }
+        if (params.flags == flags) {
+            if (LogConfig.DEBUG_WINDOW) {
+                Log.d(TAG, "setWindowFocusable: skipping, already $focusable")
+            }
+            return
+        }
+        window.peekDecorView()?.also {
+            if (it.isAttachedToWindow) {
+                windowManager.updateViewLayout(it, params)
+            }
+        }
+    }
+
+    fun getContext(): WindowContext = window.context as WindowContext
+
+    fun getWindowToken(): IBinder = window.decorView.windowToken
+
+    fun getWindowInsets(): WindowInsets = windowManager.currentWindowMetrics.windowInsets
+
+    fun setContentView(view: View) {
+        window.setContentView(view)
+    }
+
+    fun setActivityConfigCallback(callback: ViewRootImpl.ActivityConfigCallback) {
+        window.peekDecorView().viewRootImpl.setActivityConfigCallback(callback)
+    }
+
+    @AssistedFactory
+    interface Factory {
+        fun create(display: Display): ScreenshotWindow
+    }
+
+    companion object {
+        private const val TAG = "ScreenshotWindow"
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/CommandQueue.java b/packages/SystemUI/src/com/android/systemui/statusbar/CommandQueue.java
index cea97d6..50be6dc 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/CommandQueue.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/CommandQueue.java
@@ -19,7 +19,6 @@
 import static android.app.StatusBarManager.DISABLE2_NONE;
 import static android.app.StatusBarManager.DISABLE_NONE;
 import static android.inputmethodservice.InputMethodService.BACK_DISPOSITION_DEFAULT;
-import static android.inputmethodservice.InputMethodService.IME_INVISIBLE;
 import static android.view.Display.INVALID_DISPLAY;
 
 import android.annotation.Nullable;
@@ -1219,7 +1218,7 @@
                 && mLastUpdatedImeDisplayId != INVALID_DISPLAY) {
             // Set previous NavBar's IME window status as invisible when IME
             // window switched to another display for single-session IME case.
-            sendImeInvisibleStatusForPrevNavBar();
+            sendImeNotVisibleStatusForPrevNavBar();
         }
         for (int i = 0; i < mCallbacks.size(); i++) {
             mCallbacks.get(i).setImeWindowStatus(displayId, vis, backDisposition, showImeSwitcher);
@@ -1227,9 +1226,9 @@
         mLastUpdatedImeDisplayId = displayId;
     }
 
-    private void sendImeInvisibleStatusForPrevNavBar() {
+    private void sendImeNotVisibleStatusForPrevNavBar() {
         for (int i = 0; i < mCallbacks.size(); i++) {
-            mCallbacks.get(i).setImeWindowStatus(mLastUpdatedImeDisplayId, IME_INVISIBLE,
+            mCallbacks.get(i).setImeWindowStatus(mLastUpdatedImeDisplayId, 0 /* vis */,
                     BACK_DISPOSITION_DEFAULT, false /* showImeSwitcher */);
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationScrollViewBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationScrollViewBinder.kt
index a30b877..fd08e89 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationScrollViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewbinder/NotificationScrollViewBinder.kt
@@ -17,14 +17,14 @@
 package com.android.systemui.statusbar.notification.stack.ui.viewbinder
 
 import android.util.Log
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.repeatOnLifecycle
 import com.android.systemui.common.ui.ConfigurationState
 import com.android.systemui.common.ui.view.onLayoutChanged
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.dump.DumpManager
-import com.android.systemui.lifecycle.WindowLifecycleState
 import com.android.systemui.lifecycle.repeatWhenAttached
-import com.android.systemui.lifecycle.viewModel
 import com.android.systemui.res.R
 import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationScrollViewModel
@@ -33,6 +33,7 @@
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.launch
@@ -45,7 +46,7 @@
     dumpManager: DumpManager,
     @Main private val mainImmediateDispatcher: CoroutineDispatcher,
     private val view: NotificationScrollView,
-    private val viewModelFactory: NotificationScrollViewModel.Factory,
+    private val viewModel: NotificationScrollViewModel,
     private val configuration: ConfigurationState,
 ) : FlowDumperImpl(dumpManager) {
 
@@ -60,42 +61,38 @@
     }
 
     fun bindWhileAttached(): DisposableHandle {
-        return view.asView().repeatWhenAttached(mainImmediateDispatcher) { bind() }
+        return view.asView().repeatWhenAttached(mainImmediateDispatcher) {
+            repeatOnLifecycle(Lifecycle.State.CREATED) { bind() }
+        }
     }
 
-    suspend fun bind(): Nothing =
-        view.asView().viewModel(
-            minWindowLifecycleState = WindowLifecycleState.ATTACHED,
-            factory = viewModelFactory::create,
-        ) { viewModel ->
-            launchAndDispose {
-                updateViewPosition()
-                view.asView().onLayoutChanged { updateViewPosition() }
-            }
+    suspend fun bind() = coroutineScope {
+        launchAndDispose {
+            updateViewPosition()
+            view.asView().onLayoutChanged { updateViewPosition() }
+        }
 
-            launch {
-                viewModel
-                    .shadeScrimShape(cornerRadius = scrimRadius, viewLeftOffset = viewLeftOffset)
-                    .collect { view.setScrimClippingShape(it) }
-            }
+        launch {
+            viewModel
+                .shadeScrimShape(cornerRadius = scrimRadius, viewLeftOffset = viewLeftOffset)
+                .collect { view.setScrimClippingShape(it) }
+        }
 
-            launch { viewModel.maxAlpha.collect { view.setMaxAlpha(it) } }
-            launch { viewModel.scrolledToTop.collect { view.setScrolledToTop(it) } }
-            launch {
-                viewModel.expandFraction.collect { view.setExpandFraction(it.coerceIn(0f, 1f)) }
-            }
-            launch { viewModel.isScrollable.collect { view.setScrollingEnabled(it) } }
-            launch { viewModel.isDozing.collect { isDozing -> view.setDozing(isDozing) } }
+        launch { viewModel.maxAlpha.collect { view.setMaxAlpha(it) } }
+        launch { viewModel.scrolledToTop.collect { view.setScrolledToTop(it) } }
+        launch { viewModel.expandFraction.collect { view.setExpandFraction(it.coerceIn(0f, 1f)) } }
+        launch { viewModel.isScrollable.collect { view.setScrollingEnabled(it) } }
+        launch { viewModel.isDozing.collect { isDozing -> view.setDozing(isDozing) } }
 
-            launchAndDispose {
-                view.setSyntheticScrollConsumer(viewModel.syntheticScrollConsumer)
-                view.setCurrentGestureOverscrollConsumer(viewModel.currentGestureOverscrollConsumer)
-                DisposableHandle {
-                    view.setSyntheticScrollConsumer(null)
-                    view.setCurrentGestureOverscrollConsumer(null)
-                }
+        launchAndDispose {
+            view.setSyntheticScrollConsumer(viewModel.syntheticScrollConsumer)
+            view.setCurrentGestureOverscrollConsumer(viewModel.currentGestureOverscrollConsumer)
+            DisposableHandle {
+                view.setSyntheticScrollConsumer(null)
+                view.setCurrentGestureOverscrollConsumer(null)
             }
         }
+    }
 
     /** flow of the scrim clipping radius */
     private val scrimRadius: Flow<Int>
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt
index 4281025..2ba79a8 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/ui/viewmodel/NotificationScrollViewModel.kt
@@ -19,9 +19,9 @@
 
 import com.android.compose.animation.scene.ObservableTransitionState
 import com.android.compose.animation.scene.SceneKey
+import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.dump.DumpManager
 import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
-import com.android.systemui.lifecycle.SysUiViewModel
 import com.android.systemui.scene.domain.interactor.SceneInteractor
 import com.android.systemui.scene.shared.flag.SceneContainerFlag
 import com.android.systemui.scene.shared.model.SceneFamilies
@@ -33,11 +33,9 @@
 import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimShape
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_DELAYED_STACK_FADE_IN
 import com.android.systemui.statusbar.notification.stack.ui.viewmodel.NotificationTransitionThresholds.EXPANSION_FOR_MAX_SCRIM_ALPHA
-import com.android.systemui.util.kotlin.FlowDumper
 import com.android.systemui.util.kotlin.FlowDumperImpl
 import dagger.Lazy
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
+import javax.inject.Inject
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
@@ -45,8 +43,9 @@
 import kotlinx.coroutines.flow.map
 
 /** ViewModel which represents the state of the NSSL/Controller in the world of flexiglass */
+@SysUISingleton
 class NotificationScrollViewModel
-@AssistedInject
+@Inject
 constructor(
     dumpManager: DumpManager,
     stackAppearanceInteractor: NotificationStackAppearanceInteractor,
@@ -55,9 +54,7 @@
     // TODO(b/336364825) Remove Lazy when SceneContainerFlag is released -
     // while the flag is off, creating this object too early results in a crash
     keyguardInteractor: Lazy<KeyguardInteractor>,
-) : FlowDumper by FlowDumperImpl(dumpManager, "NotificationScrollViewModel"),
-    SysUiViewModel() {
-
+) : FlowDumperImpl(dumpManager) {
     /**
      * The expansion fraction of the notification stack. It should go from 0 to 1 when transitioning
      * from Gone to Shade scenes, and remain at 1 when in Lockscreen or Shade scenes and while
@@ -189,9 +186,4 @@
             keyguardInteractor.get().isDozing.dumpWhileCollecting("isDozing")
         }
     }
-
-    @AssistedFactory
-    interface Factory {
-        fun create(): NotificationScrollViewModel
-    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/views/NavigationBarTest.java b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/views/NavigationBarTest.java
index a8cbbd4..a52ab0c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/navigationbar/views/NavigationBarTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/navigationbar/views/NavigationBarTest.java
@@ -20,7 +20,6 @@
 import static android.app.StatusBarManager.NAVIGATION_HINT_IME_SHOWN;
 import static android.app.StatusBarManager.NAVIGATION_HINT_IME_SWITCHER_SHOWN;
 import static android.inputmethodservice.InputMethodService.BACK_DISPOSITION_DEFAULT;
-import static android.inputmethodservice.InputMethodService.IME_INVISIBLE;
 import static android.inputmethodservice.InputMethodService.IME_VISIBLE;
 import static android.view.Display.DEFAULT_DISPLAY;
 import static android.view.DisplayAdjustments.DEFAULT_DISPLAY_ADJUSTMENTS;
@@ -512,7 +511,7 @@
 
         externalNavBar.setImeWindowStatus(EXTERNAL_DISPLAY_ID, IME_VISIBLE,
                 BACK_DISPOSITION_DEFAULT, true);
-        defaultNavBar.setImeWindowStatus(DEFAULT_DISPLAY, IME_INVISIBLE,
+        defaultNavBar.setImeWindowStatus(DEFAULT_DISPLAY, 0 /* vis */,
                 BACK_DISPOSITION_DEFAULT, false);
         // Verify IME window state will be updated in external NavBar & default NavBar state reset.
         assertEquals(NAVIGATION_HINT_BACK_ALT | NAVIGATION_HINT_IME_SHOWN
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/CommandQueueTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/CommandQueueTest.java
index 86d21e8..6916bbd 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/CommandQueueTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/CommandQueueTest.java
@@ -16,7 +16,6 @@
 
 import static android.hardware.biometrics.BiometricAuthenticator.TYPE_FACE;
 import static android.inputmethodservice.InputMethodService.BACK_DISPOSITION_DEFAULT;
-import static android.inputmethodservice.InputMethodService.IME_INVISIBLE;
 import static android.view.Display.DEFAULT_DISPLAY;
 import static android.view.WindowInsetsController.BEHAVIOR_DEFAULT;
 
@@ -207,7 +206,7 @@
 
         mCommandQueue.setImeWindowStatus(SECONDARY_DISPLAY, 1, 2, true);
         waitForIdleSync();
-        verify(mCallbacks).setImeWindowStatus(eq(DEFAULT_DISPLAY), eq(IME_INVISIBLE),
+        verify(mCallbacks).setImeWindowStatus(eq(DEFAULT_DISPLAY), eq(0),
                 eq(BACK_DISPOSITION_DEFAULT), eq(false));
         verify(mCallbacks).setImeWindowStatus(eq(SECONDARY_DISPLAY), eq(1), eq(2), eq(true));
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryKosmos.kt
index aac5e57..1d2bce2 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/ContextualEducationRepositoryKosmos.kt
@@ -17,10 +17,9 @@
 package com.android.systemui.education.data.repository
 
 import com.android.systemui.kosmos.Kosmos
-import java.time.Clock
 import java.time.Instant
 
 var Kosmos.contextualEducationRepository: ContextualEducationRepository by
     Kosmos.Fixture { FakeContextualEducationRepository() }
 
-var Kosmos.fakeEduClock: Clock by Kosmos.Fixture { FakeEduClock(Instant.MIN) }
+var Kosmos.fakeEduClock: FakeEduClock by Kosmos.Fixture { FakeEduClock(Instant.MIN) }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/FakeEduClock.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/FakeEduClock.kt
index 513c143..c9a5d4b 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/FakeEduClock.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/education/data/repository/FakeEduClock.kt
@@ -19,8 +19,9 @@
 import java.time.Clock
 import java.time.Instant
 import java.time.ZoneId
+import kotlin.time.Duration
 
-class FakeEduClock(private val base: Instant) : Clock() {
+class FakeEduClock(private var base: Instant) : Clock() {
     private val zone: ZoneId = ZoneId.of("UTC")
 
     override fun instant(): Instant {
@@ -34,4 +35,8 @@
     override fun getZone(): ZoneId {
         return zone
     }
+
+    fun offset(duration: Duration) {
+        base = base.plusSeconds(duration.inWholeSeconds)
+    }
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorKosmos.kt
index fb4e901..5088677 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/education/domain/interactor/KeyboardTouchpadEduInteractorKosmos.kt
@@ -16,6 +16,7 @@
 
 package com.android.systemui.education.domain.interactor
 
+import com.android.systemui.education.data.repository.fakeEduClock
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.testScope
 
@@ -23,7 +24,8 @@
     Kosmos.Fixture {
         KeyboardTouchpadEduInteractor(
             backgroundScope = testScope.backgroundScope,
-            contextualEducationInteractor = contextualEducationInteractor
+            contextualEducationInteractor = contextualEducationInteractor,
+            clock = fakeEduClock
         )
     }
 
diff --git a/services/core/java/com/android/server/inputmethod/InputMethodBindingController.java b/services/core/java/com/android/server/inputmethod/InputMethodBindingController.java
index 94b1473..079b724 100644
--- a/services/core/java/com/android/server/inputmethod/InputMethodBindingController.java
+++ b/services/core/java/com/android/server/inputmethod/InputMethodBindingController.java
@@ -109,10 +109,6 @@
      * <dd>
      *   If this bit is ON, some of IME view, e.g. software input, candidate view, is visible.
      * </dd>
-     * <dt>{@link InputMethodService#IME_INVISIBLE}</dt>
-     * <dd> If this bit is ON, IME is ready with views from last EditorInfo but is
-     *    currently invisible.
-     * </dd>
      * </dl>
      * <em>Do not update this value outside of {@link #setImeWindowStatus(IBinder, int, int)} and
      * {@link InputMethodBindingController#unbindCurrentMethod()}.</em>
diff --git a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
index 4dcc353..3863cf0 100644
--- a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
+++ b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java
@@ -2625,8 +2625,7 @@
                 && mWindowManagerInternal.isKeyguardSecure(userId)) {
             return false;
         }
-        if ((visibility & InputMethodService.IME_ACTIVE) == 0
-                || (visibility & InputMethodService.IME_INVISIBLE) != 0) {
+        if ((visibility & InputMethodService.IME_ACTIVE) == 0) {
             return false;
         }
         if (mWindowManagerInternal.isHardKeyboardAvailable() && !Flags.imeSwitcherRevamp()) {
@@ -2791,7 +2790,7 @@
         if (DEBUG) {
             Slog.d(TAG, "IME window vis: " + vis
                     + " active: " + (vis & InputMethodService.IME_ACTIVE)
-                    + " inv: " + (vis & InputMethodService.IME_INVISIBLE)
+                    + " visible: " + (vis & InputMethodService.IME_VISIBLE)
                     + " displayId: " + curTokenDisplayId);
         }
         final IBinder focusedWindowToken = userData.mImeBindingState != null
diff --git a/services/core/java/com/android/server/notification/NotificationAttentionHelper.java b/services/core/java/com/android/server/notification/NotificationAttentionHelper.java
index bd551fb..b4459cb 100644
--- a/services/core/java/com/android/server/notification/NotificationAttentionHelper.java
+++ b/services/core/java/com/android/server/notification/NotificationAttentionHelper.java
@@ -1194,9 +1194,9 @@
         }
 
         boolean shouldIgnoreNotification(final NotificationRecord record) {
-            // Ignore group summaries
-            return (record.getSbn().isGroup() && record.getSbn().getNotification()
-                    .isGroupSummary());
+            // Ignore auto-group summaries => don't count them as app-posted notifications
+            // for the cooldown budget
+            return (record.getSbn().isGroup() && GroupHelper.isAggregatedGroup(record));
         }
 
         /**
diff --git a/services/devicepolicy/java/com/android/server/devicepolicy/PolicyDefinition.java b/services/devicepolicy/java/com/android/server/devicepolicy/PolicyDefinition.java
index 8e3248e..f86d307 100644
--- a/services/devicepolicy/java/com/android/server/devicepolicy/PolicyDefinition.java
+++ b/services/devicepolicy/java/com/android/server/devicepolicy/PolicyDefinition.java
@@ -708,17 +708,15 @@
     }
 
     @Nullable
-    static <V> PolicyKey readPolicyKeyFromXml(TypedXmlPullParser parser)
+    static PolicyKey readPolicyKeyFromXml(TypedXmlPullParser parser)
             throws XmlPullParserException, IOException {
-        // TODO: can we avoid casting?
         PolicyKey policyKey = PolicyKey.readGenericPolicyKeyFromXml(parser);
         if (policyKey == null) {
             Slogf.wtf(TAG, "Error parsing PolicyKey, GenericPolicyKey is null");
             return null;
         }
-        PolicyDefinition<PolicyValue<V>> genericPolicyDefinition =
-                (PolicyDefinition<PolicyValue<V>>) POLICY_DEFINITIONS.get(
-                        policyKey.getIdentifier());
+        PolicyDefinition<?> genericPolicyDefinition =
+                POLICY_DEFINITIONS.get(policyKey.getIdentifier());
         if (genericPolicyDefinition == null) {
             Slogf.wtf(TAG, "Error parsing PolicyKey, Unknown generic policy key: " + policyKey);
             return null;
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java
index de70280..14ad15e 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationAttentionHelperTest.java
@@ -15,7 +15,9 @@
  */
 package com.android.server.notification;
 
+import static android.app.Notification.FLAG_AUTOGROUP_SUMMARY;
 import static android.app.Notification.FLAG_BUBBLE;
+import static android.app.Notification.FLAG_GROUP_SUMMARY;
 import static android.app.Notification.GROUP_ALERT_ALL;
 import static android.app.Notification.GROUP_ALERT_CHILDREN;
 import static android.app.Notification.GROUP_ALERT_SUMMARY;
@@ -539,6 +541,36 @@
         return r;
     }
 
+    private NotificationRecord getAutogroupSummaryNotificationRecord(int id, String groupKey,
+            int groupAlertBehavior, UserHandle userHandle, String packageName) {
+        final Builder builder = new Builder(getContext())
+                .setContentTitle("foo")
+                .setSmallIcon(android.R.drawable.sym_def_app_icon)
+                .setPriority(Notification.PRIORITY_HIGH)
+                .setFlag(FLAG_GROUP_SUMMARY | FLAG_AUTOGROUP_SUMMARY, true);
+
+        int defaults = 0;
+        defaults |= Notification.DEFAULT_SOUND;
+        mChannel.setSound(Settings.System.DEFAULT_NOTIFICATION_URI,
+                Notification.AUDIO_ATTRIBUTES_DEFAULT);
+
+        builder.setDefaults(defaults);
+        builder.setGroup(groupKey);
+        builder.setGroupAlertBehavior(groupAlertBehavior);
+        Notification n = builder.build();
+
+        Context context = spy(getContext());
+        PackageManager packageManager = spy(context.getPackageManager());
+        when(context.getPackageManager()).thenReturn(packageManager);
+        when(packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)).thenReturn(false);
+
+        StatusBarNotification sbn = new StatusBarNotification(packageName, packageName, id, mTag,
+                mUid, mPid, n, userHandle, null, System.currentTimeMillis());
+        NotificationRecord r = new NotificationRecord(context, sbn, mChannel);
+        mService.addNotification(r);
+        return r;
+    }
+
     //
     // Convenience functions for interacting with mocks
     //
@@ -2603,6 +2635,79 @@
     }
 
     @Test
+    public void testBeepVolume_politeNotif_justSummaries() throws Exception {
+        mSetFlagsRule.enableFlags(Flags.FLAG_POLITE_NOTIFICATIONS);
+        mSetFlagsRule.disableFlags(Flags.FLAG_CROSS_APP_POLITE_NOTIFICATIONS);
+        TestableFlagResolver flagResolver = new TestableFlagResolver();
+        flagResolver.setFlagOverride(NotificationFlags.NOTIF_VOLUME1, 50);
+        flagResolver.setFlagOverride(NotificationFlags.NOTIF_VOLUME2, 0);
+        // NOTIFICATION_COOLDOWN_ALL setting is enabled
+        Settings.System.putInt(getContext().getContentResolver(),
+                Settings.System.NOTIFICATION_COOLDOWN_ALL, 1);
+        initAttentionHelper(flagResolver);
+
+        NotificationRecord summary = getBeepyNotificationRecord("a", GROUP_ALERT_ALL);
+        summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY;
+
+        // first update at 100% volume
+        mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS);
+        assertNotEquals(-1, summary.getLastAudiblyAlertedMs());
+        verifyBeepVolume(1.0f);
+        Mockito.reset(mRingtonePlayer);
+
+        // update should beep at 50% volume
+        summary = getBeepyNotificationRecord("a", GROUP_ALERT_ALL);
+        summary.getNotification().flags |= Notification.FLAG_GROUP_SUMMARY;
+        mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS);
+        assertNotEquals(-1, summary.getLastAudiblyAlertedMs());
+        verifyBeepVolume(0.5f);
+        Mockito.reset(mRingtonePlayer);
+
+        // next update at 0% volume
+        mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS);
+        assertEquals(-1, summary.getLastAudiblyAlertedMs());
+        verifyBeepVolume(0.0f);
+
+        verify(mAccessibilityService, times(3)).sendAccessibilityEvent(any(), anyInt());
+    }
+
+    @Test
+    public void testBeepVolume_politeNotif_autogroupSummary() throws Exception {
+        mSetFlagsRule.enableFlags(Flags.FLAG_POLITE_NOTIFICATIONS);
+        mSetFlagsRule.disableFlags(Flags.FLAG_CROSS_APP_POLITE_NOTIFICATIONS);
+        TestableFlagResolver flagResolver = new TestableFlagResolver();
+        flagResolver.setFlagOverride(NotificationFlags.NOTIF_VOLUME1, 50);
+        flagResolver.setFlagOverride(NotificationFlags.NOTIF_VOLUME2, 0);
+        // NOTIFICATION_COOLDOWN_ALL setting is enabled
+        Settings.System.putInt(getContext().getContentResolver(),
+                Settings.System.NOTIFICATION_COOLDOWN_ALL, 1);
+        initAttentionHelper(flagResolver);
+
+        // child should beep at 100% volume
+        NotificationRecord child = getBeepyNotificationRecord("a", GROUP_ALERT_ALL);
+        mAttentionHelper.buzzBeepBlinkLocked(child, DEFAULT_SIGNALS);
+        assertNotEquals(-1, child.getLastAudiblyAlertedMs());
+        verifyBeepVolume(1.0f);
+        Mockito.reset(mRingtonePlayer);
+
+        // summary 0% volume (GROUP_ALERT_CHILDREN)
+        NotificationRecord summary = getAutogroupSummaryNotificationRecord(mId, "a",
+                GROUP_ALERT_CHILDREN, mUser, mPkg);
+        mAttentionHelper.buzzBeepBlinkLocked(summary, DEFAULT_SIGNALS);
+        verifyNeverBeep();
+        assertFalse(summary.isInterruptive());
+        assertEquals(-1, summary.getLastAudiblyAlertedMs());
+        Mockito.reset(mRingtonePlayer);
+
+        // next update at 50% volume because autogroup summary was ignored
+        mAttentionHelper.buzzBeepBlinkLocked(child, DEFAULT_SIGNALS);
+        assertNotEquals(-1, child.getLastAudiblyAlertedMs());
+        verifyBeepVolume(0.5f);
+
+        verify(mAccessibilityService, times(3)).sendAccessibilityEvent(any(), anyInt());
+    }
+
+    @Test
     public void testBeepVolume_politeNotif_applyPerApp() throws Exception {
         mSetFlagsRule.enableFlags(Flags.FLAG_POLITE_NOTIFICATIONS);
         mSetFlagsRule.disableFlags(Flags.FLAG_CROSS_APP_POLITE_NOTIFICATIONS);
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatActivityRobot.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatActivityRobot.java
index d582b07..a745724 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatActivityRobot.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatActivityRobot.java
@@ -80,19 +80,33 @@
     @Nullable
     private Consumer<ActivityRecord> mOnPostActivityCreation;
 
+    @Nullable
+    private Consumer<DisplayContent> mOnPostDisplayContentCreation;
+
     AppCompatActivityRobot(@NonNull WindowManagerService wm,
             @NonNull ActivityTaskManagerService atm, @NonNull ActivityTaskSupervisor supervisor,
-            int displayWidth, int displayHeight) {
+            int displayWidth, int displayHeight,
+            @Nullable Consumer<ActivityRecord> onPostActivityCreation,
+            @Nullable Consumer<DisplayContent> onPostDisplayContentCreation) {
         mAtm = atm;
         mSupervisor = supervisor;
         mDisplayWidth = displayWidth;
         mDisplayHeight = displayHeight;
         mActivityStack = new TestComponentStack<>();
         mTaskStack = new TestComponentStack<>();
+        mOnPostActivityCreation = onPostActivityCreation;
+        mOnPostDisplayContentCreation = onPostDisplayContentCreation;
         createNewDisplay();
     }
 
     AppCompatActivityRobot(@NonNull WindowManagerService wm,
+            @NonNull ActivityTaskManagerService atm, @NonNull ActivityTaskSupervisor supervisor,
+            int displayWidth, int displayHeight) {
+        this(wm, atm, supervisor, displayWidth, displayHeight, /* onPostActivityCreation */ null,
+                /* onPostDisplayContentCreation */ null);
+    }
+
+    AppCompatActivityRobot(@NonNull WindowManagerService wm,
             @NonNull ActivityTaskManagerService atm, @NonNull ActivityTaskSupervisor supervisor) {
         this(wm, atm, supervisor, DEFAULT_DISPLAY_WIDTH, DEFAULT_DISPLAY_HEIGHT);
     }
@@ -114,7 +128,6 @@
         createActivityWithComponentInNewTask(/* inNewTask */ true, /* inNewDisplay */ true);
     }
 
-
     void configureTopActivity(float minAspect, float maxAspect, int screenOrientation,
             boolean isUnresizable) {
         prepareLimitedBounds(mActivityStack.top(), minAspect, maxAspect, screenOrientation,
@@ -260,21 +273,20 @@
     void createNewDisplay() {
         mDisplayContent = new TestDisplayContent.Builder(mAtm, mDisplayWidth, mDisplayHeight)
                 .build();
-        spyOn(mDisplayContent);
-        spyOnAppCompatCameraPolicy();
+        onPostDisplayContentCreation(mDisplayContent);
     }
 
     void createNewTask() {
         final Task newTask = new WindowTestsBase.TaskBuilder(mSupervisor)
                 .setDisplay(mDisplayContent).build();
-        pushTask(newTask);
+        mTaskStack.push(newTask);
     }
 
     void createNewTaskWithBaseActivity() {
         final Task newTask = new WindowTestsBase.TaskBuilder(mSupervisor)
                 .setCreateActivity(true)
                 .setDisplay(mDisplayContent).build();
-        pushTask(newTask);
+        mTaskStack.push(newTask);
         pushActivity(newTask.getTopNonFinishingActivity());
     }
 
@@ -408,7 +420,6 @@
      */
     @CallSuper
     void onPostActivityCreation(@NonNull ActivityRecord activity) {
-        spyOn(activity);
         spyOn(activity.mLetterboxUiController);
         if (mOnPostActivityCreation != null) {
             mOnPostActivityCreation.accept(activity);
@@ -416,14 +427,17 @@
     }
 
     /**
-     * Each Robot can specify its own set of operation to execute on a newly created
-     * {@link ActivityRecord}. Most common the use of spyOn().
+     * Specific Robots can override this method to add operation to run on a newly created
+     * {@link DisplayContent}. Common case is to invoke spyOn().
      *
-     * @param onPostActivityCreation The reference to the code to execute after the creation of a
-     *                               new {@link ActivityRecord}.
+     * @param displayContent The newly created {@link DisplayContent}.
      */
-    void setOnPostActivityCreation(@Nullable Consumer<ActivityRecord> onPostActivityCreation) {
-        mOnPostActivityCreation = onPostActivityCreation;
+    @CallSuper
+    void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+        spyOn(mDisplayContent);
+        if (mOnPostDisplayContentCreation != null) {
+            mOnPostDisplayContentCreation.accept(mDisplayContent);
+        }
     }
 
     private void createActivityWithComponentInNewTask(boolean inNewTask, boolean inNewDisplay) {
@@ -489,28 +503,5 @@
     private void pushActivity(@NonNull ActivityRecord activity) {
         mActivityStack.push(activity);
         onPostActivityCreation(activity);
-        // TODO (b/351763164): Use these spyOn calls only when necessary.
-        spyOn(activity.mAppCompatController.getTransparentPolicy());
-        spyOn(activity.mAppCompatController.getAppCompatAspectRatioOverrides());
-        spyOn(activity.mAppCompatController.getAppCompatAspectRatioPolicy());
-        spyOn(activity.mAppCompatController.getAppCompatFocusOverrides());
-        spyOn(activity.mAppCompatController.getAppCompatResizeOverrides());
-        spyOn(activity.mAppCompatController.getAppCompatReachabilityPolicy());
-        spyOn(activity.mAppCompatController.getAppCompatReachabilityOverrides());
-    }
-
-    private void pushTask(@NonNull Task task) {
-        spyOn(task);
-        mTaskStack.push(task);
-    }
-
-    private void spyOnAppCompatCameraPolicy() {
-        spyOn(mDisplayContent.mAppCompatCameraPolicy);
-        if (mDisplayContent.mAppCompatCameraPolicy.hasDisplayRotationCompatPolicy()) {
-            spyOn(mDisplayContent.mAppCompatCameraPolicy.mDisplayRotationCompatPolicy);
-        }
-        if (mDisplayContent.mAppCompatCameraPolicy.hasCameraCompatFreeformPolicy()) {
-            spyOn(mDisplayContent.mAppCompatCameraPolicy.mCameraCompatFreeformPolicy);
-        }
     }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatAspectRatioOverridesTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatAspectRatioOverridesTest.java
index a6fd112..1e40aa0 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatAspectRatioOverridesTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatAspectRatioOverridesTest.java
@@ -291,7 +291,6 @@
      * Runs a test scenario providing a Robot.
      */
     void runTestScenario(@NonNull Consumer<AspectRatioOverridesRobotTest> consumer) {
-        spyOn(mWm.mAppCompatConfiguration);
         final AspectRatioOverridesRobotTest robot =
                 new AspectRatioOverridesRobotTest(mWm, mAtm, mSupervisor);
         consumer.accept(robot);
@@ -305,6 +304,18 @@
             super(wm, atm, supervisor);
         }
 
+        @Override
+        void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+            super.onPostDisplayContentCreation(displayContent);
+            spyOn(displayContent.mAppCompatCameraPolicy);
+        }
+
+        @Override
+        void onPostActivityCreation(@NonNull ActivityRecord activity) {
+            super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getAppCompatAspectRatioOverrides());
+        }
+
         void checkShouldApplyUserFullscreenOverride(boolean expected) {
             assertEquals(expected, getTopActivityAppCompatAspectRatioOverrides()
                     .shouldApplyUserFullscreenOverride());
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraOverridesTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraOverridesTest.java
index de99f54..84ffcb8 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraOverridesTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraOverridesTest.java
@@ -387,6 +387,12 @@
             super(wm, atm, supervisor);
         }
 
+        @Override
+        void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+            super.onPostDisplayContentCreation(displayContent);
+            spyOn(displayContent.mAppCompatCameraPolicy);
+        }
+
         void checkShouldRefreshActivityForCameraCompat(boolean expected) {
             Assert.assertEquals(getAppCompatCameraOverrides()
                     .shouldRefreshActivityForCameraCompat(), expected);
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraPolicyTest.java
index 0b1bb0f..c42228d 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraPolicyTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraPolicyTest.java
@@ -150,6 +150,12 @@
             super(wm, atm, supervisor);
         }
 
+        @Override
+        void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+            super.onPostDisplayContentCreation(displayContent);
+            spyOn(displayContent.mAppCompatCameraPolicy);
+        }
+
         void checkTopActivityHasDisplayRotationCompatPolicy(boolean exists) {
             Assert.assertEquals(exists, activity().top().mDisplayContent
                     .mAppCompatCameraPolicy.hasDisplayRotationCompatPolicy());
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationOverridesTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationOverridesTest.java
index 6c0d8c4..d9b5f37 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationOverridesTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationOverridesTest.java
@@ -250,6 +250,12 @@
             mTestCurrentTimeMillisSupplier = new CurrentTimeMillisSupplierFake();
         }
 
+        @Override
+        void onPostActivityCreation(@NonNull ActivityRecord activity) {
+            super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getAppCompatAspectRatioPolicy());
+        }
+
         // Useful to reduce timeout during tests
         void prepareMockedTime() {
             getTopOrientationOverrides().mOrientationOverridesState.mCurrentTimeMillisSupplier =
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java
index ad34a6b..f6d0744 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatOrientationPolicyTest.java
@@ -536,6 +536,25 @@
             }
         }
 
+        @Override
+        void onPostActivityCreation(@NonNull ActivityRecord activity) {
+            super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getAppCompatAspectRatioOverrides());
+            spyOn(activity.mAppCompatController.getAppCompatAspectRatioPolicy());
+        }
+
+        @Override
+        void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+            super.onPostDisplayContentCreation(displayContent);
+            spyOn(displayContent.mAppCompatCameraPolicy);
+            if (displayContent.mAppCompatCameraPolicy.hasDisplayRotationCompatPolicy()) {
+                spyOn(displayContent.mAppCompatCameraPolicy.mDisplayRotationCompatPolicy);
+            }
+            if (displayContent.mAppCompatCameraPolicy.hasCameraCompatFreeformPolicy()) {
+                spyOn(displayContent.mAppCompatCameraPolicy.mCameraCompatFreeformPolicy);
+            }
+        }
+
         void prepareRelaunchingAfterRequestedOrientationChanged(boolean enabled) {
             getTopOrientationOverrides().setRelaunchingAfterRequestedOrientationChanged(enabled);
         }
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityOverridesTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityOverridesTest.java
index 47f584a..5ff8f02 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityOverridesTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityOverridesTest.java
@@ -183,6 +183,7 @@
         @Override
         void onPostActivityCreation(@NonNull ActivityRecord activity) {
             super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getAppCompatReachabilityOverrides());
             activity.mAppCompatController.getAppCompatReachabilityPolicy()
                     .setLetterboxInnerBoundsSupplier(mLetterboxInnerBoundsSupplier);
         }
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityPolicyTest.java
index 84f89b5..96734b3 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityPolicyTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatReachabilityPolicyTest.java
@@ -247,6 +247,7 @@
         @Override
         void onPostActivityCreation(@NonNull ActivityRecord activity) {
             super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getAppCompatReachabilityOverrides());
             activity.mAppCompatController.getAppCompatReachabilityPolicy()
                     .setLetterboxInnerBoundsSupplier(mLetterboxInnerBoundsSupplier);
         }
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatRobotBase.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatRobotBase.java
index 57ff4f6..4e58e1d 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatRobotBase.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatRobotBase.java
@@ -43,8 +43,8 @@
             @NonNull ActivityTaskSupervisor supervisor,
             int displayWidth, int displayHeight) {
         mActivityRobot = new AppCompatActivityRobot(wm, atm, supervisor,
-                displayWidth, displayHeight);
-        mActivityRobot.setOnPostActivityCreation(this::onPostActivityCreation);
+                displayWidth, displayHeight, this::onPostActivityCreation,
+                this::onPostDisplayContentCreation);
         mConfigurationRobot =
                 new AppCompatConfigurationRobot(wm.mAppCompatConfiguration);
         mOptPropRobot = new AppCompatComponentPropRobot(wm);
@@ -66,6 +66,16 @@
     void onPostActivityCreation(@NonNull ActivityRecord activity) {
     }
 
+    /**
+     * Specific Robots can override this method to add operation to run on a newly created
+     * {@link DisplayContent}. Common case is to invoke spyOn().
+     *
+     * @param displayContent THe newly created {@link DisplayContent}.
+     */
+    @CallSuper
+    void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+    }
+
     @NonNull
     AppCompatConfigurationRobot conf() {
         return mConfigurationRobot;
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatUtilsTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatUtilsTest.java
index 9e242ee..21fac9b 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatUtilsTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatUtilsTest.java
@@ -16,6 +16,8 @@
 
 package com.android.server.wm;
 
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+
 import static org.mockito.Mockito.when;
 
 import android.platform.test.annotations.Presubmit;
@@ -42,7 +44,10 @@
     @Test
     public void getLetterboxReasonString_inSizeCompatMode() {
         runTestScenario((robot) -> {
-            robot.activity().setTopActivityInSizeCompatMode(/* inScm */ true);
+            robot.applyOnActivity((a) -> {
+                a.createActivityWithComponent();
+                a.setTopActivityInSizeCompatMode(/* inScm */ true);
+            });
 
             robot.checkTopActivityLetterboxReason(/* expected */ "SIZE_COMPAT_MODE");
         });
@@ -51,7 +56,10 @@
     @Test
     public void getLetterboxReasonString_fixedOrientation() {
         runTestScenario((robot) -> {
-            robot.activity().checkTopActivityInSizeCompatMode(/* inScm */ false);
+            robot.applyOnActivity((a) -> {
+                a.createActivityWithComponent();
+                a.checkTopActivityInSizeCompatMode(/* inScm */ false);
+            });
             robot.setIsLetterboxedForFixedOrientationAndAspectRatio(
                     /* forFixedOrientationAndAspectRatio */ true);
 
@@ -62,7 +70,10 @@
     @Test
     public void getLetterboxReasonString_isLetterboxedForDisplayCutout() {
         runTestScenario((robot) -> {
-            robot.activity().checkTopActivityInSizeCompatMode(/* inScm */ false);
+            robot.applyOnActivity((a) -> {
+                a.createActivityWithComponent();
+                a.checkTopActivityInSizeCompatMode(/* inScm */ false);
+            });
             robot.setIsLetterboxedForFixedOrientationAndAspectRatio(
                     /* forFixedOrientationAndAspectRatio */ false);
             robot.setIsLetterboxedForDisplayCutout(/* displayCutout */ true);
@@ -74,7 +85,10 @@
     @Test
     public void getLetterboxReasonString_aspectRatio() {
         runTestScenario((robot) -> {
-            robot.activity().checkTopActivityInSizeCompatMode(/* inScm */ false);
+            robot.applyOnActivity((a) -> {
+                a.createActivityWithComponent();
+                a.checkTopActivityInSizeCompatMode(/* inScm */ false);
+            });
             robot.setIsLetterboxedForFixedOrientationAndAspectRatio(
                     /* forFixedOrientationAndAspectRatio */ false);
             robot.setIsLetterboxedForDisplayCutout(/* displayCutout */ false);
@@ -87,7 +101,10 @@
     @Test
     public void getLetterboxReasonString_unknownReason() {
         runTestScenario((robot) -> {
-            robot.activity().checkTopActivityInSizeCompatMode(/* inScm */ false);
+            robot.applyOnActivity((a) -> {
+                a.createActivityWithComponent();
+                a.checkTopActivityInSizeCompatMode(/* inScm */ false);
+            });
             robot.setIsLetterboxedForFixedOrientationAndAspectRatio(
                     /* forFixedOrientationAndAspectRatio */ false);
             robot.setIsLetterboxedForDisplayCutout(/* displayCutout */ false);
@@ -97,7 +114,6 @@
         });
     }
 
-
     /**
      * Runs a test scenario providing a Robot.
      */
@@ -114,10 +130,15 @@
                 @NonNull ActivityTaskManagerService atm,
                 @NonNull ActivityTaskSupervisor supervisor) {
             super(wm, atm, supervisor);
-            activity().createActivityWithComponent();
             mWindowState = Mockito.mock(WindowState.class);
         }
 
+        @Override
+        void onPostActivityCreation(@NonNull ActivityRecord activity) {
+            super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getAppCompatAspectRatioPolicy());
+        }
+
         void setIsLetterboxedForFixedOrientationAndAspectRatio(
                 boolean forFixedOrientationAndAspectRatio) {
             when(activity().top().mAppCompatController.getAppCompatAspectRatioPolicy()
diff --git a/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java
index 407218d..a0641cd 100644
--- a/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java
+++ b/services/tests/wmtests/src/com/android/server/wm/TransparentPolicyTest.java
@@ -22,6 +22,8 @@
 import static android.view.Surface.ROTATION_0;
 import static android.view.Surface.ROTATION_90;
 
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+
 import static org.mockito.Mockito.clearInvocations;
 
 import android.platform.test.annotations.EnableFlags;
@@ -363,6 +365,12 @@
             activity().createNewTaskWithBaseActivity();
         }
 
+        @Override
+        void onPostActivityCreation(@NonNull ActivityRecord activity) {
+            super.onPostActivityCreation(activity);
+            spyOn(activity.mAppCompatController.getTransparentPolicy());
+        }
+
         void transparentActivity(@NonNull Consumer<AppCompatTransparentActivityRobot> consumer) {
             consumer.accept(mTransparentActivityRobot);
         }
diff --git a/tests/Internal/src/com/android/internal/protolog/ProtoLogCommandHandlerTest.java b/tests/Internal/src/com/android/internal/protolog/ProtoLogCommandHandlerTest.java
new file mode 100644
index 0000000..e3ec62d
--- /dev/null
+++ b/tests/Internal/src/com/android/internal/protolog/ProtoLogCommandHandlerTest.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.protolog;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.ArgumentMatchers.endsWith;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.times;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+
+/**
+ * Test class for {@link ProtoLogImpl}.
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner.class)
+public class ProtoLogCommandHandlerTest {
+
+    @Mock
+    ProtoLogService mProtoLogService;
+    @Mock
+    PrintWriter mPrintWriter;
+
+    @Test
+    public void printsHelpForAllAvailableCommands() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.onHelp();
+        validateOnHelpPrinted();
+    }
+
+    @Test
+    public void printsHelpIfCommandIsNull() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.onCommand(null);
+        validateOnHelpPrinted();
+    }
+
+    @Test
+    public void handlesGroupListCommand() {
+        Mockito.when(mProtoLogService.getGroups())
+                .thenReturn(new String[] {"MY_TEST_GROUP", "MY_OTHER_GROUP"});
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "groups", "list" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_TEST_GROUP"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_OTHER_GROUP"));
+    }
+
+    @Test
+    public void handlesIncompleteGroupsCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "groups" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesGroupStatusCommand() {
+        Mockito.when(mProtoLogService.getGroups()).thenReturn(new String[] {"MY_GROUP"});
+        Mockito.when(mProtoLogService.isLoggingToLogcat("MY_GROUP")).thenReturn(true);
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "groups", "status", "MY_GROUP" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_GROUP"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("LOG_TO_LOGCAT = true"));
+    }
+
+    @Test
+    public void handlesGroupStatusCommandOfUnregisteredGroups() {
+        Mockito.when(mProtoLogService.getGroups()).thenReturn(new String[] {});
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "groups", "status", "MY_GROUP" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("MY_GROUP"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("UNREGISTERED"));
+    }
+
+    @Test
+    public void handlesGroupStatusCommandWithNoGroups() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "groups", "status" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesIncompleteLogcatCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat" });
+
+        Mockito.verify(mPrintWriter, times(1))
+                .println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesLogcatEnableCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat", "enable", "MY_GROUP" });
+        Mockito.verify(mProtoLogService).enableProtoLogToLogcat("MY_GROUP");
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat", "enable", "MY_GROUP", "MY_OTHER_GROUP" });
+        Mockito.verify(mProtoLogService)
+                .enableProtoLogToLogcat("MY_GROUP", "MY_OTHER_GROUP");
+    }
+
+    @Test
+    public void handlesLogcatDisableCommand() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat", "disable", "MY_GROUP" });
+        Mockito.verify(mProtoLogService).disableProtoLogToLogcat("MY_GROUP");
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat", "disable", "MY_GROUP", "MY_OTHER_GROUP" });
+        Mockito.verify(mProtoLogService)
+                .disableProtoLogToLogcat("MY_GROUP", "MY_OTHER_GROUP");
+    }
+
+    @Test
+    public void handlesLogcatEnableCommandWithNoGroups() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat", "enable" });
+        Mockito.verify(mPrintWriter).println(contains("Incomplete command"));
+    }
+
+    @Test
+    public void handlesLogcatDisableCommandWithNoGroups() {
+        final ProtoLogCommandHandler cmdHandler =
+                new ProtoLogCommandHandler(mProtoLogService, mPrintWriter);
+
+        cmdHandler.exec(mProtoLogService, FileDescriptor.in, FileDescriptor.out, FileDescriptor.err,
+                new String[] { "logcat", "disable" });
+        Mockito.verify(mPrintWriter).println(contains("Incomplete command"));
+    }
+
+    private void validateOnHelpPrinted() {
+        Mockito.verify(mPrintWriter, times(1)).println(endsWith("help"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(endsWith("groups (list | status)"));
+        Mockito.verify(mPrintWriter, times(1))
+                .println(endsWith("logcat (enable | disable) <group>"));
+        Mockito.verify(mPrintWriter, atLeast(0)).println(anyString());
+    }
+}