Merge "Announce new state of modes when toggled in Modes Dialog" into main
diff --git a/core/api/system-current.txt b/core/api/system-current.txt
index df01aa8..1cfb9fe 100644
--- a/core/api/system-current.txt
+++ b/core/api/system-current.txt
@@ -3450,7 +3450,7 @@
   }
 
   public static interface VirtualDeviceManager.ActivityListener {
-    method @FlaggedApi("android.companion.virtualdevice.flags.activity_control_api") public default void onActivityLaunchBlocked(int, @NonNull android.content.ComponentName, int, @Nullable android.content.IntentSender);
+    method @FlaggedApi("android.companion.virtualdevice.flags.activity_control_api") public default void onActivityLaunchBlocked(int, @NonNull android.content.ComponentName, @NonNull android.os.UserHandle, @Nullable android.content.IntentSender);
     method public void onDisplayEmpty(int);
     method @Deprecated public void onTopActivityChanged(int, @NonNull android.content.ComponentName);
     method public default void onTopActivityChanged(int, @NonNull android.content.ComponentName, int);
@@ -3530,7 +3530,7 @@
     field @Deprecated public static final int NAVIGATION_POLICY_DEFAULT_BLOCKED = 1; // 0x1
     field @FlaggedApi("android.companion.virtual.flags.dynamic_policy") public static final int POLICY_TYPE_ACTIVITY = 3; // 0x3
     field public static final int POLICY_TYPE_AUDIO = 1; // 0x1
-    field @FlaggedApi("android.companion.virtualdevice.flags.activity_control_api") public static final int POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR = 6; // 0x6
+    field @FlaggedApi("android.companion.virtualdevice.flags.activity_control_api") public static final int POLICY_TYPE_BLOCKED_ACTIVITY = 6; // 0x6
     field @FlaggedApi("android.companion.virtual.flags.virtual_camera") public static final int POLICY_TYPE_CAMERA = 5; // 0x5
     field @FlaggedApi("android.companion.virtual.flags.cross_device_clipboard") public static final int POLICY_TYPE_CLIPBOARD = 4; // 0x4
     field public static final int POLICY_TYPE_RECENTS = 2; // 0x2
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java
index db979a5..e99ba84 100644
--- a/core/java/android/app/Notification.java
+++ b/core/java/android/app/Notification.java
@@ -1578,6 +1578,22 @@
     public static final String EXTRA_DECLINE_COLOR = "android.declineColor";
 
     /**
+     * {@link #extras} key: {@link Icon} of an image used as an overlay Icon on
+     * {@link Notification#mLargeIcon} for {@link EnRouteStyle} notifications.
+     * This extra is an {@code Icon}.
+     * @hide
+     */
+    @FlaggedApi(Flags.FLAG_API_RICH_ONGOING)
+    public static final String EXTRA_ENROUTE_OVERLAY_ICON = "android.enrouteOverlayIcon";
+
+    /**
+     * {@link #extras} key: text used as a sub-text for the largeIcon of
+     * {@link EnRouteStyle} notification. This extra is a {@code CharSequence}.
+     * @hide
+     */
+    @FlaggedApi(Flags.FLAG_API_RICH_ONGOING)
+    public static final String EXTRA_ENROUTE_LARGE_ICON_SUBTEXT = "android.enrouteLargeIconSubText";
+    /**
      * {@link #extras} key: whether the notification should be colorized as
      * supplied to {@link Builder#setColorized(boolean)}.
      */
@@ -3039,6 +3055,10 @@
             visitIconUri(visitor, extras.getParcelable(EXTRA_VERIFICATION_ICON, Icon.class));
         }
 
+        if (Flags.apiRichOngoing()) {
+            visitIconUri(visitor, extras.getParcelable(EXTRA_ENROUTE_OVERLAY_ICON, Icon.class));
+        }
+
         if (mBubbleMetadata != null) {
             visitIconUri(visitor, mBubbleMetadata.getIcon());
         }
@@ -10979,6 +10999,144 @@
     }
 
     /**
+     * TODO(b/360827871): Make EnRouteStyle public.
+     * A style used to represent the progress of a real-world journey with a known destination.
+     * For example:
+     * <ul>
+     *     <li>Delivery tracking</li>
+     *     <li>Ride progress</li>
+     *     <li>Flight tracking</li>
+     * </ul>
+     *
+     * The exact fields from {@link Notification} that are shown with this style may vary by
+     * the surface where this update appears, but the following fields are recommended:
+     * <ul>
+     *     <li>{@link Notification.Builder#setContentTitle}</li>
+     *     <li>{@link Notification.Builder#setContentText}</li>
+     *     <li>{@link Notification.Builder#setSubText}</li>
+     *     <li>{@link Notification.Builder#setLargeIcon}</li>
+     *     <li>{@link Notification.Builder#setProgress}</li>
+     *     <li>{@link Notification.Builder#setWhen} - This should be the future time of the next,
+     *     final, or most important stop on this journey.</li>
+     * </ul>
+     * @hide
+     */
+    @FlaggedApi(Flags.FLAG_API_RICH_ONGOING)
+    public static class EnRouteStyle extends Notification.Style {
+
+        @Nullable
+        private Icon mOverlayIcon = null;
+
+        @Nullable
+        private CharSequence mLargeIconSubText = null;
+
+        public EnRouteStyle() {
+        }
+
+        /**
+         * Returns the overlay icon to be displayed on {@link Notification#mLargeIcon}.
+         * @see EnRouteStyle#setOverlayIcon
+         */
+        @Nullable
+        public Icon getOverlayIcon() {
+            return mOverlayIcon;
+        }
+
+        /**
+         * Optional icon to be displayed on {@link Notification#mLargeIcon}.
+         *
+         * This image will be cropped to a circle and will obscure
+         * a semicircle of the right side of the large icon.
+         */
+        @NonNull
+        public EnRouteStyle setOverlayIcon(@Nullable Icon overlayIcon) {
+            mOverlayIcon = overlayIcon;
+            return this;
+        }
+
+        /**
+         * Returns the sub-text for {@link Notification#mLargeIcon}.
+         * @see EnRouteStyle#setLargeIconSubText
+         */
+        @Nullable
+        public CharSequence getLargeIconSubText() {
+            return mLargeIconSubText;
+        }
+
+        /**
+         * Optional text which generally related to
+         * the {@link Notification.Builder#setLargeIcon} or {@link #setOverlayIcon} or both.
+         */
+        @NonNull
+        public EnRouteStyle setLargeIconSubText(@Nullable CharSequence largeIconSubText) {
+            mLargeIconSubText = stripStyling(largeIconSubText);
+            return this;
+        }
+
+         /**
+         * @hide
+         */
+        @Override
+        public boolean areNotificationsVisiblyDifferent(Style other) {
+            if (other == null || getClass() != other.getClass()) {
+                return true;
+            }
+
+            final EnRouteStyle enRouteStyle = (EnRouteStyle) other;
+            return !Objects.equals(mOverlayIcon, enRouteStyle.mOverlayIcon)
+                    || !Objects.equals(mLargeIconSubText, enRouteStyle.mLargeIconSubText);
+        }
+
+        /**
+         * @hide
+         */
+        @Override
+        public void addExtras(Bundle extras) {
+            super.addExtras(extras);
+            extras.putParcelable(EXTRA_ENROUTE_OVERLAY_ICON, mOverlayIcon);
+            extras.putCharSequence(EXTRA_ENROUTE_LARGE_ICON_SUBTEXT, mLargeIconSubText);
+        }
+
+        /**
+         * @hide
+         */
+        @Override
+        protected void restoreFromExtras(Bundle extras) {
+            super.restoreFromExtras(extras);
+            mOverlayIcon = extras.getParcelable(EXTRA_ENROUTE_OVERLAY_ICON, Icon.class);
+            mLargeIconSubText = extras.getCharSequence(EXTRA_ENROUTE_LARGE_ICON_SUBTEXT);
+        }
+
+        /**
+         * @hide
+         */
+        @Override
+        public void purgeResources() {
+            super.purgeResources();
+            if (mOverlayIcon != null) {
+                mOverlayIcon.convertToAshmem();
+            }
+        }
+
+        /**
+         * @hide
+         */
+        @Override
+        public void reduceImageSizes(Context context) {
+            super.reduceImageSizes(context);
+            if (mOverlayIcon != null) {
+                final Resources resources = context.getResources();
+                final boolean isLowRam = ActivityManager.isLowRamDeviceStatic();
+
+                int rightIconSize = resources.getDimensionPixelSize(isLowRam
+                        ? R.dimen.notification_right_icon_size_low_ram
+                        : R.dimen.notification_right_icon_size);
+                mOverlayIcon.scaleDownIfNecessary(rightIconSize, rightIconSize);
+            }
+        }
+    }
+
+    /**
      * Notification style for custom views that are decorated by the system
      *
      * <p>Instead of providing a notification that is completely custom, a developer can set this
diff --git a/core/java/android/app/NotificationChannel.java b/core/java/android/app/NotificationChannel.java
index 326d7ce..789c99d 100644
--- a/core/java/android/app/NotificationChannel.java
+++ b/core/java/android/app/NotificationChannel.java
@@ -753,9 +753,14 @@
 
     /**
      * Sets whether or not notifications posted to this channel can interrupt the user in
-     * {@link android.app.NotificationManager.Policy#INTERRUPTION_FILTER_PRIORITY} mode.
+     * {@link android.app.NotificationManager#INTERRUPTION_FILTER_PRIORITY} mode.
      *
-     * Only modifiable by the system and notification ranker.
+     * <p>Apps with Do Not Disturb policy access (see
+     * {@link NotificationManager#isNotificationPolicyAccessGranted()}) can set up their own
+     * channels this way, but only if the channel hasn't been updated by the user since its
+     * creation.
+     *
+     * <p>Otherwise, this value is only modifiable by the system and the notification ranker.
      */
     public void setBypassDnd(boolean bypassDnd) {
         this.mBypassDnd = bypassDnd;
diff --git a/core/java/android/app/NotificationManager.java b/core/java/android/app/NotificationManager.java
index 3d1a785..83f9ff7 100644
--- a/core/java/android/app/NotificationManager.java
+++ b/core/java/android/app/NotificationManager.java
@@ -1440,10 +1440,36 @@
      * Informs the notification manager that the state of an {@link AutomaticZenRule} has changed.
      * Use this method to put the system into Do Not Disturb mode or request that it exits Do Not
      * Disturb mode. The calling app must own the provided {@link android.app.AutomaticZenRule}.
-     * <p>
-     *     This method can be used in conjunction with or as a replacement to
-     *     {@link android.service.notification.ConditionProviderService#notifyCondition(Condition)}.
-     * </p>
+     *
+     * <p>This method can be used in conjunction with or as a replacement to
+     * {@link android.service.notification.ConditionProviderService#notifyCondition(Condition)}.
+     *
+     * <p>The condition change may be ignored if the user has activated or deactivated the rule
+     * manually -- the user can "override" the rule <em>this time</em>, with the rule resuming its
+     * normal operation for the next cycle. When this has happened, the supplied condition will be
+     * applied only once the automatic state is in agreement with the user-provided state. For
+     * example, assume that the {@link AutomaticZenRule} corresponds to a "Driving Mode" with
+     * automatic driving detection.
+     *
+     * <ol>
+     *     <li>App detects driving and notifies the system that the rule should be active, calling
+     *     this method with a {@link Condition} with {@link Condition#STATE_TRUE}).
+     *     <li>User deactivates ("snoozes") the rule for some reason. This overrides the
+     *     app-provided condition state.
+     *     <li>App is still detecting driving, so again calls with {@link Condition#STATE_TRUE}.
+     *     This is ignored by the system, as the user override prevails.
+     *     <li>Some time later, the app detects that driving stopped, so the rule should be
+     *     inactive, and calls with {@link Condition#STATE_FALSE}). This doesn't change the actual
+     *     rule state (it was already inactive due to the user's override), but clears the override.
+     *     <li>Some time later, the app detects that driving has started again, and notifies that
+     *     the rule should be active (calling with {@link Condition#STATE_TRUE} again). The rule is
+     *     activated.
+     * </ol>
+     *
+     * <p>Note that the behavior at step #3 is different if the app also specifies
+     * {@link Condition#SOURCE_USER_ACTION} as the {@link Condition#source} -- rule state updates
+     * coming from user actions are not ignored.
+     *
      * @param id The id of the rule whose state should change
      * @param condition The new state of this rule
      */
diff --git a/core/java/android/app/appfunctions/AppFunctionManager.java b/core/java/android/app/appfunctions/AppFunctionManager.java
index bf21549..7801201 100644
--- a/core/java/android/app/appfunctions/AppFunctionManager.java
+++ b/core/java/android/app/appfunctions/AppFunctionManager.java
@@ -27,7 +27,6 @@
  *
  * <p>App function is a specific piece of functionality that an app offers to the system. These
  * functionalities can be integrated into various system features.
- *
  */
 @FlaggedApi(FLAG_ENABLE_APP_FUNCTION_MANAGER)
 @SystemService(Context.APP_FUNCTION_SERVICE)
diff --git a/core/java/android/app/appfunctions/IAppFunctionManager.aidl b/core/java/android/app/appfunctions/IAppFunctionManager.aidl
index 018bc75..14944f0 100644
--- a/core/java/android/app/appfunctions/IAppFunctionManager.aidl
+++ b/core/java/android/app/appfunctions/IAppFunctionManager.aidl
@@ -16,9 +16,22 @@
 
 package android.app.appfunctions;
 
+import android.app.appfunctions.ExecuteAppFunctionAidlRequest;
+import android.app.appfunctions.IExecuteAppFunctionCallback;
+
 /**
 * Interface between an app and the server implementation service (AppFunctionManagerService).
 * @hide
 */
 oneway interface IAppFunctionManager {
+    /**
+    * Executes an app function provided by {@link AppFunctionService} through the system.
+    *
+    * @param request the request to execute an app function.
+    * @param callback the callback to report the result.
+    */
+    void executeAppFunction(
+        in ExecuteAppFunctionAidlRequest request,
+        in IExecuteAppFunctionCallback callback
+    );
 }
\ No newline at end of file
diff --git a/core/java/android/companion/virtual/IVirtualDeviceActivityListener.aidl b/core/java/android/companion/virtual/IVirtualDeviceActivityListener.aidl
index 564fb02..7c674f9 100644
--- a/core/java/android/companion/virtual/IVirtualDeviceActivityListener.aidl
+++ b/core/java/android/companion/virtual/IVirtualDeviceActivityListener.aidl
@@ -18,6 +18,7 @@
 
 import android.content.ComponentName;
 import android.content.IntentSender;
+import android.os.UserHandle;
 
 /**
  * Interface to listen for activity changes in a virtual device.
@@ -48,9 +49,9 @@
      *
      * @param displayId The display ID on which the activity tried to launch.
      * @param componentName The component name of the blocked activity.
-     * @param userId The user ID associated with the blocked activity.
+     * @param user The user associated with the blocked activity.
      * @param intentSender The original sender of the intent.
      */
-    void onActivityLaunchBlocked(int displayId, in ComponentName componentName, int userId,
+    void onActivityLaunchBlocked(int displayId, in ComponentName componentName, in UserHandle user,
             in IntentSender intentSender);
 }
diff --git a/core/java/android/companion/virtual/VirtualDeviceInternal.java b/core/java/android/companion/virtual/VirtualDeviceInternal.java
index 19eb497..9636cd4 100644
--- a/core/java/android/companion/virtual/VirtualDeviceInternal.java
+++ b/core/java/android/companion/virtual/VirtualDeviceInternal.java
@@ -19,7 +19,7 @@
 import static android.companion.virtual.VirtualDeviceParams.DEVICE_POLICY_CUSTOM;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_ACTIVITY;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_AUDIO;
-import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR;
+import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_BLOCKED_ACTIVITY;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CLIPBOARD;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_RECENTS;
 
@@ -65,6 +65,7 @@
 import android.os.Looper;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
+import android.os.UserHandle;
 import android.util.ArrayMap;
 import android.view.WindowManager;
 
@@ -136,14 +137,14 @@
 
                 @Override
                 public void onActivityLaunchBlocked(int displayId, ComponentName componentName,
-                        @UserIdInt int userId, IntentSender intentSender) {
+                        UserHandle user, IntentSender intentSender) {
                     final long token = Binder.clearCallingIdentity();
                     try {
                         synchronized (mActivityListenersLock) {
                             for (int i = 0; i < mActivityListeners.size(); i++) {
                                 mActivityListeners.valueAt(i)
                                         .onActivityLaunchBlocked(
-                                                displayId, componentName, userId, intentSender);
+                                                displayId, componentName, user, intentSender);
                             }
                         }
                     } finally {
@@ -292,7 +293,7 @@
             case POLICY_TYPE_RECENTS:
             case POLICY_TYPE_CLIPBOARD:
             case POLICY_TYPE_ACTIVITY:
-            case POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR:
+            case POLICY_TYPE_BLOCKED_ACTIVITY:
                 break;
             default:
                 throw new IllegalArgumentException("Device policy " + policyType
@@ -595,10 +596,10 @@
         }
 
         public void onActivityLaunchBlocked(int displayId, ComponentName componentName,
-                @UserIdInt int userId, IntentSender intentSender) {
+                UserHandle user, IntentSender intentSender) {
             mExecutor.execute(() ->
                     mActivityListener.onActivityLaunchBlocked(
-                            displayId, componentName, userId, intentSender));
+                            displayId, componentName, user, intentSender));
         }
     }
 
diff --git a/core/java/android/companion/virtual/VirtualDeviceManager.java b/core/java/android/companion/virtual/VirtualDeviceManager.java
index d07fb25..c2300e0 100644
--- a/core/java/android/companion/virtual/VirtualDeviceManager.java
+++ b/core/java/android/companion/virtual/VirtualDeviceManager.java
@@ -67,6 +67,7 @@
 import android.os.Binder;
 import android.os.Looper;
 import android.os.RemoteException;
+import android.os.UserHandle;
 import android.util.ArraySet;
 import android.util.Log;
 import android.view.Display;
@@ -1263,7 +1264,7 @@
          *
          * @param displayId The display ID on which the activity tried to launch.
          * @param componentName The component name of the blocked activity.
-         * @param userId The user ID associated with the blocked activity.
+         * @param user The user associated with the blocked activity.
          * @param intentSender The original sender of the intent. May be {@code null} if the sender
          *   expects an activity result to be reported. In that case
          *   {@link android.app.Activity#RESULT_CANCELED} was already reported back because the
@@ -1275,7 +1276,7 @@
          */
         @FlaggedApi(android.companion.virtualdevice.flags.Flags.FLAG_ACTIVITY_CONTROL_API)
         default void onActivityLaunchBlocked(int displayId, @NonNull ComponentName componentName,
-                @UserIdInt int userId, @Nullable IntentSender intentSender) {}
+                @NonNull UserHandle user, @Nullable IntentSender intentSender) {}
     }
 
     /**
diff --git a/core/java/android/companion/virtual/VirtualDeviceParams.java b/core/java/android/companion/virtual/VirtualDeviceParams.java
index c1fc51d..03b72bd 100644
--- a/core/java/android/companion/virtual/VirtualDeviceParams.java
+++ b/core/java/android/companion/virtual/VirtualDeviceParams.java
@@ -160,7 +160,7 @@
      */
     @IntDef(prefix = "POLICY_TYPE_", value = {POLICY_TYPE_SENSORS, POLICY_TYPE_AUDIO,
             POLICY_TYPE_RECENTS, POLICY_TYPE_ACTIVITY, POLICY_TYPE_CAMERA,
-            POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR})
+            POLICY_TYPE_BLOCKED_ACTIVITY})
     @Retention(RetentionPolicy.SOURCE)
     @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE})
     public @interface PolicyType {}
@@ -172,7 +172,7 @@
      * @hide
      */
     @IntDef(prefix = "POLICY_TYPE_", value = {POLICY_TYPE_RECENTS, POLICY_TYPE_ACTIVITY,
-            POLICY_TYPE_CLIPBOARD, POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR})
+            POLICY_TYPE_CLIPBOARD, POLICY_TYPE_BLOCKED_ACTIVITY})
     @Retention(RetentionPolicy.SOURCE)
     @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE})
     public @interface DynamicPolicyType {}
@@ -242,7 +242,7 @@
      * @see VirtualDeviceManager.VirtualDevice#removeActivityPolicyExemption
      */
     // TODO(b/333443509): Update the documentation of custom policy and link to the new policy
-    // POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR
+    // POLICY_TYPE_BLOCKED_ACTIVITY
     @FlaggedApi(Flags.FLAG_DYNAMIC_POLICY)
     public static final int POLICY_TYPE_ACTIVITY = 3;
 
@@ -292,7 +292,7 @@
      */
     // TODO(b/333443509): Link to POLICY_TYPE_ACTIVITY
     @FlaggedApi(android.companion.virtualdevice.flags.Flags.FLAG_ACTIVITY_CONTROL_API)
-    public static final int POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR = 6;
+    public static final int POLICY_TYPE_BLOCKED_ACTIVITY = 6;
 
     private final int mLockState;
     @NonNull private final ArraySet<UserHandle> mUsersWithMatchingAccounts;
@@ -1206,7 +1206,7 @@
             }
 
             if (!android.companion.virtualdevice.flags.Flags.activityControlApi()) {
-                mDevicePolicies.delete(POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR);
+                mDevicePolicies.delete(POLICY_TYPE_BLOCKED_ACTIVITY);
             }
 
             if ((mAudioPlaybackSessionId != AUDIO_SESSION_ID_GENERATE
diff --git a/core/java/android/service/notification/ZenModeConfig.java b/core/java/android/service/notification/ZenModeConfig.java
index fc6c2e8..57acc71 100644
--- a/core/java/android/service/notification/ZenModeConfig.java
+++ b/core/java/android/service/notification/ZenModeConfig.java
@@ -187,6 +187,13 @@
     @Retention(RetentionPolicy.SOURCE)
     public @interface ConfigOrigin {}
 
+    /**
+     * Prefix for the ids of implicit Zen rules. Implicit rules are those created automatically
+     * on behalf of apps that call {@link NotificationManager#setNotificationPolicy} or
+     * {@link NotificationManager#setInterruptionFilter}.
+     */
+    private static final String IMPLICIT_RULE_ID_PREFIX = "implicit_"; // + pkg_name
+
     public static final int SOURCE_ANYONE = Policy.PRIORITY_SENDERS_ANY;
     public static final int SOURCE_CONTACT = Policy.PRIORITY_SENDERS_CONTACTS;
     public static final int SOURCE_STAR = Policy.PRIORITY_SENDERS_STARRED;
@@ -2492,6 +2499,16 @@
 
     // ==== End built-in system conditions ====
 
+    /** Generate the rule id for the implicit rule for the specified package. */
+    public static String implicitRuleId(String forPackage) {
+        return IMPLICIT_RULE_ID_PREFIX + forPackage;
+    }
+
+    /** Returns whether the rule id corresponds to an implicit rule. */
+    public static boolean isImplicitRuleId(@NonNull String ruleId) {
+        return ruleId.startsWith(IMPLICIT_RULE_ID_PREFIX);
+    }
+
     private static int[] tryParseHourAndMinute(String value) {
         if (TextUtils.isEmpty(value)) return null;
         final int i = value.indexOf('.');
diff --git a/media/java/android/media/projection/MediaProjection.java b/media/java/android/media/projection/MediaProjection.java
index 1c5049e..3cc0ad2 100644
--- a/media/java/android/media/projection/MediaProjection.java
+++ b/media/java/android/media/projection/MediaProjection.java
@@ -90,23 +90,24 @@
         mDisplayManager = displayManager;
     }
 
-    /**
-     * Register a listener to receive notifications about when the {@link MediaProjection} or
-     * captured content changes state.
-     *
-     * <p>The callback must be registered before invoking
-     * {@link #createVirtualDisplay(String, int, int, int, int, Surface, VirtualDisplay.Callback,
-     * Handler)} to ensure that any notifications on the callback are not missed. The client must
-     * implement {@link Callback#onStop()} and clean up any resources it is holding, e.g. the
-     * {@link VirtualDisplay} and {@link Surface}.
-     *
-     * @param callback The callback to call.
-     * @param handler  The handler on which the callback should be invoked, or
-     *                 null if the callback should be invoked on the calling thread's looper.
-     * @throws NullPointerException If the given callback is null.
-     * @see #unregisterCallback
-     */
-    public void registerCallback(@NonNull Callback callback, @Nullable Handler handler) {
+  /**
+   * Register a listener to receive notifications about when the {@link MediaProjection} or captured
+   * content changes state.
+   *
+   * <p>The callback must be registered before invoking {@link #createVirtualDisplay(String, int,
+   * int, int, int, Surface, VirtualDisplay.Callback, Handler)} to ensure that any notifications on
+   * the callback are not missed. The client must implement {@link Callback#onStop()} and clean up
+   * any resources it is holding, e.g. the {@link VirtualDisplay} and {@link Surface}. This should
+   * also update any application UI indicating the MediaProjection status as MediaProjection has
+   * stopped.
+   *
+   * @param callback The callback to call.
+   * @param handler The handler on which the callback should be invoked, or null if the callback
+   *     should be invoked on the calling thread's looper.
+   * @throws NullPointerException If the given callback is null.
+   * @see #unregisterCallback
+   */
+  public void registerCallback(@NonNull Callback callback, @Nullable Handler handler) {
         try {
             final Callback c = Objects.requireNonNull(callback);
             if (handler == null) {
@@ -158,74 +159,67 @@
         return createVirtualDisplay(builder, callback, handler);
     }
 
-    /**
-     * Creates a {@link android.hardware.display.VirtualDisplay} to capture the
-     * contents of the screen.
-     *
-     * <p>To correctly clean up resources associated with a capture, the application must register a
-     * {@link Callback} before invocation. The app must override {@link Callback#onStop()} to clean
-     * up (by invoking{@link VirtualDisplay#release()}, {@link Surface#release()} and related
-     * resources).
-     *
-     * @param name     The name of the virtual display, must be non-empty.
-     * @param width    The width of the virtual display in pixels. Must be greater than 0.
-     * @param height   The height of the virtual display in pixels. Must be greater than 0.
-     * @param dpi      The density of the virtual display in dpi. Must be greater than 0.
-     * @param surface  The surface to which the content of the virtual display should be rendered,
-     *                 or null if there is none initially.
-     * @param flags    A combination of virtual display flags. See {@link DisplayManager} for the
-     *                 full list of flags. Note that
-     *                 {@link DisplayManager#VIRTUAL_DISPLAY_FLAG_PRESENTATION}
-     *                 is always enabled. The following flags may be overridden, depending on how
-     *                 the component with {android.Manifest.permission.MANAGE_MEDIA_PROJECTION}
-     *                 handles the user's consent:
-     *                 {@link DisplayManager#VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY},
-     *                 {@link DisplayManager#VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR},
-     *                 {@link DisplayManager#VIRTUAL_DISPLAY_FLAG_PUBLIC}.
-     * @param callback Callback invoked when the virtual display's state changes, or null.
-     * @param handler  The {@link android.os.Handler} on which the callback should be invoked, or
-     *                 null if the callback should be invoked on the calling thread's main
-     *                 {@link android.os.Looper}.
-     * @throws IllegalStateException If the target SDK is {@link
-     *                               android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U} and up, and
-     *                               if no {@link Callback} is registered.
-     * @throws SecurityException In any of the following scenarios:
-     *                               <ol>
-     *                                 <li>If attempting to create a new virtual display
-     *                                 associated with this MediaProjection instance after it has
-     *                                 been stopped by invoking {@link #stop()}.
-     *                                 <li>If attempting to create a new virtual display
-     *                                 associated with this MediaProjection instance after a
-     *                                 {@link MediaProjection.Callback#onStop()} callback has been
-     *                                 received due to the user or the system stopping the
-     *                                 MediaProjection session.
-     *                                 <li>If the target SDK is {@link
-     *                                 android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U} and up,
-     *                                 and if this instance has already taken a recording through
-     *                                 {@code #createVirtualDisplay}, but {@link #stop()} wasn't
-     *                                 invoked to end the recording.
-     *                                 <li>If the target SDK is {@link
-     *                                 android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U} and up,
-     *                                 and if {@link MediaProjectionManager#getMediaProjection}
-     *                                 was invoked more than once to get this
-     *                                 {@code MediaProjection} instance.
-     *                               </ol>
-     *                               In cases 2 & 3, no exception is thrown if the target SDK is
-     *                               less than
-     *                               {@link android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U}.
-     *                               Instead, recording doesn't begin until the user re-grants
-     *                               consent in the dialog.
-     * @return The created {@link VirtualDisplay}, or {@code null} if no {@link VirtualDisplay}
-     * could be created.
-     * @see VirtualDisplay
-     * @see VirtualDisplay.Callback
-     */
-    @SuppressWarnings("RequiresPermission")
-    @Nullable
-    public VirtualDisplay createVirtualDisplay(@NonNull String name,
-            int width, int height, int dpi, @VirtualDisplayFlag int flags,
-            @Nullable Surface surface, @Nullable VirtualDisplay.Callback callback,
-            @Nullable Handler handler) {
+  /**
+   * Creates a {@link android.hardware.display.VirtualDisplay} to capture the contents of the
+   * screen.
+   *
+   * <p>To correctly clean up resources associated with a capture, the application must register a
+   * {@link Callback} before invocation. The app must override {@link Callback#onStop()} to clean up
+   * resources (by invoking{@link VirtualDisplay#release()}, {@link Surface#release()} and related
+   * resources) and to update any available UI regarding the MediaProjection status.
+   *
+   * @param name The name of the virtual display, must be non-empty.
+   * @param width The width of the virtual display in pixels. Must be greater than 0.
+   * @param height The height of the virtual display in pixels. Must be greater than 0.
+   * @param dpi The density of the virtual display in dpi. Must be greater than 0.
+   * @param surface The surface to which the content of the virtual display should be rendered, or
+   *     null if there is none initially.
+   * @param flags A combination of virtual display flags. See {@link DisplayManager} for the full
+   *     list of flags. Note that {@link DisplayManager#VIRTUAL_DISPLAY_FLAG_PRESENTATION} is always
+   *     enabled. The following flags may be overridden, depending on how the component with
+   *     {android.Manifest.permission.MANAGE_MEDIA_PROJECTION} handles the user's consent: {@link
+   *     DisplayManager#VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY}, {@link
+   *     DisplayManager#VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR}, {@link
+   *     DisplayManager#VIRTUAL_DISPLAY_FLAG_PUBLIC}.
+   * @param callback Callback invoked when the virtual display's state changes, or null.
+   * @param handler The {@link android.os.Handler} on which the callback should be invoked, or null
+   *     if the callback should be invoked on the calling thread's main {@link android.os.Looper}.
+   * @throws IllegalStateException If the target SDK is {@link
+   *     android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U} and up, and if no {@link Callback} is
+   *     registered.
+   * @throws SecurityException In any of the following scenarios:
+   *     <ol>
+   *       <li>If attempting to create a new virtual display associated with this MediaProjection
+   *           instance after it has been stopped by invoking {@link #stop()}.
+   *       <li>If attempting to create a new virtual display associated with this MediaProjection
+   *           instance after a {@link MediaProjection.Callback#onStop()} callback has been received
+   *           due to the user or the system stopping the MediaProjection session.
+   *       <li>If the target SDK is {@link android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U} and
+   *           up, and if this instance has already taken a recording through {@code
+   *           #createVirtualDisplay}, but {@link #stop()} wasn't invoked to end the recording.
+   *       <li>If the target SDK is {@link android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U} and
+   *           up, and if {@link MediaProjectionManager#getMediaProjection} was invoked more than
+   *           once to get this {@code MediaProjection} instance.
+   *     </ol>
+   *     In cases 2 & 3, no exception is thrown if the target SDK is less than {@link
+   *     android.os.Build.VERSION_CODES#UPSIDE_DOWN_CAKE U}. Instead, recording doesn't begin until
+   *     the user re-grants consent in the dialog.
+   * @return The created {@link VirtualDisplay}, or {@code null} if no {@link VirtualDisplay} could
+   *     be created.
+   * @see VirtualDisplay
+   * @see VirtualDisplay.Callback
+   */
+  @SuppressWarnings("RequiresPermission")
+  @Nullable
+  public VirtualDisplay createVirtualDisplay(
+      @NonNull String name,
+      int width,
+      int height,
+      int dpi,
+      @VirtualDisplayFlag int flags,
+      @Nullable Surface surface,
+      @Nullable VirtualDisplay.Callback callback,
+      @Nullable Handler handler) {
         if (shouldMediaProjectionRequireCallback()) {
             if (mCallbacks.isEmpty()) {
                 final IllegalStateException e = new IllegalStateException(
@@ -322,14 +316,20 @@
          * Called when the MediaProjection session is no longer valid.
          *
          * <p>Once a MediaProjection has been stopped, it's up to the application to release any
-         * resources it may be holding (e.g. releasing the {@link VirtualDisplay} and
-         * {@link Surface}).
+         * resources it may be holding (e.g. releasing the {@link VirtualDisplay} and {@link
+         * Surface}). If the application is displaying any UI indicating the MediaProjection state
+         * it should be updated to indicate that MediaProjection is no longer active.
          *
-         * <p>After this callback any call to
-         * {@link MediaProjection#createVirtualDisplay} will fail, even if no such
-         * {@link VirtualDisplay} was ever created for this MediaProjection session.
+         * <p>MediaProjection stopping can be a result of the system stopping the ongoing
+         * MediaProjection due to various reasons, such as another MediaProjection session starting.
+         * MediaProjection may also stop due to the user explicitly stopping ongoing MediaProjection
+         * via any available system-level UI.
+         *
+         * <p>After this callback any call to {@link MediaProjection#createVirtualDisplay} will
+         * fail, even if no such {@link VirtualDisplay} was ever created for this MediaProjection
+         * session.
          */
-        public void onStop() { }
+        public void onStop() {}
 
         /**
          * Invoked immediately after capture begins or when the size of the captured region changes,
diff --git a/media/java/android/media/projection/MediaProjectionManager.java b/media/java/android/media/projection/MediaProjectionManager.java
index 7a7137a..03fd2c6 100644
--- a/media/java/android/media/projection/MediaProjectionManager.java
+++ b/media/java/android/media/projection/MediaProjectionManager.java
@@ -64,7 +64,9 @@
  *       holding, e.g. the {@link VirtualDisplay} and {@link Surface}. The MediaProjection may
  *       further no longer create any new {@link VirtualDisplay}s via {@link
  *       MediaProjection#createVirtualDisplay(String, int, int, int, int, Surface,
- *       VirtualDisplay.Callback, Handler)}.
+ *       VirtualDisplay.Callback, Handler)}. Note that the `onStop()` callback can be a result of
+ *       the system stopping MediaProjection due to various reasons or the user stopping the
+ *       MediaProjection via UI affordances in system-level UI.
  *   <li>Start the screen capture session for media projection by calling {@link
  *       MediaProjection#createVirtualDisplay(String, int, int, int, int, Surface,
  *       android.hardware.display.VirtualDisplay.Callback, Handler)}.
diff --git a/packages/ExternalStorageProvider/TEST_MAPPING b/packages/ExternalStorageProvider/TEST_MAPPING
new file mode 100644
index 0000000..dfa0c84
--- /dev/null
+++ b/packages/ExternalStorageProvider/TEST_MAPPING
@@ -0,0 +1,15 @@
+{
+  "postsubmit": [
+    {
+      "name": "ExternalStorageProviderTests",
+      "options": [
+        {
+          "exclude-annotation": "org.junit.Ignore"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    }
+  ]
+}
diff --git a/packages/ExternalStorageProvider/tests/Android.bp b/packages/ExternalStorageProvider/tests/Android.bp
index 86c62ef..097bb860 100644
--- a/packages/ExternalStorageProvider/tests/Android.bp
+++ b/packages/ExternalStorageProvider/tests/Android.bp
@@ -12,6 +12,10 @@
 
     manifest: "AndroidManifest.xml",
 
+    test_suites: [
+        "general-tests",
+    ],
+
     srcs: [
         "src/**/*.java",
     ],
diff --git a/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java
index 0d4ce5b..c13b261 100644
--- a/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java
+++ b/packages/SettingsLib/src/com/android/settingslib/notification/modes/ZenMode.java
@@ -117,6 +117,21 @@
             .thenComparing(ZenMode::getType, PRIORITIZED_TYPE_COMPARATOR)
             .thenComparing(ZenMode::getName);
 
+    public enum Kind {
+        /** A "normal" mode, created by apps or users via {@code addAutomaticZenRule()}. */
+        NORMAL,
+
+        /** The special, built-in "Do Not Disturb" mode. */
+        MANUAL_DND,
+
+        /**
+         * An implicit mode, automatically created and managed by the system on behalf of apps that
+         * call {@code setInterruptionFilter()} or {@code setNotificationPolicy()} (with some
+         * exceptions).
+         */
+        IMPLICIT,
+    }
+
     public enum Status {
         ENABLED,
         ENABLED_AND_ACTIVE,
@@ -126,8 +141,8 @@
 
     private final String mId;
     private final AutomaticZenRule mRule;
+    private final Kind mKind;
     private final Status mStatus;
-    private final boolean mIsManualDnd;
 
     /**
      * Initializes a {@link ZenMode}, mainly based on the information from the
@@ -137,9 +152,11 @@
      * active, or the reason it was disabled) are read from the {@link ZenModeConfig.ZenRule} --
      * see {@link #computeStatus}.
      */
-    public ZenMode(String id, @NonNull AutomaticZenRule rule,
+    ZenMode(String id, @NonNull AutomaticZenRule rule,
             @NonNull ZenModeConfig.ZenRule zenRuleExtraData) {
-        this(id, rule, computeStatus(zenRuleExtraData), false);
+        this(id, rule,
+                ZenModeConfig.isImplicitRuleId(id) ? Kind.IMPLICIT : Kind.NORMAL,
+                computeStatus(zenRuleExtraData));
     }
 
     private static Status computeStatus(@NonNull ZenModeConfig.ZenRule zenRuleExtraData) {
@@ -158,13 +175,16 @@
         }
     }
 
-    public static ZenMode manualDndMode(AutomaticZenRule manualRule, boolean isActive) {
+    static ZenMode manualDndMode(AutomaticZenRule manualRule, boolean isActive) {
         // Manual rule is owned by the system, so we set it here
         AutomaticZenRule manualRuleWithPkg = new AutomaticZenRule.Builder(manualRule)
                 .setPackage(PACKAGE_ANDROID)
                 .build();
-        return new ZenMode(MANUAL_DND_MODE_ID, manualRuleWithPkg,
-                isActive ? Status.ENABLED_AND_ACTIVE : Status.ENABLED, true);
+        return new ZenMode(
+                MANUAL_DND_MODE_ID,
+                manualRuleWithPkg,
+                Kind.MANUAL_DND,
+                isActive ? Status.ENABLED_AND_ACTIVE : Status.ENABLED);
     }
 
     /**
@@ -183,19 +203,19 @@
                 .setIconResId(iconResId)
                 .setManualInvocationAllowed(true)
                 .build();
-        return new ZenMode(TEMP_NEW_MODE_ID, rule, Status.ENABLED, false);
+        return new ZenMode(TEMP_NEW_MODE_ID, rule, Kind.NORMAL, Status.ENABLED);
     }
 
-    private ZenMode(String id, @NonNull AutomaticZenRule rule, Status status, boolean isManualDnd) {
+    private ZenMode(String id, @NonNull AutomaticZenRule rule, Kind kind, Status status) {
         mId = id;
         mRule = rule;
+        mKind = kind;
         mStatus = status;
-        mIsManualDnd = isManualDnd;
     }
 
     /** Creates a deep copy of this object. */
     public ZenMode copy() {
-        return new ZenMode(mId, new AutomaticZenRule.Builder(mRule).build(), mStatus, mIsManualDnd);
+        return new ZenMode(mId, new AutomaticZenRule.Builder(mRule).build(), mKind, mStatus);
     }
 
     @NonNull
@@ -264,10 +284,32 @@
         return mRule.getType() + ":" + mRule.getPackageName() + ":" + mRule.getIconResId();
     }
 
+    /**
+     * Returns the mode icon -- which can be either app-provided (via {@code addAutomaticZenRule}),
+     * user-chosen (via the icon picker in Settings), the app's launcher icon for implicit rules
+     * (in its monochrome variant, if available), or a default icon based on the mode type.
+     */
     @NonNull
     public ListenableFuture<Drawable> getIcon(@NonNull Context context,
             @NonNull ZenIconLoader iconLoader) {
-        if (mIsManualDnd) {
+        if (mKind == Kind.MANUAL_DND) {
+            return Futures.immediateFuture(requireNonNull(
+                    context.getDrawable(R.drawable.ic_do_not_disturb_on_24dp)));
+        }
+
+        return iconLoader.getIcon(context, mRule);
+    }
+
+    /**
+     * Returns an alternative mode icon. The difference with {@link #getIcon} is that it's the
+     * basic DND icon not only for Manual DND, but also for <em>implicit rules</em>. As such, it's
+     * suitable for places where showing the launcher icon of an app could be confusing, such as
+     * the status bar or lockscreen.
+     */
+    @NonNull
+    public ListenableFuture<Drawable> getLockscreenIcon(@NonNull Context context,
+            @NonNull ZenIconLoader iconLoader) {
+        if (mKind == Kind.MANUAL_DND || mKind == Kind.IMPLICIT) {
             return Futures.immediateFuture(requireNonNull(
                     context.getDrawable(R.drawable.ic_do_not_disturb_on_24dp)));
         }
@@ -373,7 +415,7 @@
     }
 
     public boolean isManualDnd() {
-        return mIsManualDnd;
+        return mKind == Kind.MANUAL_DND;
     }
 
     /**
@@ -404,18 +446,18 @@
         return obj instanceof ZenMode other
                 && mId.equals(other.mId)
                 && mRule.equals(other.mRule)
-                && mStatus.equals(other.mStatus)
-                && mIsManualDnd == other.mIsManualDnd;
+                && mKind.equals(other.mKind)
+                && mStatus.equals(other.mStatus);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(mId, mRule, mStatus, mIsManualDnd);
+        return Objects.hash(mId, mRule, mKind, mStatus);
     }
 
     @Override
     public String toString() {
-        return mId + " (" + mStatus + ") -> " + mRule;
+        return mId + " (" + mKind + ", " + mStatus + ") -> " + mRule;
     }
 
     @Override
@@ -427,8 +469,8 @@
     public void writeToParcel(@NonNull Parcel dest, int flags) {
         dest.writeString(mId);
         dest.writeParcelable(mRule, 0);
+        dest.writeString(mKind.name());
         dest.writeString(mStatus.name());
-        dest.writeBoolean(mIsManualDnd);
     }
 
     public static final Creator<ZenMode> CREATOR = new Creator<ZenMode>() {
@@ -438,8 +480,8 @@
                     in.readString(),
                     checkNotNull(in.readParcelable(AutomaticZenRule.class.getClassLoader(),
                             AutomaticZenRule.class)),
-                    Status.valueOf(in.readString()),
-                    in.readBoolean());
+                    Kind.valueOf(in.readString()),
+                    Status.valueOf(in.readString()));
         }
 
         @Override
diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java
index bab4bc3b..f533e77 100644
--- a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java
+++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/notification/modes/ZenModeTest.java
@@ -30,7 +30,14 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
 import android.app.AutomaticZenRule;
+import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Parcel;
 import android.service.notification.Condition;
@@ -40,9 +47,12 @@
 
 import com.android.internal.R;
 
+import com.google.common.util.concurrent.ListenableFuture;
+
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -60,6 +70,13 @@
                     .setZenPolicy(ZEN_POLICY)
                     .build();
 
+    private static final String IMPLICIT_RULE_ID = ZenModeConfig.implicitRuleId("some.package");
+    private static final AutomaticZenRule IMPLICIT_ZEN_RULE =
+            new AutomaticZenRule.Builder("Implicit", Uri.parse("implicit/some.package"))
+                    .setPackage("some.package")
+                    .setType(TYPE_OTHER)
+                    .build();
+
     @Test
     public void testBasicMethods() {
         ZenMode zenMode = new ZenMode("id", ZEN_RULE, zenConfigRuleFor(ZEN_RULE, true));
@@ -265,6 +282,79 @@
 
         assertUnparceledIsEqualToOriginal("custom_manual",
                 ZenMode.newCustomManual("New mode", R.drawable.ic_zen_mode_type_immersive));
+
+        assertUnparceledIsEqualToOriginal("implicit",
+                new ZenMode(IMPLICIT_RULE_ID, IMPLICIT_ZEN_RULE,
+                        zenConfigRuleFor(IMPLICIT_ZEN_RULE, false)));
+    }
+
+    @Test
+    public void getIcon_normalMode_loadsIconNormally() {
+        ZenIconLoader iconLoader = mock(ZenIconLoader.class);
+        ZenMode mode = new ZenMode("id", ZEN_RULE, zenConfigRuleFor(ZEN_RULE, false));
+
+        ListenableFuture<Drawable> unused = mode.getIcon(RuntimeEnvironment.getApplication(),
+                iconLoader);
+
+        verify(iconLoader).getIcon(any(), eq(ZEN_RULE));
+    }
+
+    @Test
+    public void getIcon_manualDnd_returnsFixedIcon() {
+        ZenIconLoader iconLoader = mock(ZenIconLoader.class);
+
+        ListenableFuture<Drawable> future = TestModeBuilder.MANUAL_DND_INACTIVE.getIcon(
+                RuntimeEnvironment.getApplication(), iconLoader);
+
+        assertThat(future.isDone()).isTrue();
+        verify(iconLoader, never()).getIcon(any(), any());
+    }
+
+    @Test
+    public void getIcon_implicitMode_loadsIconNormally() {
+        ZenIconLoader iconLoader = mock(ZenIconLoader.class);
+        ZenMode mode = new ZenMode(IMPLICIT_RULE_ID, IMPLICIT_ZEN_RULE,
+                zenConfigRuleFor(IMPLICIT_ZEN_RULE, false));
+
+        ListenableFuture<Drawable> unused = mode.getIcon(RuntimeEnvironment.getApplication(),
+                iconLoader);
+
+        verify(iconLoader).getIcon(any(), eq(IMPLICIT_ZEN_RULE));
+    }
+
+    @Test
+    public void getLockscreenIcon_normalMode_loadsIconNormally() {
+        ZenIconLoader iconLoader = mock(ZenIconLoader.class);
+        ZenMode mode = new ZenMode("id", ZEN_RULE, zenConfigRuleFor(ZEN_RULE, false));
+
+        ListenableFuture<Drawable> unused = mode.getLockscreenIcon(
+                RuntimeEnvironment.getApplication(), iconLoader);
+
+        verify(iconLoader).getIcon(any(), eq(ZEN_RULE));
+    }
+
+    @Test
+    public void getLockscreenIcon_manualDnd_returnsFixedIcon() {
+        ZenIconLoader iconLoader = mock(ZenIconLoader.class);
+
+        ListenableFuture<Drawable> future = TestModeBuilder.MANUAL_DND_INACTIVE.getLockscreenIcon(
+                RuntimeEnvironment.getApplication(), iconLoader);
+
+        assertThat(future.isDone()).isTrue();
+        verify(iconLoader, never()).getIcon(any(), any());
+    }
+
+    @Test
+    public void getLockscreenIcon_implicitMode_returnsFixedIcon() {
+        ZenIconLoader iconLoader = mock(ZenIconLoader.class);
+        ZenMode mode = new ZenMode(IMPLICIT_RULE_ID, IMPLICIT_ZEN_RULE,
+                zenConfigRuleFor(IMPLICIT_ZEN_RULE, false));
+
+        ListenableFuture<Drawable> future = mode.getLockscreenIcon(
+                RuntimeEnvironment.getApplication(), iconLoader);
+
+        assertThat(future.isDone()).isTrue();
+        verify(iconLoader, never()).getIcon(any(), any());
     }
 
     private static void assertUnparceledIsEqualToOriginal(String type, ZenMode original) {
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/gesture/domain/GestureInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/gesture/domain/GestureInteractorTest.kt
index bc142e6..6395448 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/gesture/domain/GestureInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/gesture/domain/GestureInteractorTest.kt
@@ -16,120 +16,108 @@
 
 package com.android.systemui.gesture.domain
 
+import android.app.ActivityManager
 import android.content.ComponentName
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.coroutines.collectLastValue
-import com.android.systemui.navigationbar.gestural.data.respository.GestureRepository
+import com.android.systemui.kosmos.backgroundCoroutineContext
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.navigationbar.gestural.data.gestureRepository
 import com.android.systemui.navigationbar.gestural.domain.GestureInteractor
+import com.android.systemui.shared.system.activityManagerWrapper
+import com.android.systemui.shared.system.taskStackChangeListeners
+import com.android.systemui.testKosmos
 import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.resetMain
-import kotlinx.coroutines.test.runCurrent
 import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.test.setMain
-import org.junit.After
-import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
-import org.mockito.Mock
 import org.mockito.junit.MockitoJUnit
 import org.mockito.junit.MockitoRule
-import org.mockito.kotlin.any
-import org.mockito.kotlin.argumentCaptor
 import org.mockito.kotlin.mock
-import org.mockito.kotlin.never
-import org.mockito.kotlin.verify
+import org.mockito.kotlin.spy
 import org.mockito.kotlin.whenever
 
 @RunWith(AndroidJUnit4::class)
 @SmallTest
 class GestureInteractorTest : SysuiTestCase() {
     @Rule @JvmField val mockitoRule: MockitoRule = MockitoJUnit.rule()
+    private val kosmos = testKosmos()
 
-    val dispatcher = StandardTestDispatcher()
+    val dispatcher = kosmos.testDispatcher
+    val repository = spy(kosmos.gestureRepository)
     val testScope = TestScope(dispatcher)
 
-    @Mock private lateinit var gestureRepository: GestureRepository
+    private val underTest by lazy { createInteractor() }
 
-    private val underTest by lazy {
-        GestureInteractor(gestureRepository, testScope.backgroundScope)
+    private fun createInteractor(): GestureInteractor {
+        return GestureInteractor(
+            repository,
+            dispatcher,
+            kosmos.backgroundCoroutineContext,
+            testScope,
+            kosmos.activityManagerWrapper,
+            kosmos.taskStackChangeListeners
+        )
     }
 
-    @Before
-    fun setup() {
-        Dispatchers.setMain(dispatcher)
-        whenever(gestureRepository.gestureBlockedActivities).thenReturn(MutableStateFlow(setOf()))
-    }
+    private fun setTopActivity(componentName: ComponentName) {
+        val task = mock<ActivityManager.RunningTaskInfo>()
+        task.topActivity = componentName
+        whenever(kosmos.activityManagerWrapper.runningTask).thenReturn(task)
 
-    @After
-    fun tearDown() {
-        Dispatchers.resetMain()
+        kosmos.taskStackChangeListeners.listenerImpl.onTaskStackChanged()
     }
 
     @Test
     fun addBlockedActivity_testCombination() =
         testScope.runTest {
             val globalComponent = mock<ComponentName>()
-            whenever(gestureRepository.gestureBlockedActivities)
-                .thenReturn(MutableStateFlow(setOf(globalComponent)))
+            repository.addGestureBlockedActivity(globalComponent)
+
             val localComponent = mock<ComponentName>()
+
+            val blocked by collectLastValue(underTest.topActivityBlocked)
+
             underTest.addGestureBlockedActivity(localComponent, GestureInteractor.Scope.Local)
-            val lastSeen by collectLastValue(underTest.gestureBlockedActivities)
-            testScope.runCurrent()
-            verify(gestureRepository, never()).addGestureBlockedActivity(any())
-            assertThat(lastSeen).hasSize(2)
-            assertThat(lastSeen).containsExactly(globalComponent, localComponent)
+
+            assertThat(blocked).isFalse()
+
+            setTopActivity(localComponent)
+
+            assertThat(blocked).isTrue()
+        }
+
+    @Test
+    fun initialization_testEmit() =
+        testScope.runTest {
+            val globalComponent = mock<ComponentName>()
+            repository.addGestureBlockedActivity(globalComponent)
+            setTopActivity(globalComponent)
+
+            val interactor = createInteractor()
+
+            val blocked by collectLastValue(interactor.topActivityBlocked)
+            assertThat(blocked).isTrue()
         }
 
     @Test
     fun addBlockedActivityLocally_onlyAffectsLocalInteractor() =
         testScope.runTest {
-            val component = mock<ComponentName>()
-            underTest.addGestureBlockedActivity(component, GestureInteractor.Scope.Local)
-            val lastSeen by collectLastValue(underTest.gestureBlockedActivities)
-            testScope.runCurrent()
-            verify(gestureRepository, never()).addGestureBlockedActivity(any())
-            assertThat(lastSeen).contains(component)
-        }
+            val interactor1 = createInteractor()
+            val interactor1Blocked by collectLastValue(interactor1.topActivityBlocked)
+            val interactor2 = createInteractor()
+            val interactor2Blocked by collectLastValue(interactor2.topActivityBlocked)
 
-    @Test
-    fun removeBlockedActivityLocally_onlyAffectsLocalInteractor() =
-        testScope.runTest {
-            val component = mock<ComponentName>()
-            underTest.addGestureBlockedActivity(component, GestureInteractor.Scope.Local)
-            val lastSeen by collectLastValue(underTest.gestureBlockedActivities)
-            testScope.runCurrent()
-            underTest.removeGestureBlockedActivity(component, GestureInteractor.Scope.Local)
-            testScope.runCurrent()
-            verify(gestureRepository, never()).removeGestureBlockedActivity(any())
-            assertThat(lastSeen).isEmpty()
-        }
+            val localComponent = mock<ComponentName>()
 
-    @Test
-    fun addBlockedActivity_invokesRepository() =
-        testScope.runTest {
-            val component = mock<ComponentName>()
-            underTest.addGestureBlockedActivity(component, GestureInteractor.Scope.Global)
-            runCurrent()
-            val captor = argumentCaptor<ComponentName>()
-            verify(gestureRepository).addGestureBlockedActivity(captor.capture())
-            assertThat(captor.firstValue).isEqualTo(component)
-        }
+            interactor1.addGestureBlockedActivity(localComponent, GestureInteractor.Scope.Local)
+            setTopActivity(localComponent)
 
-    @Test
-    fun removeBlockedActivity_invokesRepository() =
-        testScope.runTest {
-            val component = mock<ComponentName>()
-            underTest.removeGestureBlockedActivity(component, GestureInteractor.Scope.Global)
-            runCurrent()
-            val captor = argumentCaptor<ComponentName>()
-            verify(gestureRepository).removeGestureBlockedActivity(captor.capture())
-            assertThat(captor.firstValue).isEqualTo(component)
+            assertThat(interactor1Blocked).isTrue()
+            assertThat(interactor2Blocked).isFalse()
         }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java
index 388272f..0f82e02 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/EdgeBackGestureHandler.java
@@ -73,8 +73,8 @@
 
 import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
 import com.android.internal.policy.GestureNavigationSettingsObserver;
-import com.android.systemui.dagger.qualifiers.Background;
 import com.android.systemui.contextualeducation.GestureType;
+import com.android.systemui.dagger.qualifiers.Background;
 import com.android.systemui.model.SysUiState;
 import com.android.systemui.navigationbar.NavigationModeController;
 import com.android.systemui.navigationbar.gestural.domain.GestureInteractor;
@@ -102,6 +102,8 @@
 import com.android.wm.shell.desktopmode.DesktopMode;
 import com.android.wm.shell.pip.Pip;
 
+import kotlinx.coroutines.Job;
+
 import java.io.PrintWriter;
 import java.util.ArrayDeque;
 import java.util.Date;
@@ -109,6 +111,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Consumer;
@@ -158,7 +161,7 @@
     private TaskStackChangeListener mTaskStackListener = new TaskStackChangeListener() {
         @Override
         public void onTaskStackChanged() {
-            updateRunningActivityGesturesBlocked();
+            updateTopActivity();
         }
         @Override
         public void onTaskCreated(int taskId, ComponentName componentName) {
@@ -222,6 +225,8 @@
     private final Provider<LightBarController> mLightBarControllerProvider;
 
     private final GestureInteractor mGestureInteractor;
+    private final ArraySet<ComponentName> mBlockedActivities = new ArraySet<>();
+    private Job mBlockedActivitiesJob = null;
 
     private final JavaAdapter mJavaAdapter;
 
@@ -450,9 +455,6 @@
         mJavaAdapter = javaAdapter;
         mLastReportedConfig.setTo(mContext.getResources().getConfiguration());
 
-        mJavaAdapter.alwaysCollectFlow(mGestureInteractor.getGestureBlockedActivities(),
-                componentNames -> updateRunningActivityGesturesBlocked());
-
         ComponentName recentsComponentName = ComponentName.unflattenFromString(
                 context.getString(com.android.internal.R.string.config_recentsComponentName));
         if (recentsComponentName != null) {
@@ -568,12 +570,11 @@
         }
     }
 
-    private void updateRunningActivityGesturesBlocked() {
+    private void updateTopActivity() {
         if (edgebackGestureHandlerGetRunningTasksBackground()) {
-            mBackgroundExecutor.execute(() -> mGestureBlockingActivityRunning.set(
-                    isGestureBlockingActivityRunning()));
+            mBackgroundExecutor.execute(() -> updateTopActivityPackageName());
         } else {
-            mGestureBlockingActivityRunning.set(isGestureBlockingActivityRunning());
+            updateTopActivityPackageName();
         }
     }
 
@@ -678,6 +679,11 @@
                     Log.e(TAG, "Failed to unregister window manager callbacks", e);
                 }
 
+                if (mBlockedActivitiesJob != null) {
+                    mBlockedActivitiesJob.cancel(new CancellationException());
+                    mBlockedActivitiesJob = null;
+                }
+                mBlockedActivities.clear();
             } else {
                 mBackgroundExecutor.execute(mGestureNavigationSettingsObserver::register);
                 updateDisplaySize();
@@ -710,6 +716,12 @@
                 resetEdgeBackPlugin();
                 mPluginManager.addPluginListener(
                         this, NavigationEdgeBackPlugin.class, /*allowMultiple=*/ false);
+
+                // Begin listening to changes in blocked activities list
+                mBlockedActivitiesJob = mJavaAdapter.alwaysCollectFlow(
+                        mGestureInteractor.getTopActivityBlocked(),
+                        blocked -> mGestureBlockingActivityRunning.set(blocked));
+
             }
             // Update the ML model resources.
             updateMLModelState();
@@ -1302,7 +1314,7 @@
         }
     }
 
-    private boolean isGestureBlockingActivityRunning() {
+    private void updateTopActivityPackageName() {
         ActivityManager.RunningTaskInfo runningTask =
                 ActivityManagerWrapper.getInstance().getRunningTask();
         ComponentName topActivity = runningTask == null ? null : runningTask.topActivity;
@@ -1311,8 +1323,6 @@
         } else {
             mPackageName = "_UNKNOWN";
         }
-
-        return topActivity != null && mGestureInteractor.areGesturesBlocked(topActivity);
     }
 
     public void setBackAnimation(BackAnimation backAnimation) {
diff --git a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/domain/GestureInteractor.kt b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/domain/GestureInteractor.kt
index 6dc5939..6182878 100644
--- a/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/domain/GestureInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/navigationbar/gestural/domain/GestureInteractor.kt
@@ -17,17 +17,29 @@
 package com.android.systemui.navigationbar.gestural.domain
 
 import android.content.ComponentName
+import com.android.app.tracing.coroutines.flow.flowOn
 import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.dagger.qualifiers.Main
 import com.android.systemui.navigationbar.gestural.data.respository.GestureRepository
+import com.android.systemui.shared.system.ActivityManagerWrapper
+import com.android.systemui.shared.system.TaskStackChangeListener
+import com.android.systemui.shared.system.TaskStackChangeListeners
+import com.android.systemui.util.kotlin.combine
+import com.android.systemui.util.kotlin.emitOnStart
+import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
 import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.mapLatest
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 /**
  * {@link GestureInteractor} helps interact with gesture-related logic, including accessing the
@@ -37,7 +49,11 @@
 @Inject
 constructor(
     private val gestureRepository: GestureRepository,
-    @Application private val scope: CoroutineScope
+    @Main private val mainDispatcher: CoroutineDispatcher,
+    @Background private val backgroundCoroutineContext: CoroutineContext,
+    @Application private val scope: CoroutineScope,
+    private val activityManagerWrapper: ActivityManagerWrapper,
+    private val taskStackChangeListeners: TaskStackChangeListeners,
 ) {
     enum class Scope {
         Local,
@@ -45,16 +61,38 @@
     }
 
     private val _localGestureBlockedActivities = MutableStateFlow<Set<ComponentName>>(setOf())
-    /** A {@link StateFlow} for listening to changes in Activities where gestures are blocked */
-    val gestureBlockedActivities: StateFlow<Set<ComponentName>>
-        get() =
-            combine(
-                    gestureRepository.gestureBlockedActivities,
-                    _localGestureBlockedActivities.asStateFlow()
-                ) { global, local ->
-                    global + local
-                }
-                .stateIn(scope, SharingStarted.WhileSubscribed(), setOf())
+
+    private val _topActivity =
+        conflatedCallbackFlow {
+                val taskListener =
+                    object : TaskStackChangeListener {
+                        override fun onTaskStackChanged() {
+                            trySend(Unit)
+                        }
+                    }
+
+                taskStackChangeListeners.registerTaskStackListener(taskListener)
+                awaitClose { taskStackChangeListeners.unregisterTaskStackListener(taskListener) }
+            }
+            .flowOn(mainDispatcher)
+            .emitOnStart()
+            .mapLatest { getTopActivity() }
+            .distinctUntilChanged()
+
+    private suspend fun getTopActivity(): ComponentName? =
+        withContext(backgroundCoroutineContext) {
+            val runningTask = activityManagerWrapper.runningTask
+            runningTask?.topActivity
+        }
+
+    val topActivityBlocked =
+        combine(
+            _topActivity,
+            gestureRepository.gestureBlockedActivities,
+            _localGestureBlockedActivities.asStateFlow()
+        ) { activity, global, local ->
+            activity != null && (global + local).contains(activity)
+        }
 
     /**
      * Adds an {@link Activity} to be blocked based on component when the topmost, focused {@link
@@ -92,12 +130,4 @@
             }
         }
     }
-
-    /**
-     * Checks whether the specified {@link Activity} {@link ComponentName} is being blocked from
-     * gestures.
-     */
-    fun areGesturesBlocked(activity: ComponentName): Boolean {
-        return gestureBlockedActivities.value.contains(activity)
-    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/DeviceBasedSatelliteTableLog.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/DeviceBasedSatelliteTableLog.kt
new file mode 100644
index 0000000..a40d510
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/DeviceBasedSatelliteTableLog.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.statusbar.pipeline.dagger
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class DeviceBasedSatelliteTableLog
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
index a81bfa4..4850049 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/dagger/StatusBarPipelineModule.kt
@@ -239,6 +239,13 @@
             return factory.create("VerboseDeviceBasedSatelliteInputLog", 200)
         }
 
+        @Provides
+        @SysUISingleton
+        @DeviceBasedSatelliteTableLog
+        fun provideDeviceBasedSatelliteTableLog(factory: TableLogBufferFactory): TableLogBuffer {
+            return factory.create("DeviceBasedSatelliteTableLog", 200)
+        }
+
         const val FIRST_MOBILE_SUB_SHOWING_NETWORK_TYPE_ICON =
             "FirstMobileSubShowingNetworkTypeIcon"
     }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
index cc4d568..26553e6 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/mobile/domain/interactor/MobileIconsInteractor.kt
@@ -385,7 +385,15 @@
             .stateIn(scope, SharingStarted.WhileSubscribed(), false)
 
     override val isDeviceInEmergencyCallsOnlyMode: Flow<Boolean> =
-        mobileConnectionsRepo.deviceServiceState.map { it?.isEmergencyOnly ?: false }
+        mobileConnectionsRepo.deviceServiceState
+            .map { it?.isEmergencyOnly ?: false }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLogger,
+                columnPrefix = LOGGING_PREFIX,
+                columnName = "deviceEmergencyOnly",
+                initialValue = false,
+            )
 
     /** Vends out new [MobileIconInteractor] for a particular subId */
     override fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractor.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractor.kt
index 03f88c7..f1a444f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractor.kt
@@ -21,7 +21,10 @@
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.log.LogBuffer
 import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
 import com.android.systemui.statusbar.pipeline.dagger.DeviceBasedSatelliteInputLog
+import com.android.systemui.statusbar.pipeline.dagger.DeviceBasedSatelliteTableLog
 import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor
 import com.android.systemui.statusbar.pipeline.satellite.data.DeviceBasedSatelliteRepository
 import com.android.systemui.statusbar.pipeline.satellite.shared.model.SatelliteConnectionState
@@ -33,6 +36,7 @@
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
@@ -47,6 +51,7 @@
     wifiInteractor: WifiInteractor,
     @Application scope: CoroutineScope,
     @DeviceBasedSatelliteInputLog private val logBuffer: LogBuffer,
+    @DeviceBasedSatelliteTableLog private val tableLog: TableLogBuffer,
 ) {
     /** Must be observed by any UI showing Satellite iconography */
     val isSatelliteAllowed =
@@ -55,6 +60,13 @@
             } else {
                 flowOf(false)
             }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "",
+                columnName = COL_ALLOWED,
+                initialValue = false,
+            )
             .stateIn(scope, SharingStarted.WhileSubscribed(), false)
 
     /** See [SatelliteConnectionState] for relevant states */
@@ -65,6 +77,12 @@
 
                 flowOf(SatelliteConnectionState.Off)
             }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "",
+                initialValue = SatelliteConnectionState.Off,
+            )
             .stateIn(scope, SharingStarted.WhileSubscribed(), SatelliteConnectionState.Off)
 
     /** 0-4 description of the connection strength */
@@ -74,6 +92,13 @@
             } else {
                 flowOf(0)
             }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "",
+                columnName = COL_LEVEL,
+                initialValue = 0,
+            )
             .stateIn(scope, SharingStarted.WhileSubscribed(), 0)
 
     val isSatelliteProvisioned = repo.isSatelliteProvisioned
@@ -82,19 +107,27 @@
         wifiInteractor.wifiNetwork.map { it is WifiNetworkModel.Active }
 
     private val allConnectionsOos =
-        iconsInteractor.icons.aggregateOver(
-            selector = { intr ->
-                combine(intr.isInService, intr.isEmergencyOnly, intr.isNonTerrestrial) {
-                    isInService,
-                    isEmergencyOnly,
-                    isNtn ->
-                    !isInService && !isEmergencyOnly && !isNtn
-                }
-            },
-            defaultValue = true, // no connections == everything is OOS
-        ) { isOosAndNotEmergencyAndNotSatellite ->
-            isOosAndNotEmergencyAndNotSatellite.all { it }
-        }
+        iconsInteractor.icons
+            .aggregateOver(
+                selector = { intr ->
+                    combine(intr.isInService, intr.isEmergencyOnly, intr.isNonTerrestrial) {
+                        isInService,
+                        isEmergencyOnly,
+                        isNtn ->
+                        !isInService && !isEmergencyOnly && !isNtn
+                    }
+                },
+                defaultValue = true, // no connections == everything is OOS
+            ) { isOosAndNotEmergencyAndNotSatellite ->
+                isOosAndNotEmergencyAndNotSatellite.all { it }
+            }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "",
+                columnName = COL_ALL_OOS,
+                initialValue = true,
+            )
 
     /** When all connections are considered OOS, satellite connectivity is potentially valid */
     val areAllConnectionsOutOfService =
@@ -122,10 +155,24 @@
             } else {
                 flowOf(false)
             }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "",
+                columnName = COL_FULL_OOS,
+                initialValue = true,
+            )
             .stateIn(scope, SharingStarted.WhileSubscribed(), true)
 
     companion object {
         const val TAG = "DeviceBasedSatelliteInteractor"
+
+        const val COL_LEVEL = "level"
+        const val COL_ALL_OOS = "allConnsOOS"
+        const val COL_ALLOWED = "allowed"
+        // Going to try to optimize for not using too much width on the table here. This information
+        // can be ascertained by checking for the device emergency only in the mobile logs as well
+        const val COL_FULL_OOS = "allOosAndNoEmer"
     }
 }
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/shared/model/SatelliteConnectionState.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/shared/model/SatelliteConnectionState.kt
index bfe2941..905ed730 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/shared/model/SatelliteConnectionState.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/shared/model/SatelliteConnectionState.kt
@@ -26,8 +26,10 @@
 import android.telephony.satellite.SatelliteManager.SATELLITE_MODEM_STATE_OFF
 import android.telephony.satellite.SatelliteManager.SATELLITE_MODEM_STATE_UNAVAILABLE
 import android.telephony.satellite.SatelliteManager.SATELLITE_MODEM_STATE_UNKNOWN
+import com.android.systemui.log.table.Diffable
+import com.android.systemui.log.table.TableRowLogger
 
-enum class SatelliteConnectionState {
+enum class SatelliteConnectionState : Diffable<SatelliteConnectionState> {
     // State is unknown or undefined
     Unknown,
     // Radio is off
@@ -37,7 +39,15 @@
     // Radio is connected, aka satellite is available for use
     Connected;
 
+    override fun logDiffs(prevVal: SatelliteConnectionState, row: TableRowLogger) {
+        if (prevVal != this) {
+            row.logChange(COL_CONNECTION_STATE, name)
+        }
+    }
+
     companion object {
+        const val COL_CONNECTION_STATE = "connState"
+
         // TODO(b/316635648): validate these states. We don't need the level of granularity that
         //  SatelliteManager gives us.
         fun fromModemState(@SatelliteManager.SatelliteModemState modemState: Int) =
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt
index 48278d4..199b5b67 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModel.kt
@@ -22,9 +22,12 @@
 import com.android.systemui.dagger.qualifiers.Application
 import com.android.systemui.log.LogBuffer
 import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.table.TableLogBuffer
+import com.android.systemui.log.table.logDiffsForTable
 import com.android.systemui.res.R
 import com.android.systemui.statusbar.pipeline.airplane.data.repository.AirplaneModeRepository
 import com.android.systemui.statusbar.pipeline.dagger.DeviceBasedSatelliteInputLog
+import com.android.systemui.statusbar.pipeline.dagger.DeviceBasedSatelliteTableLog
 import com.android.systemui.statusbar.pipeline.satellite.domain.interactor.DeviceBasedSatelliteInteractor
 import com.android.systemui.statusbar.pipeline.satellite.shared.model.SatelliteConnectionState
 import com.android.systemui.statusbar.pipeline.satellite.ui.model.SatelliteIconModel
@@ -71,22 +74,34 @@
     @Application scope: CoroutineScope,
     airplaneModeRepository: AirplaneModeRepository,
     @DeviceBasedSatelliteInputLog logBuffer: LogBuffer,
+    @DeviceBasedSatelliteTableLog tableLog: TableLogBuffer,
 ) : DeviceBasedSatelliteViewModel {
     private val shouldShowIcon: Flow<Boolean> =
-        interactor.areAllConnectionsOutOfService.flatMapLatest { allOos ->
-            if (!allOos) {
-                flowOf(false)
-            } else {
-                combine(
-                    interactor.isSatelliteAllowed,
-                    interactor.isSatelliteProvisioned,
-                    interactor.isWifiActive,
-                    airplaneModeRepository.isAirplaneMode
-                ) { isSatelliteAllowed, isSatelliteProvisioned, isWifiActive, isAirplaneMode ->
-                    isSatelliteAllowed && isSatelliteProvisioned && !isWifiActive && !isAirplaneMode
+        interactor.areAllConnectionsOutOfService
+            .flatMapLatest { allOos ->
+                if (!allOos) {
+                    flowOf(false)
+                } else {
+                    combine(
+                        interactor.isSatelliteAllowed,
+                        interactor.isSatelliteProvisioned,
+                        interactor.isWifiActive,
+                        airplaneModeRepository.isAirplaneMode
+                    ) { isSatelliteAllowed, isSatelliteProvisioned, isWifiActive, isAirplaneMode ->
+                        isSatelliteAllowed &&
+                            isSatelliteProvisioned &&
+                            !isWifiActive &&
+                            !isAirplaneMode
+                    }
                 }
             }
-        }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "vm",
+                columnName = COL_VISIBLE_CONDITION,
+                initialValue = false,
+            )
 
     // This adds a 10 seconds delay before showing the icon
     private val shouldActuallyShowIcon: StateFlow<Boolean> =
@@ -106,6 +121,13 @@
                     flowOf(false)
                 }
             }
+            .distinctUntilChanged()
+            .logDiffsForTable(
+                tableLog,
+                columnPrefix = "vm",
+                columnName = COL_VISIBLE,
+                initialValue = false,
+            )
             .stateIn(scope, SharingStarted.WhileSubscribed(), false)
 
     override val icon: StateFlow<Icon?> =
@@ -163,5 +185,8 @@
     companion object {
         private const val TAG = "DeviceBasedSatelliteViewModel"
         private val DELAY_DURATION = 10.seconds
+
+        const val COL_VISIBLE_CONDITION = "visCondition"
+        const val COL_VISIBLE = "visible"
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java
index 2468449..eb91518 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java
@@ -695,11 +695,10 @@
             addRow(AudioManager.STREAM_MUSIC,
                     R.drawable.ic_volume_media, R.drawable.ic_volume_media_mute, true, true);
             if (!AudioSystem.isSingleVolume(mContext)) {
-
                 addRow(AudioManager.STREAM_RING, R.drawable.ic_ring_volume,
                         R.drawable.ic_ring_volume_off, true, false);
-
-
+                addRow(AudioManager.STREAM_NOTIFICATION, R.drawable.ic_volume_ringer,
+                        R.drawable.ic_volume_off, true, false);
                 addRow(STREAM_ALARM,
                         R.drawable.ic_alarm, R.drawable.ic_volume_alarm_mute, true, false);
                 addRow(AudioManager.STREAM_VOICE_CALL,
@@ -1994,7 +1993,7 @@
                                             : R.drawable.ic_volume_media_bt;
             }
         } else if (isStreamMuted(ss)) {
-            iconRes = ss.muted ? R.drawable.ic_volume_media_off : row.iconMuteRes;
+            iconRes = (ss.muted && isTv()) ? R.drawable.ic_volume_media_off : row.iconMuteRes;
         } else {
             iconRes = mShowLowMediaVolumeIcon && ss.level * 2 < (ss.levelMax + ss.levelMin)
                       ? R.drawable.ic_volume_media_low : row.iconRes;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractorTest.kt
index cd0390e..dbb77d5 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractorTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/domain/interactor/DeviceBasedSatelliteInteractorTest.kt
@@ -70,6 +70,7 @@
                 wifiInteractor,
                 testScope.backgroundScope,
                 FakeLogBuffer.Factory.create(),
+                mock(),
             )
     }
 
@@ -113,6 +114,7 @@
                     wifiInteractor,
                     testScope.backgroundScope,
                     FakeLogBuffer.Factory.create(),
+                    mock(),
                 )
 
             val latest by collectLastValue(underTest.isSatelliteAllowed)
@@ -161,6 +163,7 @@
                     wifiInteractor,
                     testScope.backgroundScope,
                     FakeLogBuffer.Factory.create(),
+                    mock(),
                 )
 
             val latest by collectLastValue(underTest.connectionState)
@@ -217,6 +220,7 @@
                     wifiInteractor,
                     testScope.backgroundScope,
                     FakeLogBuffer.Factory.create(),
+                    mock(),
                 )
 
             val latest by collectLastValue(underTest.signalStrength)
@@ -535,6 +539,7 @@
                     wifiInteractor,
                     testScope.backgroundScope,
                     FakeLogBuffer.Factory.create(),
+                    mock(),
                 )
 
             val latest by collectLastValue(underTest.areAllConnectionsOutOfService)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt
index 64b07fc..c3cc33f 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/pipeline/satellite/ui/viewmodel/DeviceBasedSatelliteViewModelTest.kt
@@ -33,7 +33,6 @@
 import com.android.systemui.statusbar.pipeline.wifi.data.repository.FakeWifiRepository
 import com.android.systemui.statusbar.pipeline.wifi.domain.interactor.WifiInteractorImpl
 import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
-import com.android.systemui.util.mockito.mock
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.Test
 import kotlin.time.Duration.Companion.seconds
@@ -44,6 +43,7 @@
 import org.junit.Before
 import org.junit.runner.RunWith
 import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.mock
 
 @SmallTest
 @RunWith(AndroidJUnit4::class)
@@ -73,6 +73,7 @@
                 wifiInteractor,
                 testScope.backgroundScope,
                 FakeLogBuffer.Factory.create(),
+                mock(),
             )
 
         underTest =
@@ -82,6 +83,7 @@
                 testScope.backgroundScope,
                 airplaneModeRepository,
                 FakeLogBuffer.Factory.create(),
+                mock(),
             )
     }
 
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/domain/GestureInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/domain/GestureInteractorKosmos.kt
index 658aaa6..1d2439c 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/domain/GestureInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/domain/GestureInteractorKosmos.kt
@@ -16,12 +16,23 @@
 
 package com.android.systemui.keyguard.gesture.domain
 
-import com.android.systemui.keyguard.gesture.data.gestureRepository
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.kosmos.backgroundCoroutineContext
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.navigationbar.gestural.data.gestureRepository
 import com.android.systemui.navigationbar.gestural.domain.GestureInteractor
+import com.android.systemui.shared.system.activityManagerWrapper
+import com.android.systemui.shared.system.taskStackChangeListeners
 
 val Kosmos.gestureInteractor: GestureInteractor by
     Kosmos.Fixture {
-        GestureInteractor(gestureRepository = gestureRepository, scope = applicationCoroutineScope)
+        GestureInteractor(
+            gestureRepository = gestureRepository,
+            mainDispatcher = testDispatcher,
+            backgroundCoroutineContext = backgroundCoroutineContext,
+            scope = applicationCoroutineScope,
+            activityManagerWrapper = activityManagerWrapper,
+            taskStackChangeListeners = taskStackChangeListeners
+        )
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/data/GestureRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/navigationbar/gestural/data/GestureRepositoryKosmos.kt
similarity index 94%
rename from packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/data/GestureRepositoryKosmos.kt
rename to packages/SystemUI/tests/utils/src/com/android/systemui/navigationbar/gestural/data/GestureRepositoryKosmos.kt
index 9bd346e..55ce43a 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/gesture/data/GestureRepositoryKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/navigationbar/gestural/data/GestureRepositoryKosmos.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.android.systemui.keyguard.gesture.data
+package com.android.systemui.navigationbar.gestural.data
 
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.testDispatcher
diff --git a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java
index f30e770..954651d 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java
@@ -18,7 +18,6 @@
 
 import static android.app.appfunctions.flags.Flags.enableAppFunctionManager;
 
-import android.app.appfunctions.IAppFunctionManager;
 import android.content.Context;
 
 import com.android.server.SystemService;
@@ -27,19 +26,17 @@
  * Service that manages app functions.
  */
 public class AppFunctionManagerService extends SystemService {
+    private final AppFunctionManagerServiceImpl mServiceImpl;
 
     public AppFunctionManagerService(Context context) {
         super(context);
+        mServiceImpl = new AppFunctionManagerServiceImpl(context);
     }
 
     @Override
     public void onStart() {
         if (enableAppFunctionManager()) {
-            publishBinderService(Context.APP_FUNCTION_SERVICE, new AppFunctionManagerStub());
+            publishBinderService(Context.APP_FUNCTION_SERVICE, mServiceImpl);
         }
     }
-
-    private static class AppFunctionManagerStub extends IAppFunctionManager.Stub {
-
-    }
 }
diff --git a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
new file mode 100644
index 0000000..e2167a8
--- /dev/null
+++ b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
@@ -0,0 +1,196 @@
+/*
+ * 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.server.appfunctions;
+
+import android.annotation.NonNull;
+import android.app.appfunctions.ExecuteAppFunctionAidlRequest;
+import android.app.appfunctions.ExecuteAppFunctionResponse;
+import android.app.appfunctions.IAppFunctionManager;
+import android.app.appfunctions.IAppFunctionService;
+import android.app.appfunctions.IExecuteAppFunctionCallback;
+import android.app.appfunctions.SafeOneTimeExecuteAppFunctionCallback;
+import android.app.appfunctions.ServiceCallHelper;
+import android.app.appfunctions.ServiceCallHelper.RunServiceCallCallback;
+import android.app.appfunctions.ServiceCallHelper.ServiceUsageCompleteListener;
+import android.app.appfunctions.ServiceCallHelperImpl;
+import android.content.Context;
+import android.content.Intent;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.util.Slog;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Objects;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Implementation of the AppFunctionManagerService.
+ */
+public class AppFunctionManagerServiceImpl extends IAppFunctionManager.Stub {
+    private static final String TAG = AppFunctionManagerServiceImpl.class.getSimpleName();
+    private final ServiceCallHelper<IAppFunctionService> mExternalServiceCallHelper;
+    private final CallerValidator mCallerValidator;
+    private final ServiceHelper mInternalServiceHelper;
+
+    public AppFunctionManagerServiceImpl(@NonNull Context context) {
+        this(new ServiceCallHelperImpl<>(
+                        context,
+                        IAppFunctionService.Stub::asInterface, new ThreadPoolExecutor(
+                        /*corePoolSize=*/ Runtime.getRuntime().availableProcessors(),
+                        /*maxConcurrency=*/ Runtime.getRuntime().availableProcessors(),
+                        /*keepAliveTime=*/ 0L,
+                        /*unit=*/ TimeUnit.SECONDS,
+                        /*workQueue=*/ new LinkedBlockingQueue<>())),
+                new CallerValidatorImpl(context),
+                new ServiceHelperImpl(context));
+    }
+
+    @VisibleForTesting
+    AppFunctionManagerServiceImpl(ServiceCallHelper<IAppFunctionService> serviceCallHelper,
+                                  CallerValidator apiValidator,
+                                  ServiceHelper appFunctionInternalServiceHelper) {
+        mExternalServiceCallHelper = Objects.requireNonNull(serviceCallHelper);
+        mCallerValidator = Objects.requireNonNull(apiValidator);
+        mInternalServiceHelper =
+                Objects.requireNonNull(appFunctionInternalServiceHelper);
+    }
+
+    @Override
+    public void executeAppFunction(
+            @NonNull ExecuteAppFunctionAidlRequest requestInternal,
+            @NonNull IExecuteAppFunctionCallback executeAppFunctionCallback) {
+        Objects.requireNonNull(requestInternal);
+        Objects.requireNonNull(executeAppFunctionCallback);
+
+        final SafeOneTimeExecuteAppFunctionCallback safeExecuteAppFunctionCallback =
+                new SafeOneTimeExecuteAppFunctionCallback(executeAppFunctionCallback);
+
+        String validatedCallingPackage = mCallerValidator
+                .validateCallingPackage(requestInternal.getCallingPackage());
+        UserHandle targetUser = mCallerValidator.verifyTargetUserHandle(
+                requestInternal.getUserHandle(), validatedCallingPackage);
+
+        // TODO(b/354956319): Add and honor the new enterprise policies.
+        if (mCallerValidator.isUserOrganizationManaged(targetUser)) {
+            safeExecuteAppFunctionCallback.onResult(new ExecuteAppFunctionResponse.Builder(
+                    ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR,
+                    "Cannot run on a device with a device owner or from the managed profile."
+            ).build());
+            return;
+        }
+
+        String targetPackageName = requestInternal.getClientRequest().getTargetPackageName();
+        if (TextUtils.isEmpty(targetPackageName)) {
+            safeExecuteAppFunctionCallback.onResult(new ExecuteAppFunctionResponse.Builder(
+                    ExecuteAppFunctionResponse.RESULT_INVALID_ARGUMENT,
+                    "Target package name cannot be empty."
+            ).build());
+            return;
+        }
+
+        if (!mCallerValidator.verifyCallerCanExecuteAppFunction(
+                validatedCallingPackage, targetPackageName)) {
+            throw new SecurityException("Caller does not have permission to execute the app "
+                    + "function.");
+        }
+
+        Intent serviceIntent = mInternalServiceHelper.resolveAppFunctionService(
+                targetPackageName,
+                targetUser);
+        if (serviceIntent == null) {
+            safeExecuteAppFunctionCallback.onResult(new ExecuteAppFunctionResponse.Builder(
+                    ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR,
+                    "Cannot find the target service."
+            ).build());
+            return;
+        }
+
+        // TODO(b/357551503): Offload call to async executor.
+        bindAppFunctionServiceUnchecked(requestInternal, serviceIntent, targetUser,
+                safeExecuteAppFunctionCallback,
+                /*bindFlags=*/ Context.BIND_AUTO_CREATE,
+                // TODO(b/357551503): Make timeout configurable.
+                /*timeoutInMillis=*/ 30_000L);
+    }
+
+    private void bindAppFunctionServiceUnchecked(
+            @NonNull ExecuteAppFunctionAidlRequest requestInternal,
+            @NonNull Intent serviceIntent, @NonNull UserHandle targetUser,
+            @NonNull SafeOneTimeExecuteAppFunctionCallback
+                    safeExecuteAppFunctionCallback,
+            int bindFlags, long timeoutInMillis) {
+        boolean bindServiceResult = mExternalServiceCallHelper.runServiceCall(
+                serviceIntent,
+                bindFlags,
+                timeoutInMillis,
+                targetUser,
+                /*timeOutCallback=*/ new RunServiceCallCallback<IAppFunctionService>() {
+                    @Override
+                    public void onServiceConnected(@NonNull IAppFunctionService service,
+                                                   @NonNull ServiceUsageCompleteListener
+                                                           serviceUsageCompleteListener) {
+                        try {
+                            service.executeAppFunction(
+                                    requestInternal.getClientRequest(),
+                                    new IExecuteAppFunctionCallback.Stub() {
+                                        @Override
+                                        public void onResult(ExecuteAppFunctionResponse response) {
+                                            safeExecuteAppFunctionCallback.onResult(response);
+                                            serviceUsageCompleteListener.onCompleted();
+                                        }
+                                    }
+                            );
+                        } catch (Exception e) {
+                            safeExecuteAppFunctionCallback.onResult(new ExecuteAppFunctionResponse
+                                    .Builder(ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR,
+                                    e.getMessage()).build());
+                            serviceUsageCompleteListener.onCompleted();
+                        }
+                    }
+
+                    @Override
+                    public void onFailedToConnect() {
+                        Slog.e(TAG, "Failed to connect to service");
+                        safeExecuteAppFunctionCallback.onResult(new ExecuteAppFunctionResponse
+                                .Builder(ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR,
+                                "Failed to connect to AppFunctionService").build());
+                    }
+
+                    @Override
+                    public void onTimedOut() {
+                        Slog.e(TAG, "Timed out");
+                        safeExecuteAppFunctionCallback.onResult(
+                                new ExecuteAppFunctionResponse.Builder(
+                                        ExecuteAppFunctionResponse.RESULT_TIMED_OUT,
+                                        "Binding to AppFunctionService timed out."
+                                ).build());
+                    }
+                }
+        );
+
+        if (!bindServiceResult) {
+            Slog.e(TAG, "Failed to bind to the AppFunctionService");
+            safeExecuteAppFunctionCallback.onResult(new ExecuteAppFunctionResponse.Builder(
+                    ExecuteAppFunctionResponse.RESULT_TIMED_OUT,
+                    "Failed to bind the AppFunctionService."
+            ).build());
+        }
+    }
+}
diff --git a/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java b/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java
new file mode 100644
index 0000000..9bd633f
--- /dev/null
+++ b/services/appfunctions/java/com/android/server/appfunctions/CallerValidator.java
@@ -0,0 +1,81 @@
+/*
+ * 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.server.appfunctions;
+
+import android.Manifest;
+import android.annotation.NonNull;
+import android.os.UserHandle;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+
+/**
+ * Interface for validating that the caller has the correct privilege to call an AppFunctionManager
+ * API.
+ */
+@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+public interface CallerValidator {
+    // TODO(b/357551503): Should we verify NOT instant app?
+    // TODO(b/357551503): Verify that user have been unlocked.
+
+    /**
+     * This method is used to validate that the calling package reported in the request is the
+     * same as the binder calling identity.
+     *
+     * @param claimedCallingPackage The package name of the caller.
+     * @return The package name of the caller.
+     * @throws SecurityException if the package name and uid don't match.
+     */
+    String validateCallingPackage(@NonNull String claimedCallingPackage);
+
+    /**
+     * Validates that the caller can invoke an AppFunctionManager API in the provided
+     * target user space.
+     *
+     * @param targetUserHandle      The user which the caller is requesting to execute as.
+     * @param claimedCallingPackage The package name of the caller.
+     * @return The user handle that the call should run as. Will always be a concrete user.
+     * @throws IllegalArgumentException if the target user is a special user.
+     * @throws SecurityException        if caller trying to interact across users without {@link
+     *                                  Manifest.permission#INTERACT_ACROSS_USERS_FULL}
+     */
+    UserHandle verifyTargetUserHandle(@NonNull UserHandle targetUserHandle,
+                                      @NonNull String claimedCallingPackage);
+
+    /**
+     * Validates that the caller can execute the specified app function.
+     * <p>
+     * The caller can execute if the app function's package name is the same as the caller's package
+     * or the caller has either {@link Manifest.permission.EXECUTE_APP_FUNCTIONS_TRUSTED} or
+     * {@link Manifest.permission.EXECUTE_APP_FUNCTIONS} granted. In some cases, app functions
+     * can still opt-out of caller having {@link Manifest.permission.EXECUTE_APP_FUNCTIONS}.
+     *
+     * @param callerPackageName     The calling package (as previously validated).
+     * @param targetPackageName     The package that owns the app function to execute.
+     * @return Whether the caller can execute the specified app function.
+     */
+    boolean verifyCallerCanExecuteAppFunction(
+            @NonNull String callerPackageName, @NonNull String targetPackageName);
+
+    /**
+     * Checks if the user is organization managed.
+     *
+     * @param targetUser The user which the caller is requesting to execute as.
+     * @return Whether the user is organization managed.
+     */
+    boolean isUserOrganizationManaged(@NonNull UserHandle targetUser);
+}
diff --git a/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java b/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java
new file mode 100644
index 0000000..7cd660d
--- /dev/null
+++ b/services/appfunctions/java/com/android/server/appfunctions/CallerValidatorImpl.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.appfunctions;
+
+import android.Manifest;
+import android.annotation.BinderThread;
+import android.annotation.NonNull;
+import android.annotation.RequiresPermission;
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.UserManager;
+
+import java.util.Objects;
+
+/* Validates that caller has the correct privilege to call an AppFunctionManager Api. */
+class CallerValidatorImpl implements CallerValidator {
+    private final Context mContext;
+
+
+    CallerValidatorImpl(@NonNull Context context) {
+        mContext = Objects.requireNonNull(context);
+    }
+
+    @Override
+    @NonNull
+    @BinderThread
+    public String validateCallingPackage(@NonNull String claimedCallingPackage) {
+        int callingUid = Binder.getCallingUid();
+        final long callingIdentityToken = Binder.clearCallingIdentity();
+        try {
+            validateCallingPackageInternal(callingUid, claimedCallingPackage);
+            return claimedCallingPackage;
+        } finally {
+            Binder.restoreCallingIdentity(callingIdentityToken);
+        }
+    }
+
+    @Override
+    @NonNull
+    @BinderThread
+    public UserHandle verifyTargetUserHandle(@NonNull UserHandle targetUserHandle,
+                                             @NonNull String claimedCallingPackage) {
+        int callingPid = Binder.getCallingPid();
+        int callingUid = Binder.getCallingUid();
+        final long callingIdentityToken = Binder.clearCallingIdentity();
+        try {
+            return handleIncomingUser(claimedCallingPackage, targetUserHandle,
+                    callingPid, callingUid);
+        } finally {
+            Binder.restoreCallingIdentity(callingIdentityToken);
+        }
+    }
+
+    @Override
+    @BinderThread
+    @RequiresPermission(anyOf = {Manifest.permission.EXECUTE_APP_FUNCTIONS_TRUSTED,
+            Manifest.permission.EXECUTE_APP_FUNCTIONS}, conditional = true)
+    // TODO(b/360864791): Add and honor apps that opt-out from EXECUTE_APP_FUNCTIONS caller.
+    public boolean verifyCallerCanExecuteAppFunction(
+            @NonNull String callerPackageName, @NonNull String targetPackageName) {
+        int pid = Binder.getCallingPid();
+        int uid = Binder.getCallingUid();
+        boolean hasExecutionPermission = mContext.checkPermission(
+                Manifest.permission.EXECUTE_APP_FUNCTIONS_TRUSTED, pid, uid)
+                == PackageManager.PERMISSION_GRANTED;
+        boolean hasTrustedExecutionPermission = mContext.checkPermission(
+                Manifest.permission.EXECUTE_APP_FUNCTIONS, pid, uid)
+                == PackageManager.PERMISSION_GRANTED;
+        boolean isSamePackage = callerPackageName.equals(targetPackageName);
+        return hasExecutionPermission || hasTrustedExecutionPermission || isSamePackage;
+    }
+
+    @Override
+    @BinderThread
+    public boolean isUserOrganizationManaged(@NonNull UserHandle targetUser) {
+        final long callingIdentityToken = Binder.clearCallingIdentity();
+        try {
+            if (Objects.requireNonNull(mContext.getSystemService(DevicePolicyManager.class))
+                    .isDeviceManaged()) {
+                return true;
+            }
+            return Objects.requireNonNull(mContext.getSystemService(UserManager.class))
+                    .isManagedProfile(targetUser.getIdentifier());
+        } finally {
+            Binder.restoreCallingIdentity(callingIdentityToken);
+        }
+    }
+
+    /**
+     * Helper for dealing with incoming user arguments to system service calls.
+     *
+     * <p>Takes care of checking permissions and if the target is special user, this method will
+     * simply throw.
+     *
+     * @param callingPackageName The package name of the caller.
+     * @param targetUserHandle   The user which the caller is requesting to execute as.
+     * @param callingPid         The actual pid of the caller as determined by Binder.
+     * @param callingUid         The actual uid of the caller as determined by Binder.
+     * @return the user handle that the call should run as. Will always be a concrete user.
+     * @throws IllegalArgumentException if the target user is a special user.
+     * @throws SecurityException        if caller trying to interact across user without {@link
+     *                                  Manifest.permission#INTERACT_ACROSS_USERS_FULL}
+     */
+    @NonNull
+    private UserHandle handleIncomingUser(
+            @NonNull String callingPackageName,
+            @NonNull UserHandle targetUserHandle,
+            int callingPid,
+            int callingUid) {
+        UserHandle callingUserHandle = UserHandle.getUserHandleForUid(callingUid);
+        if (callingUserHandle.equals(targetUserHandle)) {
+            return targetUserHandle;
+        }
+
+        // Duplicates UserController#ensureNotSpecialUser
+        if (targetUserHandle.getIdentifier() < 0) {
+            throw new IllegalArgumentException(
+                    "Call does not support special user " + targetUserHandle);
+        }
+
+        if (mContext.checkPermission(
+                Manifest.permission.INTERACT_ACROSS_USERS_FULL, callingPid, callingUid)
+                == PackageManager.PERMISSION_GRANTED) {
+            try {
+                mContext.createPackageContextAsUser(
+                        callingPackageName, /* flags= */ 0, targetUserHandle);
+            } catch (PackageManager.NameNotFoundException e) {
+                throw new SecurityException(
+                        "Package: "
+                                + callingPackageName
+                                + " haven't installed for user "
+                                + targetUserHandle.getIdentifier());
+            }
+            return targetUserHandle;
+        }
+        throw new SecurityException(
+                "Permission denied while calling from uid "
+                        + callingUid
+                        + " with "
+                        + targetUserHandle
+                        + "; Requires permission: "
+                        + Manifest.permission.INTERACT_ACROSS_USERS_FULL);
+    }
+
+    /**
+     * Checks that the caller's supposed package name matches the uid making the call.
+     *
+     * @throws SecurityException if the package name and uid don't match.
+     */
+    private void validateCallingPackageInternal(
+            int actualCallingUid, @NonNull String claimedCallingPackage) {
+        UserHandle callingUserHandle = UserHandle.getUserHandleForUid(actualCallingUid);
+        Context actualCallingUserContext = mContext.createContextAsUser(
+                callingUserHandle, /* flags= */ 0);
+        int claimedCallingUid =
+                getPackageUid(actualCallingUserContext, claimedCallingPackage);
+        if (claimedCallingUid != actualCallingUid) {
+            throw new SecurityException(
+                    "Specified calling package ["
+                            + claimedCallingPackage
+                            + "] does not match the calling uid "
+                            + actualCallingUid);
+        }
+    }
+
+    /**
+     * Finds the UID of the {@code packageName} in the given {@code context}. Returns {@link
+     * Process#INVALID_UID} if unable to find the UID.
+     */
+    private int getPackageUid(@NonNull Context context, @NonNull String packageName) {
+        try {
+            return context.getPackageManager().getPackageUid(packageName, /* flags= */ 0);
+        } catch (PackageManager.NameNotFoundException e) {
+            return Process.INVALID_UID;
+        }
+    }
+}
diff --git a/services/appfunctions/java/com/android/server/appfunctions/ServiceHelper.java b/services/appfunctions/java/com/android/server/appfunctions/ServiceHelper.java
new file mode 100644
index 0000000..6cd87d3
--- /dev/null
+++ b/services/appfunctions/java/com/android/server/appfunctions/ServiceHelper.java
@@ -0,0 +1,39 @@
+/*
+ * 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.server.appfunctions;
+
+import android.annotation.NonNull;
+import android.content.Intent;
+import android.os.UserHandle;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * Helper interface for AppFunctionService.
+ */
+@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
+public interface ServiceHelper {
+    /**
+     * Resolves the AppFunctionService for the target package.
+     *
+     * @param targetPackageName The package name of the target.
+     * @param targetUser        The user which the caller is requesting to execute as.
+     * @return The intent to bind to the target service.
+     */
+    Intent resolveAppFunctionService(@NonNull String targetPackageName,
+                                     @NonNull UserHandle targetUser);
+}
diff --git a/services/appfunctions/java/com/android/server/appfunctions/ServiceHelperImpl.java b/services/appfunctions/java/com/android/server/appfunctions/ServiceHelperImpl.java
new file mode 100644
index 0000000..e49fba5
--- /dev/null
+++ b/services/appfunctions/java/com/android/server/appfunctions/ServiceHelperImpl.java
@@ -0,0 +1,61 @@
+/*
+ * 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.server.appfunctions;
+
+import android.Manifest;
+import android.annotation.NonNull;
+import android.app.appfunctions.AppFunctionService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.os.UserHandle;
+
+import java.util.Objects;
+
+class ServiceHelperImpl implements ServiceHelper {
+    private final Context mContext;
+
+    // TODO(b/357551503): Keep track of unlocked users.
+
+    ServiceHelperImpl(@NonNull Context context) {
+        mContext = Objects.requireNonNull(context);
+    }
+
+    @Override
+    public Intent resolveAppFunctionService(@NonNull String targetPackageName,
+                                            @NonNull UserHandle targetUser) {
+        Intent serviceIntent = new Intent(AppFunctionService.SERVICE_INTERFACE);
+        serviceIntent.setPackage(targetPackageName);
+        ResolveInfo resolveInfo = mContext.createContextAsUser(targetUser, /* flags= */ 0)
+                .getPackageManager().resolveService(serviceIntent, 0);
+        if (resolveInfo == null || resolveInfo.serviceInfo == null) {
+            return null;
+        }
+
+        ServiceInfo serviceInfo = resolveInfo.serviceInfo;
+        if (!Manifest.permission.BIND_APP_FUNCTION_SERVICE.equals(
+                serviceInfo.permission)) {
+            return null;
+        }
+        serviceIntent.setComponent(
+                new ComponentName(serviceInfo.packageName, serviceInfo.name));
+
+        return serviceIntent;
+    }
+}
diff --git a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
index 1be352e..0827f2a 100644
--- a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
+++ b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java
@@ -24,7 +24,7 @@
 import static android.companion.virtual.VirtualDeviceParams.NAVIGATION_POLICY_DEFAULT_ALLOWED;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_ACTIVITY;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_AUDIO;
-import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR;
+import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_BLOCKED_ACTIVITY;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CAMERA;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_CLIPBOARD;
 import static android.companion.virtual.VirtualDeviceParams.POLICY_TYPE_RECENTS;
@@ -237,11 +237,11 @@
 
             @Override
             public void onActivityLaunchBlocked(int displayId,
-                    @NonNull ComponentName componentName, @UserIdInt int userId,
+                    @NonNull ComponentName componentName, @NonNull UserHandle user,
                     @Nullable IntentSender intentSender) {
                 try {
                     mActivityListener.onActivityLaunchBlocked(
-                            displayId, componentName, userId, intentSender);
+                            displayId, componentName, user, intentSender);
                 } catch (RemoteException e) {
                     Slog.w(TAG, "Unable to call mActivityListener for display: " + displayId, e);
                 }
@@ -736,7 +736,7 @@
                     }
                 }
                 break;
-            case POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR:
+            case POLICY_TYPE_BLOCKED_ACTIVITY:
                 if (android.companion.virtualdevice.flags.Flags.activityControlApi()) {
                     synchronized (mVirtualDeviceLock) {
                         mDevicePolicies.put(policyType, devicePolicy);
@@ -1371,8 +1371,7 @@
             mActivityListenerAdapter.onActivityLaunchBlocked(
                     displayId,
                     activityInfo.getComponentName(),
-                    UserHandle.getUserHandleForUid(
-                            activityInfo.applicationInfo.uid).getIdentifier(),
+                    UserHandle.getUserHandleForUid(activityInfo.applicationInfo.uid),
                     intentSender);
         }
     }
@@ -1388,7 +1387,7 @@
             return true;
         }
         // Do not show the dialog if disabled by policy.
-        return getDevicePolicy(POLICY_TYPE_BLOCKED_ACTIVITY_BEHAVIOR) == DEVICE_POLICY_DEFAULT;
+        return getDevicePolicy(POLICY_TYPE_BLOCKED_ACTIVITY) == DEVICE_POLICY_DEFAULT;
     }
 
     private void onSecureWindowShown(int displayId, int uid) {
diff --git a/services/core/java/com/android/server/notification/NotificationManagerService.java b/services/core/java/com/android/server/notification/NotificationManagerService.java
index c7c984b..ffb2bb6 100644
--- a/services/core/java/com/android/server/notification/NotificationManagerService.java
+++ b/services/core/java/com/android/server/notification/NotificationManagerService.java
@@ -5675,7 +5675,7 @@
             // a "normal" rule, it must provide a CP/ConfigActivity too.
             if (android.app.Flags.modesApi()) {
                 boolean isImplicitRuleUpdateFromSystem = updateId != null
-                        && ZenModeHelper.isImplicitRuleId(updateId)
+                        && ZenModeConfig.isImplicitRuleId(updateId)
                         && isCallerSystemOrSystemUi();
                 if (!isImplicitRuleUpdateFromSystem
                         && rule.getOwner() == null
diff --git a/services/core/java/com/android/server/notification/ZenModeHelper.java b/services/core/java/com/android/server/notification/ZenModeHelper.java
index 0f50260..ee3f48d 100644
--- a/services/core/java/com/android/server/notification/ZenModeHelper.java
+++ b/services/core/java/com/android/server/notification/ZenModeHelper.java
@@ -39,6 +39,7 @@
 import static android.service.notification.ZenModeConfig.ORIGIN_USER_IN_SYSTEMUI;
 import static android.service.notification.ZenModeConfig.ZenRule.OVERRIDE_ACTIVATE;
 import static android.service.notification.ZenModeConfig.ZenRule.OVERRIDE_DEACTIVATE;
+import static android.service.notification.ZenModeConfig.implicitRuleId;
 
 import static com.android.internal.util.FrameworkStatsLog.DND_MODE_RULE;
 import static com.android.internal.util.Preconditions.checkArgument;
@@ -155,8 +156,6 @@
     static final int RULE_LIMIT_PER_PACKAGE = 100;
     private static final Duration DELETED_RULE_KEPT_FOR = Duration.ofDays(30);
 
-    private static final String IMPLICIT_RULE_ID_PREFIX = "implicit_"; // + pkg_name
-
     private static final int MAX_ICON_RESOURCE_NAME_LENGTH = 1000;
 
     /**
@@ -783,14 +782,6 @@
         return rule;
     }
 
-    private static String implicitRuleId(String forPackage) {
-        return IMPLICIT_RULE_ID_PREFIX + forPackage;
-    }
-
-    static boolean isImplicitRuleId(@NonNull String ruleId) {
-        return ruleId.startsWith(IMPLICIT_RULE_ID_PREFIX);
-    }
-
     boolean removeAutomaticZenRule(String id, @ConfigOrigin int origin, String reason,
             int callingUid) {
         checkManageRuleOrigin("removeAutomaticZenRule", origin);
@@ -977,7 +968,16 @@
                         rule.setConditionOverride(OVERRIDE_DEACTIVATE);
                     }
                 }
+            } else if (origin == ORIGIN_USER_IN_APP && condition != null
+                    && condition.source == SOURCE_USER_ACTION) {
+                // Remove override and just apply the condition. Since the app is reporting that the
+                // user asked for it, by definition it knows that, and will adjust its automatic
+                // behavior accordingly -> no need to override.
+                rule.condition = condition;
+                rule.resetConditionOverride();
             } else {
+                // Update the condition, and check whether we can remove the override (if automatic
+                // and manual decisions agree).
                 rule.condition = condition;
                 rule.reconsiderConditionOverride();
             }
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java
index 6a99731..411a610 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java
@@ -89,7 +89,7 @@
 import javax.annotation.Nullable;
 
 @RunWith(AndroidJUnit4.class)
-@EnableFlags(Flags.FLAG_VISIT_PERSON_URI)
+@EnableFlags({Flags.FLAG_VISIT_PERSON_URI, Flags.FLAG_API_RICH_ONGOING})
 public class NotificationVisitUrisTest extends UiServiceTestCase {
     @Rule
     public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
diff --git a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
index ed8ebc8..dd2b845 100644
--- a/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
+++ b/services/tests/uiservicestests/src/com/android/server/notification/ZenModeHelperTest.java
@@ -82,6 +82,7 @@
 import static com.android.server.notification.ZenModeEventLogger.ACTIVE_RULE_TYPE_MANUAL;
 import static com.android.server.notification.ZenModeHelper.RULE_LIMIT_PER_PACKAGE;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
@@ -6672,6 +6673,91 @@
         assertThat(zenRule.getConditionOverride()).isEqualTo(OVERRIDE_NONE);
     }
 
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void setAutomaticZenRuleState_withActivationOverride_userActionFromAppCanDeactivate() {
+        AutomaticZenRule rule = new AutomaticZenRule.Builder("Rule", Uri.parse("cond"))
+                .setPackage(mPkg)
+                .build();
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, rule, ORIGIN_APP, "adding",
+                CUSTOM_PKG_UID);
+
+        // User manually turns on rule from SysUI / Settings...
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "manual-on-from-sysui", STATE_TRUE,
+                        SOURCE_USER_ACTION), ORIGIN_USER_IN_SYSTEMUI, SYSTEM_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isTrue();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_ACTIVATE);
+
+        // ... and they can turn it off manually from inside the app.
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "manual-off-from-app", STATE_FALSE,
+                        SOURCE_USER_ACTION), ORIGIN_USER_IN_APP, CUSTOM_PKG_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isFalse();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_NONE);
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void setAutomaticZenRuleState_withDeactivationOverride_userActionFromAppCanActivate() {
+        AutomaticZenRule rule = new AutomaticZenRule.Builder("Rule", Uri.parse("cond"))
+                .setPackage(mPkg)
+                .build();
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, rule, ORIGIN_APP, "adding",
+                CUSTOM_PKG_UID);
+
+        // Rule is activated due to its schedule.
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "auto-on-from-app", STATE_TRUE,
+                        SOURCE_SCHEDULE), ORIGIN_APP, CUSTOM_PKG_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isTrue();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_NONE);
+
+        // User manually turns off rule from SysUI / Settings...
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "manual-off-from-sysui", STATE_FALSE,
+                        SOURCE_USER_ACTION), ORIGIN_USER_IN_SYSTEMUI, SYSTEM_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isFalse();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_DEACTIVATE);
+
+        // ... and they can turn it on manually from inside the app.
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "manual-on-from-app", STATE_TRUE,
+                        SOURCE_USER_ACTION), ORIGIN_USER_IN_APP, CUSTOM_PKG_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isTrue();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_NONE);
+    }
+
+    @Test
+    @EnableFlags({FLAG_MODES_API, FLAG_MODES_UI})
+    public void setAutomaticZenRuleState_manualActionFromApp_isNotOverride() {
+        AutomaticZenRule rule = new AutomaticZenRule.Builder("Rule", Uri.parse("cond"))
+                .setPackage(mPkg)
+                .build();
+        String ruleId = mZenModeHelper.addAutomaticZenRule(mPkg, rule, ORIGIN_APP, "adding",
+                CUSTOM_PKG_UID);
+
+        // Rule is manually activated by the user in the app.
+        // This turns the rule on, but is NOT an override...
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "manual-on-from-app", STATE_TRUE,
+                        SOURCE_USER_ACTION), ORIGIN_USER_IN_APP, CUSTOM_PKG_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isTrue();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_NONE);
+
+        // ... so the app can turn it off when its schedule is over.
+        mZenModeHelper.setAutomaticZenRuleState(ruleId,
+                new Condition(rule.getConditionId(), "auto-off-from-app", STATE_FALSE,
+                        SOURCE_SCHEDULE), ORIGIN_APP, CUSTOM_PKG_UID);
+        assertThat(getZenRule(ruleId).isAutomaticActive()).isFalse();
+        assertThat(getZenRule(ruleId).getConditionOverride()).isEqualTo(OVERRIDE_NONE);
+    }
+
+    private ZenRule getZenRule(String ruleId) {
+        return checkNotNull(mZenModeHelper.mConfig.automaticRules.get(ruleId),
+                "Didn't find rule with id %s", ruleId);
+    }
+
     private static void addZenRule(ZenModeConfig config, String id, String ownerPkg, int zenMode,
             @Nullable ZenPolicy zenPolicy) {
         ZenRule rule = new ZenRule();
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 8d1ba5b..c788f3b 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatActivityRobot.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatActivityRobot.java
@@ -243,6 +243,18 @@
         doReturn(embedded).when(mActivityStack.top()).isEmbedded();
     }
 
+    void setTopActivityVisible(boolean isVisible) {
+        doReturn(isVisible).when(mActivityStack.top()).isVisible();
+    }
+
+    void setTopActivityVisibleRequested(boolean isVisibleRequested) {
+        doReturn(isVisibleRequested).when(mActivityStack.top()).isVisibleRequested();
+    }
+
+    void setTopActivityFillsParent(boolean fillsParent) {
+        doReturn(fillsParent).when(mActivityStack.top()).fillsParent();
+    }
+
     void setTopActivityInMultiWindowMode(boolean multiWindowMode) {
         doReturn(multiWindowMode).when(mActivityStack.top()).inMultiWindowMode();
         if (multiWindowMode) {
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatConfigurationRobot.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatConfigurationRobot.java
index 7760051..05f6ed6 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatConfigurationRobot.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatConfigurationRobot.java
@@ -26,6 +26,8 @@
 
 import androidx.annotation.NonNull;
 
+import com.android.server.wm.AppCompatConfiguration.LetterboxBackgroundType;
+
 /**
  * Robot implementation for {@link AppCompatConfiguration}.
  */
@@ -99,6 +101,32 @@
                 .getThinLetterboxHeightPx();
     }
 
+    void setLetterboxActivityCornersRounded(boolean rounded) {
+        doReturn(rounded).when(mAppCompatConfiguration).isLetterboxActivityCornersRounded();
+    }
+
+    void setLetterboxEducationEnabled(boolean enabled) {
+        doReturn(enabled).when(mAppCompatConfiguration).getIsEducationEnabled();
+    }
+
+    void setLetterboxActivityCornersRadius(int cornerRadius) {
+        doReturn(cornerRadius).when(mAppCompatConfiguration).getLetterboxActivityCornersRadius();
+    }
+
+    void setLetterboxBackgroundType(@LetterboxBackgroundType int backgroundType) {
+        doReturn(backgroundType).when(mAppCompatConfiguration).getLetterboxBackgroundType();
+    }
+
+    void setLetterboxBackgroundWallpaperBlurRadiusPx(int blurRadiusPx) {
+        doReturn(blurRadiusPx).when(mAppCompatConfiguration)
+                .getLetterboxBackgroundWallpaperBlurRadiusPx();
+    }
+
+    void setLetterboxBackgroundWallpaperDarkScrimAlpha(float darkScrimAlpha) {
+        doReturn(darkScrimAlpha).when(mAppCompatConfiguration)
+                .getLetterboxBackgroundWallpaperDarkScrimAlpha();
+    }
+
     void checkToNextLeftStop(boolean invoked) {
         verify(mAppCompatConfiguration, times(invoked ? 1 : 0))
                 .movePositionForHorizontalReachabilityToNextLeftStop(anyBoolean());
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatLetterboxOverrideTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatLetterboxOverrideTest.java
new file mode 100644
index 0000000..af7f881
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatLetterboxOverrideTest.java
@@ -0,0 +1,281 @@
+/*
+ * 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.server.wm;
+
+import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_BACKGROUND_APP_COLOR_BACKGROUND;
+import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_BACKGROUND_APP_COLOR_BACKGROUND_FLOATING;
+import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_BACKGROUND_OVERRIDE_UNSET;
+import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_BACKGROUND_SOLID_COLOR;
+import static com.android.server.wm.AppCompatConfiguration.LETTERBOX_BACKGROUND_WALLPAPER;
+
+import static junit.framework.Assert.assertEquals;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.annotation.NonNull;
+import androidx.test.filters.SmallTest;
+
+import com.android.server.wm.AppCompatConfiguration.LetterboxBackgroundType;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.function.Consumer;
+
+/**
+ * Test class for {@link AppCompatLetterboxOverrides}.
+ *
+ * Build/Install/Run:
+ * atest WmTests:AppCompatLetterboxOverrideTest
+ */
+@SmallTest
+@Presubmit
+@RunWith(WindowTestRunner.class)
+public class AppCompatLetterboxOverrideTest extends WindowTestsBase {
+
+    @Test
+    public void testIsLetterboxEducationEnabled() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxEducationEnabled(/* enabled */ true);
+            robot.checkLetterboxEducationEnabled(/* enabled */ true);
+
+            robot.conf().setLetterboxEducationEnabled(/* enabled */ false);
+            robot.checkLetterboxEducationEnabled(/* enabled */ false);
+        });
+    }
+
+    @Test
+    public void testShouldLetterboxHaveRoundedCorners() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.activity().setTopActivityFillsParent(/* fillsParent */ true);
+            robot.checkShouldLetterboxHaveRoundedCorners(/* expected */ true);
+
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ false);
+            robot.checkShouldLetterboxHaveRoundedCorners(/* expected */ false);
+
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.activity().setTopActivityFillsParent(/* fillsParent */ false);
+            robot.checkShouldLetterboxHaveRoundedCorners(/* expected */ false);
+        });
+    }
+
+    @Test
+    public void testHasWallpaperBackgroundForLetterbox() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ false);
+
+            robot.invokeCheckWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */ false);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ false);
+
+            robot.invokeCheckWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */ true);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ true);
+
+            robot.invokeCheckWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */ true);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ true);
+
+            robot.invokeCheckWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */ false);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ false);
+        });
+    }
+
+    @Test
+    public void testCheckWallpaperBackgroundForLetterbox() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ false);
+
+            robot.checkWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */
+                    true, /* expected */ true);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ true);
+
+            robot.checkWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */
+                    true, /* expected */ false);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ true);
+
+            robot.checkWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */
+                    false, /* expected */ true);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ false);
+
+            robot.checkWallpaperBackgroundForLetterbox(/* wallpaperShouldBeShown */
+                    false, /* expected */ false);
+            robot.checkHasWallpaperBackgroundForLetterbox(/* expected */ false);
+        });
+    }
+
+    @Test
+    public void testLetterboxActivityCornersRadius() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxActivityCornersRadius(/* cornerRadius */ 0);
+            robot.checkLetterboxActivityCornersRadius(/* cornerRadius */ 0);
+
+            robot.conf().setLetterboxActivityCornersRadius(/* cornerRadius */ 37);
+            robot.checkLetterboxActivityCornersRadius(/* cornerRadius */ 37);
+
+            robot.conf().setLetterboxActivityCornersRadius(/* cornerRadius */ 5);
+            robot.checkLetterboxActivityCornersRadius(/* cornerRadius */ 5);
+        });
+    }
+
+    @Test
+    public void testLetterboxActivityCornersRaunded() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.checkLetterboxActivityCornersRounded(/* expected */ true);
+
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ false);
+            robot.checkLetterboxActivityCornersRounded(/* expected */ false);
+        });
+    }
+
+    @Test
+    public void testLetterboxBackgroundType() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxBackgroundType(LETTERBOX_BACKGROUND_OVERRIDE_UNSET);
+            robot.checkLetterboxBackgroundType(LETTERBOX_BACKGROUND_OVERRIDE_UNSET);
+
+            robot.conf().setLetterboxBackgroundType(LETTERBOX_BACKGROUND_SOLID_COLOR);
+            robot.checkLetterboxBackgroundType(LETTERBOX_BACKGROUND_SOLID_COLOR);
+
+            robot.conf().setLetterboxBackgroundType(LETTERBOX_BACKGROUND_APP_COLOR_BACKGROUND);
+            robot.checkLetterboxBackgroundType(LETTERBOX_BACKGROUND_APP_COLOR_BACKGROUND);
+
+            robot.conf().setLetterboxBackgroundType(
+                    LETTERBOX_BACKGROUND_APP_COLOR_BACKGROUND_FLOATING);
+            robot.checkLetterboxBackgroundType(LETTERBOX_BACKGROUND_APP_COLOR_BACKGROUND_FLOATING);
+
+            robot.conf().setLetterboxBackgroundType(LETTERBOX_BACKGROUND_WALLPAPER);
+            robot.checkLetterboxBackgroundType(LETTERBOX_BACKGROUND_WALLPAPER);
+        });
+    }
+
+    @Test
+    public void testLetterboxBackgroundWallpaperBlurRadiusPx() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxBackgroundWallpaperBlurRadiusPx(-1);
+            robot.checkLetterboxWallpaperBlurRadiusPx(0);
+
+            robot.conf().setLetterboxBackgroundWallpaperBlurRadiusPx(0);
+            robot.checkLetterboxWallpaperBlurRadiusPx(0);
+
+            robot.conf().setLetterboxBackgroundWallpaperBlurRadiusPx(10);
+            robot.checkLetterboxWallpaperBlurRadiusPx(10);
+        });
+    }
+
+    @Test
+    public void testLetterboxBackgroundWallpaperDarkScrimAlpha() {
+        runTestScenario((robot) -> {
+            robot.activity().createActivityWithComponent();
+
+            robot.conf().setLetterboxBackgroundWallpaperDarkScrimAlpha(-1f);
+            robot.checkLetterboxWallpaperDarkScrimAlpha(0);
+
+            robot.conf().setLetterboxBackgroundWallpaperDarkScrimAlpha(1.1f);
+            robot.checkLetterboxWallpaperDarkScrimAlpha(0);
+
+            robot.conf().setLetterboxBackgroundWallpaperDarkScrimAlpha(0.001f);
+            robot.checkLetterboxWallpaperDarkScrimAlpha(0.001f);
+
+            robot.conf().setLetterboxBackgroundWallpaperDarkScrimAlpha(0.999f);
+            robot.checkLetterboxWallpaperDarkScrimAlpha(0.999f);
+        });
+    }
+
+    /**
+     * Runs a test scenario providing a Robot.
+     */
+    void runTestScenario(@NonNull Consumer<LetterboxOverridesRobotTest> consumer) {
+        final LetterboxOverridesRobotTest robot =
+                new LetterboxOverridesRobotTest(mWm, mAtm, mSupervisor);
+        consumer.accept(robot);
+    }
+
+    private static class LetterboxOverridesRobotTest extends AppCompatRobotBase {
+        LetterboxOverridesRobotTest(@NonNull WindowManagerService wm,
+                @NonNull ActivityTaskManagerService atm,
+                @NonNull ActivityTaskSupervisor supervisor) {
+            super(wm, atm, supervisor);
+        }
+
+        void invokeCheckWallpaperBackgroundForLetterbox(boolean wallpaperShouldBeShown) {
+            getLetterboxOverrides().checkWallpaperBackgroundForLetterbox(wallpaperShouldBeShown);
+        }
+
+        void checkLetterboxEducationEnabled(boolean enabled) {
+            assertEquals(enabled, getLetterboxOverrides().isLetterboxEducationEnabled());
+        }
+
+        void checkShouldLetterboxHaveRoundedCorners(boolean expected) {
+            assertEquals(expected,
+                    getLetterboxOverrides().shouldLetterboxHaveRoundedCorners());
+        }
+
+        void checkHasWallpaperBackgroundForLetterbox(boolean expected) {
+            assertEquals(expected,
+                    getLetterboxOverrides().hasWallpaperBackgroundForLetterbox());
+        }
+
+        void checkWallpaperBackgroundForLetterbox(boolean wallpaperShouldBeShown,
+                boolean expected) {
+            assertEquals(expected,
+                    getLetterboxOverrides().checkWallpaperBackgroundForLetterbox(
+                            wallpaperShouldBeShown));
+        }
+
+        void checkLetterboxActivityCornersRadius(int expected) {
+            assertEquals(expected, getLetterboxOverrides().getLetterboxActivityCornersRadius());
+        }
+
+        void checkLetterboxActivityCornersRounded(boolean expected) {
+            assertEquals(expected, getLetterboxOverrides().isLetterboxActivityCornersRounded());
+        }
+
+        void checkLetterboxBackgroundType(@LetterboxBackgroundType int expected) {
+            assertEquals(expected, getLetterboxOverrides().getLetterboxBackgroundType());
+        }
+
+        void checkLetterboxWallpaperBlurRadiusPx(int expected) {
+            assertEquals(expected, getLetterboxOverrides().getLetterboxWallpaperBlurRadiusPx());
+        }
+
+        void checkLetterboxWallpaperDarkScrimAlpha(float expected) {
+            assertEquals(expected, getLetterboxOverrides().getLetterboxWallpaperDarkScrimAlpha(),
+                    FLOAT_TOLLERANCE);
+        }
+
+        @NonNull
+        private AppCompatLetterboxOverrides getLetterboxOverrides() {
+            return activity().top().mAppCompatController.getAppCompatLetterboxOverrides();
+        }
+
+    }
+
+}
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatLetterboxPolicyTest.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatLetterboxPolicyTest.java
new file mode 100644
index 0000000..e046f7c
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatLetterboxPolicyTest.java
@@ -0,0 +1,382 @@
+/*
+ * 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.server.wm;
+
+import static android.view.InsetsSource.FLAG_INSETS_ROUNDED_CORNER;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+
+import android.graphics.Rect;
+import android.platform.test.annotations.Presubmit;
+import android.view.InsetsSource;
+import android.view.InsetsState;
+import android.view.RoundedCorner;
+import android.view.RoundedCorners;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.R;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.function.Consumer;
+
+/**
+ * Test class for {@link AppCompatLetterboxPolicy}.
+ *
+ * Build/Install/Run:
+ * atest WmTests:AppCompatLetterboxPolicyTest
+ */
+@SmallTest
+@Presubmit
+@RunWith(WindowTestRunner.class)
+public class AppCompatLetterboxPolicyTest extends WindowTestsBase {
+
+    @Test
+    public void testGetCropBoundsIfNeeded_handleCropForTransparentActivityBasedOnOpaqueBounds() {
+        runTestScenario((robot) -> {
+            robot.configureWindowStateWithTaskBar(/* hasTaskBarInsetsRoundedCorners */ true);
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+            robot.setTopActivityTransparentPolicyRunning(/* running */ true);
+            robot.activity().configureTopActivityBounds(new Rect(0, 0, 500, 300));
+
+            robot.resizeMainWindow(/* newWidth */ 499, /* newHeight */ 299);
+            robot.checkWindowStateHasCropBounds(/* expected */ false);
+
+            robot.resizeMainWindow(/* newWidth */ 500, /* newHeight */ 300);
+            robot.checkWindowStateHasCropBounds(/* expected */ true);
+        });
+    }
+
+    @Test
+    public void testGetCropBoundsIfNeeded_noCrop() {
+        runTestScenario((robot) -> {
+            robot.configureWindowStateWithTaskBar(/* hasTaskBarInsetsRoundedCorners */ false);
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+
+            // Do not apply crop if taskbar is collapsed
+            robot.collapseTaskBar();
+            robot.checkTaskBarIsExpanded(/* expected */ false);
+
+            robot.activity().configureTopActivityBounds(new Rect(50, 25, 150, 75));
+            robot.checkWindowStateHasCropBounds(/* expected */ true);
+            // Expected the same size of the activity.
+            robot.validateWindowStateCropBounds(0, 0, 100, 50);
+        });
+    }
+
+    @Test
+    public void testGetCropBoundsIfNeeded_appliesCrop() {
+        runTestScenario((robot) -> {
+            robot.configureWindowStateWithTaskBar(/* hasTaskBarInsetsRoundedCorners */ true);
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+
+            // Apply crop if taskbar is expanded.
+            robot.expandTaskBar();
+            robot.checkTaskBarIsExpanded(/* expected */ true);
+
+            robot.activity().configureTopActivityBounds(new Rect(50, 0, 150, 100));
+            robot.checkWindowStateHasCropBounds(/* expected */ true);
+            // The task bar expanded height is removed from the crop height.
+            robot.validateWindowStateCropBounds(0, 0, 100, 80);
+        });
+    }
+
+    @Test
+    public void testGetCropBoundsIfNeeded_appliesCropWithSizeCompatScaling() {
+        runTestScenario((robot) -> {
+            robot.configureWindowStateWithTaskBar(/* hasTaskBarInsetsRoundedCorners */ true);
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+
+            // Apply crop if taskbar is expanded.
+            robot.expandTaskBar();
+            robot.checkTaskBarIsExpanded(/* expected */ true);
+            robot.activity().setTopActivityInSizeCompatMode(/* isScm */ true);
+            robot.setInvCompatState(/* scale */ 2.0f);
+
+            robot.activity().configureTopActivityBounds(new Rect(50, 0, 150, 100));
+
+            robot.checkWindowStateHasCropBounds(/* expected */ true);
+            // The width and height, considering task bar, are scaled by 2.
+            robot.validateWindowStateCropBounds(0, 0, 200, 160);
+        });
+    }
+
+    @Test
+    public void testGetRoundedCornersRadius_withRoundedCornersFromInsets() {
+        runTestScenario((robot) -> {
+            robot.conf().setLetterboxActivityCornersRadius(-1);
+            robot.configureWindowState();
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+
+            robot.setInvCompatState(/* scale */ 0.5f);
+            robot.configureInsetsRoundedCorners(new RoundedCorners(
+                    /*topLeft=*/ null,
+                    /*topRight=*/ null,
+                    /*bottomRight=*/ new RoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT,
+                    /* configurationRadius */ 15, /*centerX=*/ 1, /*centerY=*/ 1),
+                    /*bottomLeft=*/ new RoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT,
+                    30 /*2 is to test selection of the min radius*/,
+                    /*centerX=*/ 1, /*centerY=*/ 1)
+            ));
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 7);
+        });
+    }
+
+
+    @Test
+    public void testGetRoundedCornersRadius_withLetterboxActivityCornersRadius() {
+        runTestScenario((robot) -> {
+            robot.conf().setLetterboxActivityCornersRadius(15);
+            robot.configureWindowState();
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+            robot.setInvCompatState(/* scale */ 0.5f);
+
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ true);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 7);
+
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 7);
+
+            robot.activity().setTopActivityVisibleRequested(/* isVisibleRequested */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ false);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 0);
+
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ true);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 7);
+        });
+    }
+
+    @Test
+    public void testGetRoundedCornersRadius_noScalingApplied() {
+        runTestScenario((robot) -> {
+            robot.conf().setLetterboxActivityCornersRadius(15);
+            robot.configureWindowState();
+            robot.activity().createActivityWithComponent();
+            robot.setTopActivityInLetterboxAnimation(/* inLetterboxAnimation */ false);
+            robot.activity().setTopActivityVisible(/* isVisible */ true);
+            robot.setIsLetterboxedForFixedOrientationAndAspectRatio(/* inLetterbox */ true);
+            robot.conf().setLetterboxActivityCornersRounded(/* rounded */ true);
+            robot.resources().configureGetDimensionPixelSize(R.dimen.taskbar_frame_height, 20);
+
+            robot.setInvCompatState(/* scale */ -1f);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 15);
+
+            robot.setInvCompatState(/* scale */ 0f);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 15);
+
+            robot.setInvCompatState(/* scale */ 1f);
+            robot.checkWindowStateRoundedCornersRadius(/* expected */ 15);
+        });
+    }
+
+    /**
+     * Runs a test scenario providing a Robot.
+     */
+    void runTestScenario(@NonNull Consumer<LetterboxPolicyRobotTest> consumer) {
+        final LetterboxPolicyRobotTest robot = new LetterboxPolicyRobotTest(mWm, mAtm, mSupervisor);
+        consumer.accept(robot);
+    }
+
+    private static class LetterboxPolicyRobotTest extends AppCompatRobotBase {
+
+        static final int TASKBAR_COLLAPSED_HEIGHT = 10;
+        static final int TASKBAR_EXPANDED_HEIGHT = 20;
+        private static final int SCREEN_WIDTH = 200;
+        private static final int SCREEN_HEIGHT = 100;
+        static final Rect TASKBAR_COLLAPSED_BOUNDS = new Rect(0,
+                SCREEN_HEIGHT - TASKBAR_COLLAPSED_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);
+        static final Rect TASKBAR_EXPANDED_BOUNDS = new Rect(0,
+                SCREEN_HEIGHT - TASKBAR_EXPANDED_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);
+
+        @NonNull
+        private final WindowState mWindowState;
+        @Nullable
+        private InsetsSource mTaskbar;
+        @Nullable
+        private InsetsState mInsetsState;
+
+        LetterboxPolicyRobotTest(@NonNull WindowManagerService wm,
+                @NonNull ActivityTaskManagerService atm,
+                @NonNull ActivityTaskSupervisor supervisor) {
+            super(wm, atm, supervisor);
+            mWindowState = mock(WindowState.class);
+        }
+
+        @Override
+        void onPostActivityCreation(@NonNull ActivityRecord activity) {
+            super.onPostActivityCreation(activity);
+            spyOn(getAspectRatioPolicy());
+            spyOn(getTransparentPolicy());
+        }
+
+        void configureWindowStateWithTaskBar(boolean hasInsetsRoundedCorners) {
+            configureWindowState(/* withTaskBar */ true, hasInsetsRoundedCorners);
+        }
+
+        void configureWindowState() {
+            configureWindowState(/* withTaskBar */ false, /* hasInsetsRoundedCorners */ false);
+        }
+
+        void configureInsetsRoundedCorners(@NonNull RoundedCorners roundedCorners) {
+            mInsetsState.setRoundedCorners(roundedCorners);
+        }
+
+        private void configureWindowState(boolean withTaskBar, boolean hasInsetsRoundedCorners) {
+            mInsetsState = new InsetsState();
+            if (withTaskBar) {
+                mTaskbar = new InsetsSource(/*id=*/ 0,
+                        WindowInsets.Type.navigationBars());
+                if (hasInsetsRoundedCorners) {
+                    mTaskbar.setFlags(FLAG_INSETS_ROUNDED_CORNER, FLAG_INSETS_ROUNDED_CORNER);
+                }
+                mTaskbar.setVisible(true);
+                mInsetsState.addSource(mTaskbar);
+            }
+            mWindowState.mInvGlobalScale = 1f;
+            final WindowManager.LayoutParams attrs = new WindowManager.LayoutParams();
+            doReturn(mInsetsState).when(mWindowState).getInsetsState();
+            doReturn(attrs).when(mWindowState).getAttrs();
+            doReturn(true).when(mWindowState).isDrawn();
+            doReturn(true).when(mWindowState).isOnScreen();
+            doReturn(false).when(mWindowState).isLetterboxedForDisplayCutout();
+            doReturn(true).when(mWindowState).areAppWindowBoundsLetterboxed();
+        }
+
+        void setInvCompatState(float scale) {
+            mWindowState.mInvGlobalScale = scale;
+        }
+
+        void setTopActivityInLetterboxAnimation(boolean inLetterboxAnimation) {
+            doReturn(inLetterboxAnimation).when(activity().top()).isInLetterboxAnimation();
+        }
+
+        void setTopActivityTransparentPolicyRunning(boolean running) {
+            doReturn(running).when(getTransparentPolicy()).isRunning();
+        }
+
+        void setIsLetterboxedForFixedOrientationAndAspectRatio(boolean isLetterboxed) {
+            doReturn(isLetterboxed).when(getAspectRatioPolicy())
+                    .isLetterboxedForFixedOrientationAndAspectRatio();
+        }
+
+        void resizeMainWindow(int newWidth, int newHeight) {
+            mWindowState.mRequestedWidth = newWidth;
+            mWindowState.mRequestedHeight = newHeight;
+        }
+
+        void collapseTaskBar() {
+            mTaskbar.setFrame(TASKBAR_COLLAPSED_BOUNDS);
+        }
+
+        void expandTaskBar() {
+            mTaskbar.setFrame(TASKBAR_EXPANDED_BOUNDS);
+        }
+
+        void checkWindowStateHasCropBounds(boolean expected) {
+            final Rect cropBounds = getAppCompatLetterboxPolicy().getCropBoundsIfNeeded(
+                    mWindowState);
+            if (expected) {
+                assertNotNull(cropBounds);
+            } else {
+                assertNull(cropBounds);
+            }
+        }
+
+        void checkTaskBarIsExpanded(boolean expected) {
+            final InsetsSource expandedTaskBar = AppCompatUtils.getExpandedTaskbarOrNull(
+                    mWindowState);
+            if (expected) {
+                assertNotNull(expandedTaskBar);
+            } else {
+                assertNull(expandedTaskBar);
+            }
+        }
+
+        void checkWindowStateRoundedCornersRadius(int expected) {
+            assertEquals(expected, getAppCompatLetterboxPolicy()
+                    .getRoundedCornersRadius(mWindowState));
+        }
+
+        void validateWindowStateCropBounds(int left, int top, int right, int bottom) {
+            final Rect cropBounds = getAppCompatLetterboxPolicy().getCropBoundsIfNeeded(
+                    mWindowState);
+            assertEquals(left, cropBounds.left);
+            assertEquals(top, cropBounds.top);
+            assertEquals(right, cropBounds.right);
+            assertEquals(bottom, cropBounds.bottom);
+        }
+
+        @NonNull
+        private AppCompatAspectRatioPolicy getAspectRatioPolicy() {
+            return activity().top().mAppCompatController.getAppCompatAspectRatioPolicy();
+        }
+
+        @NonNull
+        private TransparentPolicy getTransparentPolicy() {
+            return activity().top().mAppCompatController.getTransparentPolicy();
+        }
+
+        @NonNull
+        private AppCompatLetterboxPolicy getAppCompatLetterboxPolicy() {
+            return activity().top().mAppCompatController.getAppCompatLetterboxPolicy();
+        }
+
+    }
+
+}
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatResourcesRobot.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatResourcesRobot.java
new file mode 100644
index 0000000..05e9a0f
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatResourcesRobot.java
@@ -0,0 +1,44 @@
+/*
+ * 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.server.wm;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+
+import static org.mockito.Mockito.doReturn;
+
+import android.content.res.Resources;
+
+import androidx.annotation.DimenRes;
+import androidx.annotation.NonNull;
+
+/**
+ * Robot for managing {@link Resources} in unit tests.
+ */
+public class AppCompatResourcesRobot {
+
+    @NonNull
+    private final Resources mResources;
+
+    AppCompatResourcesRobot(@NonNull Resources resources) {
+        mResources = resources;
+        spyOn(mResources);
+    }
+
+    void configureGetDimensionPixelSize(@DimenRes int resId, int value) {
+        doReturn(value).when(mResources).getDimensionPixelSize(resId);
+    }
+}
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 4e58e1d..5f2a63a 100644
--- a/services/tests/wmtests/src/com/android/server/wm/AppCompatRobotBase.java
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatRobotBase.java
@@ -37,6 +37,8 @@
     private final AppCompatConfigurationRobot mConfigurationRobot;
     @NonNull
     private final AppCompatComponentPropRobot mOptPropRobot;
+    @NonNull
+    private final AppCompatResourcesRobot mResourcesRobot;
 
     AppCompatRobotBase(@NonNull WindowManagerService wm,
             @NonNull ActivityTaskManagerService atm,
@@ -48,6 +50,7 @@
         mConfigurationRobot =
                 new AppCompatConfigurationRobot(wm.mAppCompatConfiguration);
         mOptPropRobot = new AppCompatComponentPropRobot(wm);
+        mResourcesRobot = new AppCompatResourcesRobot(wm.mContext.getResources());
     }
 
     AppCompatRobotBase(@NonNull WindowManagerService wm,
@@ -60,7 +63,7 @@
      * Specific Robots can override this method to add operation to run on a newly created
      * {@link ActivityRecord}. Common case is to invoke spyOn().
      *
-     * @param activity THe newly created {@link ActivityRecord}.
+     * @param activity The newly created {@link ActivityRecord}.
      */
     @CallSuper
     void onPostActivityCreation(@NonNull ActivityRecord activity) {
@@ -81,7 +84,6 @@
         return mConfigurationRobot;
     }
 
-    @NonNull
     void applyOnConf(@NonNull Consumer<AppCompatConfigurationRobot> consumer) {
         consumer.accept(mConfigurationRobot);
     }
@@ -91,7 +93,6 @@
         return mActivityRobot;
     }
 
-    @NonNull
     void applyOnActivity(@NonNull Consumer<AppCompatActivityRobot> consumer) {
         consumer.accept(mActivityRobot);
     }
@@ -101,8 +102,16 @@
         return mOptPropRobot;
     }
 
-    @NonNull
     void applyOnProp(@NonNull Consumer<AppCompatComponentPropRobot> consumer) {
         consumer.accept(mOptPropRobot);
     }
+
+    @NonNull
+    AppCompatResourcesRobot resources() {
+        return mResourcesRobot;
+    }
+
+    void applyOnResources(@NonNull Consumer<AppCompatResourcesRobot> consumer) {
+        consumer.accept(mResourcesRobot);
+    }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java b/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java
deleted file mode 100644
index 8947522..0000000
--- a/services/tests/wmtests/src/com/android/server/wm/LetterboxUiControllerTest.java
+++ /dev/null
@@ -1,327 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.wm;
-
-import static android.view.InsetsSource.FLAG_INSETS_ROUNDED_CORNER;
-
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.annotation.Nullable;
-import android.compat.testing.PlatformCompatChangeRule;
-import android.content.ComponentName;
-import android.content.res.Resources;
-import android.graphics.Rect;
-import android.platform.test.annotations.Presubmit;
-import android.view.InsetsSource;
-import android.view.InsetsState;
-import android.view.RoundedCorner;
-import android.view.RoundedCorners;
-import android.view.WindowInsets;
-import android.view.WindowManager;
-
-import androidx.test.filters.SmallTest;
-
-import com.android.internal.R;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.TestRule;
-import org.junit.runner.RunWith;
-
-/**
- * Test class for {@link LetterboxUiController}.
- *
- * Build/Install/Run:
- * atest WmTests:LetterboxUiControllerTest
- */
-@SmallTest
-@Presubmit
-@RunWith(WindowTestRunner.class)
-public class LetterboxUiControllerTest extends WindowTestsBase {
-    private static final int TASKBAR_COLLAPSED_HEIGHT = 10;
-    private static final int TASKBAR_EXPANDED_HEIGHT = 20;
-    private static final int SCREEN_WIDTH = 200;
-    private static final int SCREEN_HEIGHT = 100;
-    private static final Rect TASKBAR_COLLAPSED_BOUNDS = new Rect(0,
-            SCREEN_HEIGHT - TASKBAR_COLLAPSED_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);
-    private static final Rect TASKBAR_EXPANDED_BOUNDS = new Rect(0,
-            SCREEN_HEIGHT - TASKBAR_EXPANDED_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);
-
-    @Rule
-    public TestRule compatChangeRule = new PlatformCompatChangeRule();
-
-    private ActivityRecord mActivity;
-    private Task mTask;
-    private DisplayContent mDisplayContent;
-    private AppCompatConfiguration mAppCompatConfiguration;
-    private final Rect mLetterboxedPortraitTaskBounds = new Rect();
-
-    @Before
-    public void setUp() throws Exception {
-        mActivity = setUpActivityWithComponent();
-
-        mAppCompatConfiguration = mWm.mAppCompatConfiguration;
-        spyOn(mAppCompatConfiguration);
-    }
-
-    @Test
-    public void testGetCropBoundsIfNeeded_handleCropForTransparentActivityBasedOnOpaqueBounds() {
-        final InsetsSource taskbar = new InsetsSource(/*id=*/ 0,
-                WindowInsets.Type.navigationBars());
-        taskbar.setFlags(FLAG_INSETS_ROUNDED_CORNER, FLAG_INSETS_ROUNDED_CORNER);
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(taskbar);
-        final Rect opaqueBounds = new Rect(0, 0, 500, 300);
-        doReturn(opaqueBounds).when(mActivity).getBounds();
-        // Activity is translucent
-        spyOn(mActivity.mAppCompatController.getTransparentPolicy());
-        when(mActivity.mAppCompatController.getTransparentPolicy()
-                .isRunning()).thenReturn(true);
-
-        // Makes requested sizes different
-        mainWindow.mRequestedWidth = opaqueBounds.width() - 1;
-        mainWindow.mRequestedHeight = opaqueBounds.height() - 1;
-        final AppCompatLetterboxPolicy letterboxPolicy =
-                mActivity.mAppCompatController.getAppCompatLetterboxPolicy();
-        assertNull(letterboxPolicy.getCropBoundsIfNeeded(mainWindow));
-
-        // Makes requested sizes equals
-        mainWindow.mRequestedWidth = opaqueBounds.width();
-        mainWindow.mRequestedHeight = opaqueBounds.height();
-        assertNotNull(letterboxPolicy.getCropBoundsIfNeeded(mainWindow));
-    }
-
-    @Test
-    public void testGetCropBoundsIfNeeded_noCrop() {
-        final InsetsSource taskbar = new InsetsSource(/*id=*/ 0,
-                WindowInsets.Type.navigationBars());
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(taskbar);
-
-        // Do not apply crop if taskbar is collapsed
-        taskbar.setFrame(TASKBAR_COLLAPSED_BOUNDS);
-        assertNull(AppCompatUtils.getExpandedTaskbarOrNull(mainWindow));
-
-        mLetterboxedPortraitTaskBounds.set(SCREEN_WIDTH / 4, SCREEN_HEIGHT / 4,
-                SCREEN_WIDTH - SCREEN_WIDTH / 4, SCREEN_HEIGHT - SCREEN_HEIGHT / 4);
-
-        final AppCompatLetterboxPolicy letterboxPolicy =
-                mActivity.mAppCompatController.getAppCompatLetterboxPolicy();
-
-        final Rect noCrop = letterboxPolicy.getCropBoundsIfNeeded(mainWindow);
-        assertNotEquals(null, noCrop);
-        assertEquals(0, noCrop.left);
-        assertEquals(0, noCrop.top);
-        assertEquals(mLetterboxedPortraitTaskBounds.width(), noCrop.right);
-        assertEquals(mLetterboxedPortraitTaskBounds.height(), noCrop.bottom);
-    }
-
-    @Test
-    public void testGetCropBoundsIfNeeded_appliesCrop() {
-        final InsetsSource taskbar = new InsetsSource(/*id=*/ 0,
-                WindowInsets.Type.navigationBars());
-        taskbar.setFlags(FLAG_INSETS_ROUNDED_CORNER, FLAG_INSETS_ROUNDED_CORNER);
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(taskbar);
-
-        // Apply crop if taskbar is expanded
-        taskbar.setFrame(TASKBAR_EXPANDED_BOUNDS);
-        assertNotNull(AppCompatUtils.getExpandedTaskbarOrNull(mainWindow));
-
-        mLetterboxedPortraitTaskBounds.set(SCREEN_WIDTH / 4, 0, SCREEN_WIDTH - SCREEN_WIDTH / 4,
-                SCREEN_HEIGHT);
-
-        final AppCompatLetterboxPolicy letterboxPolicy =
-                mActivity.mAppCompatController.getAppCompatLetterboxPolicy();
-        final Rect crop = letterboxPolicy.getCropBoundsIfNeeded(mainWindow);
-        assertNotEquals(null, crop);
-        assertEquals(0, crop.left);
-        assertEquals(0, crop.top);
-        assertEquals(mLetterboxedPortraitTaskBounds.width(), crop.right);
-        assertEquals(mLetterboxedPortraitTaskBounds.height() - TASKBAR_EXPANDED_HEIGHT,
-                crop.bottom);
-    }
-
-    @Test
-    public void testGetCropBoundsIfNeeded_appliesCropWithSizeCompatScaling() {
-        final InsetsSource taskbar = new InsetsSource(/*id=*/ 0,
-                WindowInsets.Type.navigationBars());
-        taskbar.setFlags(FLAG_INSETS_ROUNDED_CORNER, FLAG_INSETS_ROUNDED_CORNER);
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(taskbar);
-        final float scaling = 2.0f;
-
-        // Apply crop if taskbar is expanded
-        taskbar.setFrame(TASKBAR_EXPANDED_BOUNDS);
-        assertNotNull(AppCompatUtils.getExpandedTaskbarOrNull(mainWindow));
-        // With SizeCompat scaling
-        doReturn(true).when(mActivity).inSizeCompatMode();
-        mainWindow.mInvGlobalScale = scaling;
-
-        mLetterboxedPortraitTaskBounds.set(SCREEN_WIDTH / 4, 0, SCREEN_WIDTH - SCREEN_WIDTH / 4,
-                SCREEN_HEIGHT);
-
-        final int appWidth = mLetterboxedPortraitTaskBounds.width();
-        final int appHeight = mLetterboxedPortraitTaskBounds.height();
-
-        final AppCompatLetterboxPolicy letterboxPolicy =
-                mActivity.mAppCompatController.getAppCompatLetterboxPolicy();
-        final Rect crop = letterboxPolicy.getCropBoundsIfNeeded(mainWindow);
-        assertNotEquals(null, crop);
-        assertEquals(0, crop.left);
-        assertEquals(0, crop.top);
-        assertEquals((int) (appWidth * scaling), crop.right);
-        assertEquals((int) ((appHeight - TASKBAR_EXPANDED_HEIGHT) * scaling), crop.bottom);
-    }
-
-    @Test
-    public void testGetRoundedCornersRadius_withRoundedCornersFromInsets() {
-        final float invGlobalScale = 0.5f;
-        final int expectedRadius = 7;
-        final int configurationRadius = 15;
-
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(/*taskbar=*/ null);
-        mainWindow.mInvGlobalScale = invGlobalScale;
-        final InsetsState insets = mainWindow.getInsetsState();
-
-        RoundedCorners roundedCorners = new RoundedCorners(
-                /*topLeft=*/ null,
-                /*topRight=*/ null,
-                /*bottomRight=*/ new RoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT,
-                    configurationRadius, /*centerX=*/ 1, /*centerY=*/ 1),
-                /*bottomLeft=*/ new RoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT,
-                    configurationRadius * 2 /*2 is to test selection of the min radius*/,
-                    /*centerX=*/ 1, /*centerY=*/ 1)
-        );
-        insets.setRoundedCorners(roundedCorners);
-        mAppCompatConfiguration.setLetterboxActivityCornersRadius(-1);
-
-        assertEquals(expectedRadius, mActivity.mAppCompatController.getAppCompatLetterboxPolicy()
-                .getRoundedCornersRadius(mainWindow));
-    }
-
-    @Test
-    public void testGetRoundedCornersRadius_withLetterboxActivityCornersRadius() {
-        final float invGlobalScale = 0.5f;
-        final int expectedRadius = 7;
-        final int configurationRadius = 15;
-
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(/*taskbar=*/ null);
-        mainWindow.mInvGlobalScale = invGlobalScale;
-        mAppCompatConfiguration.setLetterboxActivityCornersRadius(configurationRadius);
-
-        final AppCompatLetterboxPolicy letterboxPolicy =
-                mActivity.mAppCompatController.getAppCompatLetterboxPolicy();
-
-        doReturn(true).when(mActivity).isInLetterboxAnimation();
-        assertEquals(expectedRadius, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-
-        doReturn(false).when(mActivity).isInLetterboxAnimation();
-        assertEquals(expectedRadius, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-
-        doReturn(false).when(mActivity).isVisibleRequested();
-        doReturn(false).when(mActivity).isVisible();
-        assertEquals(0, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-
-        doReturn(true).when(mActivity).isInLetterboxAnimation();
-        assertEquals(expectedRadius, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-    }
-
-    @Test
-    public void testGetRoundedCornersRadius_noScalingApplied() {
-        final int configurationRadius = 15;
-
-        final WindowState mainWindow = mockForGetCropBoundsAndRoundedCorners(/*taskbar=*/ null);
-        mAppCompatConfiguration.setLetterboxActivityCornersRadius(configurationRadius);
-
-        final AppCompatLetterboxPolicy letterboxPolicy =
-                mActivity.mAppCompatController.getAppCompatLetterboxPolicy();
-
-        mainWindow.mInvGlobalScale = -1f;
-        assertEquals(configurationRadius, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-
-        mainWindow.mInvGlobalScale = 0f;
-        assertEquals(configurationRadius, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-
-        mainWindow.mInvGlobalScale = 1f;
-        assertEquals(configurationRadius, letterboxPolicy.getRoundedCornersRadius(mainWindow));
-    }
-
-    private WindowState mockForGetCropBoundsAndRoundedCorners(@Nullable InsetsSource taskbar) {
-        final WindowState mainWindow = mock(WindowState.class);
-        final InsetsState insets = new InsetsState();
-        final Resources resources = mWm.mContext.getResources();
-        final WindowManager.LayoutParams attrs = new WindowManager.LayoutParams();
-
-        final AppCompatAspectRatioPolicy aspectRatioPolicy = mActivity.mAppCompatController
-                .getAppCompatAspectRatioPolicy();
-
-        mainWindow.mInvGlobalScale = 1f;
-        spyOn(resources);
-        spyOn(mActivity);
-        spyOn(aspectRatioPolicy);
-
-        if (taskbar != null) {
-            taskbar.setVisible(true);
-            insets.addSource(taskbar);
-        }
-        doReturn(mLetterboxedPortraitTaskBounds).when(mActivity).getBounds();
-        doReturn(false).when(mActivity).isInLetterboxAnimation();
-        doReturn(true).when(mActivity).isVisible();
-        doReturn(true).when(aspectRatioPolicy)
-                .isLetterboxedForFixedOrientationAndAspectRatio();
-        doReturn(insets).when(mainWindow).getInsetsState();
-        doReturn(attrs).when(mainWindow).getAttrs();
-        doReturn(true).when(mainWindow).isDrawn();
-        doReturn(true).when(mainWindow).isOnScreen();
-        doReturn(false).when(mainWindow).isLetterboxedForDisplayCutout();
-        doReturn(true).when(mainWindow).areAppWindowBoundsLetterboxed();
-        doReturn(true).when(mAppCompatConfiguration).isLetterboxActivityCornersRounded();
-        doReturn(TASKBAR_EXPANDED_HEIGHT).when(resources).getDimensionPixelSize(
-                R.dimen.taskbar_frame_height);
-
-        return mainWindow;
-    }
-
-    @Test
-    public void testIsLetterboxEducationEnabled() {
-        mActivity.mAppCompatController.getAppCompatLetterboxOverrides()
-                .isLetterboxEducationEnabled();
-        verify(mAppCompatConfiguration).getIsEducationEnabled();
-    }
-
-    private ActivityRecord setUpActivityWithComponent() {
-        mDisplayContent = new TestDisplayContent
-                .Builder(mAtm, /* dw */ 1000, /* dh */ 2000).build();
-        mTask = new TaskBuilder(mSupervisor).setDisplay(mDisplayContent).build();
-        final ActivityRecord activity = new ActivityBuilder(mAtm)
-                .setOnTop(true)
-                .setTask(mTask)
-                // Set the component to be that of the test class in order to enable compat changes
-                .setComponent(ComponentName.createRelative(mContext,
-                        com.android.server.wm.LetterboxUiControllerTest.class.getName()))
-                .build();
-        spyOn(activity.mAppCompatController.getAppCompatCameraOverrides());
-        return activity;
-    }
-}