Merge "Add support for delegating unhandled drags to SystemUI" into main
diff --git a/core/api/current.txt b/core/api/current.txt
index 302ee5a..91a2f68 100644
--- a/core/api/current.txt
+++ b/core/api/current.txt
@@ -10045,11 +10045,22 @@
     method public CharSequence coerceToText(android.content.Context);
     method public String getHtmlText();
     method public android.content.Intent getIntent();
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @Nullable public android.app.PendingIntent getPendingIntent();
     method public CharSequence getText();
     method @Nullable public android.view.textclassifier.TextLinks getTextLinks();
     method public android.net.Uri getUri();
   }
 
+  @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") public static final class ClipData.Item.Builder {
+    ctor public ClipData.Item.Builder();
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @NonNull public android.content.ClipData.Item build();
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @NonNull public android.content.ClipData.Item.Builder setHtmlText(@Nullable String);
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @NonNull public android.content.ClipData.Item.Builder setIntent(@Nullable android.content.Intent);
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @NonNull public android.content.ClipData.Item.Builder setPendingIntent(@Nullable android.app.PendingIntent);
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @NonNull public android.content.ClipData.Item.Builder setText(@Nullable CharSequence);
+    method @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") @NonNull public android.content.ClipData.Item.Builder setUri(@Nullable android.net.Uri);
+  }
+
   public class ClipDescription implements android.os.Parcelable {
     ctor public ClipDescription(CharSequence, String[]);
     ctor public ClipDescription(android.content.ClipDescription);
@@ -52897,9 +52908,11 @@
     field public static final int DRAG_FLAG_GLOBAL = 256; // 0x100
     field public static final int DRAG_FLAG_GLOBAL_PERSISTABLE_URI_PERMISSION = 64; // 0x40
     field public static final int DRAG_FLAG_GLOBAL_PREFIX_URI_PERMISSION = 128; // 0x80
+    field @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") public static final int DRAG_FLAG_GLOBAL_SAME_APPLICATION = 4096; // 0x1000
     field public static final int DRAG_FLAG_GLOBAL_URI_READ = 1; // 0x1
     field public static final int DRAG_FLAG_GLOBAL_URI_WRITE = 2; // 0x2
     field public static final int DRAG_FLAG_OPAQUE = 512; // 0x200
+    field @FlaggedApi("com.android.window.flags.delegate_unhandled_drags") public static final int DRAG_FLAG_START_PENDING_INTENT_ON_UNHANDLED_DRAG = 8192; // 0x2000
     field @Deprecated public static final int DRAWING_CACHE_QUALITY_AUTO = 0; // 0x0
     field @Deprecated public static final int DRAWING_CACHE_QUALITY_HIGH = 1048576; // 0x100000
     field @Deprecated public static final int DRAWING_CACHE_QUALITY_LOW = 524288; // 0x80000
diff --git a/core/java/android/content/ClipData.java b/core/java/android/content/ClipData.java
index 67759f4..eb357fe 100644
--- a/core/java/android/content/ClipData.java
+++ b/core/java/android/content/ClipData.java
@@ -21,7 +21,13 @@
 import static android.content.ContentResolver.SCHEME_CONTENT;
 import static android.content.ContentResolver.SCHEME_FILE;
 
+import static com.android.window.flags.Flags.FLAG_DELEGATE_UNHANDLED_DRAGS;
+
+import android.annotation.FlaggedApi;
+import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.SuppressLint;
+import android.app.PendingIntent;
 import android.compat.annotation.UnsupportedAppUsage;
 import android.content.pm.ActivityInfo;
 import android.content.res.AssetFileDescriptor;
@@ -207,6 +213,7 @@
         final CharSequence mText;
         final String mHtmlText;
         final Intent mIntent;
+        final PendingIntent mPendingIntent;
         @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
         Uri mUri;
         private TextLinks mTextLinks;
@@ -214,12 +221,91 @@
         // if the data is obtained from {@link #copyForTransferWithActivityInfo}
         private ActivityInfo mActivityInfo;
 
+        /**
+         * A builder for a ClipData Item.
+         */
+        @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+        @SuppressLint("PackageLayering")
+        public static final class Builder {
+            private CharSequence mText;
+            private String mHtmlText;
+            private Intent mIntent;
+            private PendingIntent mPendingIntent;
+            private Uri mUri;
+
+            /**
+             * Sets the text for the item to be constructed.
+             */
+            @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+            @NonNull
+            public Builder setText(@Nullable CharSequence text) {
+                mText = text;
+                return this;
+            }
+
+            /**
+             * Sets the HTML text for the item to be constructed.
+             */
+            @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+            @NonNull
+            public Builder setHtmlText(@Nullable String htmlText) {
+                mHtmlText = htmlText;
+                return this;
+            }
+
+            /**
+             * Sets the Intent for the item to be constructed.
+             */
+            @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+            @NonNull
+            public Builder setIntent(@Nullable Intent intent) {
+                mIntent = intent;
+                return this;
+            }
+
+            /**
+             * Sets the PendingIntent for the item to be constructed. To prevent receiving apps from
+             * improperly manipulating the intent to launch another activity as this caller, the
+             * provided PendingIntent must be immutable (see {@link PendingIntent#FLAG_IMMUTABLE}).
+             * The system will clean up the PendingIntent when it is no longer used.
+             */
+            @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+            @NonNull
+            public Builder setPendingIntent(@Nullable PendingIntent pendingIntent) {
+                if (pendingIntent != null && !pendingIntent.isImmutable()) {
+                    throw new IllegalArgumentException("Expected pending intent to be immutable");
+                }
+                mPendingIntent = pendingIntent;
+                return this;
+            }
+
+            /**
+             * Sets the URI for the item to be constructed.
+             */
+            @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+            @NonNull
+            public Builder setUri(@Nullable Uri uri) {
+                mUri = uri;
+                return this;
+            }
+
+            /**
+             * Constructs a new Item with the properties set on this builder.
+             */
+            @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+            @NonNull
+            public Item build() {
+                return new Item(mText, mHtmlText, mIntent, mPendingIntent, mUri);
+            }
+        }
+
 
         /** @hide */
         public Item(Item other) {
             mText = other.mText;
             mHtmlText = other.mHtmlText;
             mIntent = other.mIntent;
+            mPendingIntent = other.mPendingIntent;
             mUri = other.mUri;
             mActivityInfo = other.mActivityInfo;
             mTextLinks = other.mTextLinks;
@@ -229,10 +315,7 @@
          * Create an Item consisting of a single block of (possibly styled) text.
          */
         public Item(CharSequence text) {
-            mText = text;
-            mHtmlText = null;
-            mIntent = null;
-            mUri = null;
+            this(text, null, null, null, null);
         }
 
         /**
@@ -245,30 +328,21 @@
          * </p>
          */
         public Item(CharSequence text, String htmlText) {
-            mText = text;
-            mHtmlText = htmlText;
-            mIntent = null;
-            mUri = null;
+            this(text, htmlText, null, null, null);
         }
 
         /**
          * Create an Item consisting of an arbitrary Intent.
          */
         public Item(Intent intent) {
-            mText = null;
-            mHtmlText = null;
-            mIntent = intent;
-            mUri = null;
+            this(null, null, intent, null, null);
         }
 
         /**
          * Create an Item consisting of an arbitrary URI.
          */
         public Item(Uri uri) {
-            mText = null;
-            mHtmlText = null;
-            mIntent = null;
-            mUri = uri;
+            this(null, null, null, null, uri);
         }
 
         /**
@@ -276,10 +350,7 @@
          * text, Intent, and/or URI.
          */
         public Item(CharSequence text, Intent intent, Uri uri) {
-            mText = text;
-            mHtmlText = null;
-            mIntent = intent;
-            mUri = uri;
+            this(text, null, intent, null, uri);
         }
 
         /**
@@ -289,6 +360,14 @@
          * will not be done from HTML formatted text into plain text.
          */
         public Item(CharSequence text, String htmlText, Intent intent, Uri uri) {
+            this(text, htmlText, intent, null, uri);
+        }
+
+        /**
+         * Builder ctor.
+         */
+        private Item(CharSequence text, String htmlText, Intent intent, PendingIntent pendingIntent,
+                Uri uri) {
             if (htmlText != null && text == null) {
                 throw new IllegalArgumentException(
                         "Plain text must be supplied if HTML text is supplied");
@@ -296,6 +375,7 @@
             mText = text;
             mHtmlText = htmlText;
             mIntent = intent;
+            mPendingIntent = pendingIntent;
             mUri = uri;
         }
 
@@ -321,6 +401,15 @@
         }
 
         /**
+         * Returns the pending intent in this Item.
+         */
+        @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+        @Nullable
+        public PendingIntent getPendingIntent() {
+            return mPendingIntent;
+        }
+
+        /**
          * Retrieve the raw URI contained in this Item.
          */
         public Uri getUri() {
@@ -777,7 +866,7 @@
             throw new NullPointerException("item is null");
         }
         mIcon = null;
-        mItems = new ArrayList<Item>();
+        mItems = new ArrayList<>();
         mItems.add(item);
         mClipDescription.setIsStyledText(isStyledText());
     }
@@ -794,7 +883,7 @@
             throw new NullPointerException("item is null");
         }
         mIcon = null;
-        mItems = new ArrayList<Item>();
+        mItems = new ArrayList<>();
         mItems.add(item);
         mClipDescription.setIsStyledText(isStyledText());
     }
@@ -826,7 +915,7 @@
     public ClipData(ClipData other) {
         mClipDescription = other.mClipDescription;
         mIcon = other.mIcon;
-        mItems = new ArrayList<Item>(other.mItems);
+        mItems = new ArrayList<>(other.mItems);
     }
 
     /**
@@ -1042,6 +1131,35 @@
     }
 
     /**
+     * Checks if this clip data has a pending intent that is an activity type.
+     * @hide
+     */
+    public boolean hasActivityPendingIntents() {
+        final int size = mItems.size();
+        for (int i = 0; i < size; i++) {
+            final Item item = mItems.get(i);
+            if (item.mPendingIntent != null && item.mPendingIntent.isActivity()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Cleans up all pending intents in the ClipData.
+     * @hide
+     */
+    public void cleanUpPendingIntents() {
+        final int size = mItems.size();
+        for (int i = 0; i < size; i++) {
+            final Item item = mItems.get(i);
+            if (item.mPendingIntent != null) {
+                item.mPendingIntent.cancel();
+            }
+        }
+    }
+
+    /**
      * Prepare this {@link ClipData} to leave an app process.
      *
      * @hide
@@ -1243,6 +1361,7 @@
             TextUtils.writeToParcel(item.mText, dest, flags);
             dest.writeString8(item.mHtmlText);
             dest.writeTypedObject(item.mIntent, flags);
+            dest.writeTypedObject(item.mPendingIntent, flags);
             dest.writeTypedObject(item.mUri, flags);
             dest.writeTypedObject(mParcelItemActivityInfos ? item.mActivityInfo : null, flags);
             dest.writeTypedObject(item.mTextLinks, flags);
@@ -1262,10 +1381,11 @@
             CharSequence text = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
             String htmlText = in.readString8();
             Intent intent = in.readTypedObject(Intent.CREATOR);
+            PendingIntent pendingIntent = in.readTypedObject(PendingIntent.CREATOR);
             Uri uri = in.readTypedObject(Uri.CREATOR);
             ActivityInfo info = in.readTypedObject(ActivityInfo.CREATOR);
             TextLinks textLinks = in.readTypedObject(TextLinks.CREATOR);
-            Item item = new Item(text, htmlText, intent, uri);
+            Item item = new Item(text, htmlText, intent, pendingIntent, uri);
             item.setActivityInfo(info);
             item.setTextLinks(textLinks);
             mItems.add(item);
@@ -1273,7 +1393,7 @@
     }
 
     public static final @android.annotation.NonNull Parcelable.Creator<ClipData> CREATOR =
-        new Parcelable.Creator<ClipData>() {
+        new Parcelable.Creator<>() {
 
             @Override
             public ClipData createFromParcel(Parcel source) {
diff --git a/core/java/android/view/IWindowManager.aidl b/core/java/android/view/IWindowManager.aidl
index b5b81d1..29cc859 100644
--- a/core/java/android/view/IWindowManager.aidl
+++ b/core/java/android/view/IWindowManager.aidl
@@ -73,6 +73,7 @@
 import android.window.ISurfaceSyncGroupCompletedListener;
 import android.window.ITaskFpsCallback;
 import android.window.ITrustedPresentationListener;
+import android.window.IUnhandledDragListener;
 import android.window.InputTransferToken;
 import android.window.ScreenCapture;
 import android.window.TrustedPresentationThresholds;
@@ -1091,4 +1092,10 @@
 
     @EnforcePermission("DETECT_SCREEN_RECORDING")
     void unregisterScreenRecordingCallback(IScreenRecordingCallback callback);
+
+    /**
+     * Sets the listener to be called back when a cross-window drag and drop operation is unhandled
+     * (ie. not handled by any window which can handle the drag).
+     */
+    void setUnhandledDragListener(IUnhandledDragListener listener);
 }
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index c22986b..e781f3c 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -43,6 +43,7 @@
 import static com.android.internal.util.FrameworkStatsLog.TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS;
 import static com.android.internal.util.FrameworkStatsLog.TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__SINGLE_TAP;
 import static com.android.internal.util.FrameworkStatsLog.TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__UNKNOWN_CLASSIFICATION;
+import static com.android.window.flags.Flags.FLAG_DELEGATE_UNHANDLED_DRAGS;
 
 import static java.lang.Math.max;
 
@@ -68,6 +69,7 @@
 import android.annotation.TestApi;
 import android.annotation.UiContext;
 import android.annotation.UiThread;
+import android.app.PendingIntent;
 import android.compat.annotation.UnsupportedAppUsage;
 import android.content.AutofillOptions;
 import android.content.ClipData;
@@ -5329,6 +5331,34 @@
     public static final int DRAG_FLAG_REQUEST_SURFACE_FOR_RETURN_ANIMATION = 1 << 11;
 
     /**
+     * Flag indicating that a drag can cross window boundaries (within the same application).  When
+     * {@link #startDragAndDrop(ClipData, DragShadowBuilder, Object, int)} is called
+     * with this flag set, only visible windows belonging to the same application (ie. share the
+     * same UID) with targetSdkVersion >= {@link android.os.Build.VERSION_CODES#N API 24} will be
+     * able to participate in the drag operation and receive the dragged content.
+     *
+     * If both DRAG_FLAG_GLOBAL_SAME_APPLICATION and DRAG_FLAG_GLOBAL are set, then
+     * DRAG_FLAG_GLOBAL_SAME_APPLICATION takes precedence and the drag will only go to visible
+     * windows from the same application.
+     */
+    @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+    public static final int DRAG_FLAG_GLOBAL_SAME_APPLICATION = 1 << 12;
+
+    /**
+     * Flag indicating that an unhandled drag should be delegated to the system to be started if no
+     * visible window wishes to handle the drop. When using this flag, the caller must provide
+     * ClipData with an Item that contains an immutable PendingIntent to an activity to be launched
+     * (not a broadcast, service, etc).  See
+     * {@link ClipData.Item.Builder#setPendingIntent(PendingIntent)}.
+     *
+     * The system can decide to launch the intent or not based on factors like the current screen
+     * size or windowing mode. If the system does not launch the intent, it will be canceled via the
+     * normal drag and drop flow.
+     */
+    @FlaggedApi(FLAG_DELEGATE_UNHANDLED_DRAGS)
+    public static final int DRAG_FLAG_START_PENDING_INTENT_ON_UNHANDLED_DRAG = 1 << 13;
+
+    /**
      * Vertical scroll factor cached by {@link #getVerticalScrollFactor}.
      */
     private float mVerticalScrollFactor;
@@ -28496,9 +28526,29 @@
             Log.w(VIEW_LOG_TAG, "startDragAndDrop called with an invalid surface.");
             return false;
         }
+        if ((flags & DRAG_FLAG_GLOBAL) != 0 && ((flags & DRAG_FLAG_GLOBAL_SAME_APPLICATION) != 0)) {
+            Log.w(VIEW_LOG_TAG, "startDragAndDrop called with both DRAG_FLAG_GLOBAL "
+                    + "and DRAG_FLAG_GLOBAL_SAME_APPLICATION, the drag will default to "
+                    + "DRAG_FLAG_GLOBAL_SAME_APPLICATION");
+            flags &= ~DRAG_FLAG_GLOBAL;
+        }
 
         if (data != null) {
-            data.prepareToLeaveProcess((flags & View.DRAG_FLAG_GLOBAL) != 0);
+            if (com.android.window.flags.Flags.delegateUnhandledDrags()) {
+                data.prepareToLeaveProcess(
+                        (flags & (DRAG_FLAG_GLOBAL_SAME_APPLICATION | DRAG_FLAG_GLOBAL)) != 0);
+                if ((flags & DRAG_FLAG_START_PENDING_INTENT_ON_UNHANDLED_DRAG) != 0) {
+                    if (!data.hasActivityPendingIntents()) {
+                        // Reset the flag if there is no launchable activity intent
+                        flags &= ~DRAG_FLAG_START_PENDING_INTENT_ON_UNHANDLED_DRAG;
+                        Log.w(VIEW_LOG_TAG, "startDragAndDrop called with "
+                                + "DRAG_FLAG_START_INTENT_ON_UNHANDLED_DRAG but the clip data "
+                                + "contains non-activity PendingIntents");
+                    }
+                }
+            } else {
+                data.prepareToLeaveProcess((flags & DRAG_FLAG_GLOBAL) != 0);
+            }
         }
 
         Rect bounds = new Rect();
@@ -28524,6 +28574,7 @@
                 if (token != null) {
                     root.setLocalDragState(myLocalState);
                     mAttachInfo.mDragToken = token;
+                    mAttachInfo.mDragData = data;
                     mAttachInfo.mViewRootImpl.setDragStartedViewForAccessibility(this);
                     setAccessibilityDragStarted(true);
                 }
@@ -28601,8 +28652,12 @@
                 if (mAttachInfo.mDragSurface != null) {
                     mAttachInfo.mDragSurface.release();
                 }
+                if (mAttachInfo.mDragData != null) {
+                    mAttachInfo.mDragData.cleanUpPendingIntents();
+                }
                 mAttachInfo.mDragSurface = surface;
                 mAttachInfo.mDragToken = token;
+                mAttachInfo.mDragData = data;
                 // Cache the local state object for delivery with DragEvents
                 root.setLocalDragState(myLocalState);
                 if (a11yEnabled) {
@@ -31516,11 +31571,15 @@
         IBinder mDragToken;
 
         /**
+         * Used to track the data of the current drag operation for cleanup later.
+         */
+        ClipData mDragData;
+
+        /**
          * The drag shadow surface for the current drag operation.
          */
         public Surface mDragSurface;
 
-
         /**
          * The view that currently has a tooltip displayed.
          */
diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java
index 07c9795..28a7334 100644
--- a/core/java/android/view/ViewRootImpl.java
+++ b/core/java/android/view/ViewRootImpl.java
@@ -8599,6 +8599,10 @@
                         mAttachInfo.mDragSurface.release();
                         mAttachInfo.mDragSurface = null;
                     }
+                    if (mAttachInfo.mDragData != null) {
+                        mAttachInfo.mDragData.cleanUpPendingIntents();
+                        mAttachInfo.mDragData = null;
+                    }
                 }
             }
         }
diff --git a/core/java/android/window/IUnhandledDragCallback.aidl b/core/java/android/window/IUnhandledDragCallback.aidl
new file mode 100644
index 0000000..7806b1f
--- /dev/null
+++ b/core/java/android/window/IUnhandledDragCallback.aidl
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.window;
+
+import android.view.DragEvent;
+
+/**
+ * A callback for notifying the system when the unhandled drop is complete.
+ * {@hide}
+ */
+oneway interface IUnhandledDragCallback {
+    /**
+     * Called when the IUnhandledDropListener has fully handled the drop, and the drag can be
+     * cleaned up.  If handled is `true`, then cleanup of the drag and drag surface will be
+     * immediate, otherwise, the system will treat the drag as a cancel back to the start of the
+     * drag.
+     */
+    void notifyUnhandledDropComplete(boolean handled);
+}
diff --git a/core/java/android/window/IUnhandledDragListener.aidl b/core/java/android/window/IUnhandledDragListener.aidl
new file mode 100644
index 0000000..52e9895
--- /dev/null
+++ b/core/java/android/window/IUnhandledDragListener.aidl
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.window;
+
+import android.view.DragEvent;
+import android.window.IUnhandledDragCallback;
+
+/**
+ * An interface to a handler for global drags that are not consumed (ie. not handled by any window).
+ * {@hide}
+ */
+oneway interface IUnhandledDragListener {
+    /**
+     * Called when the user finishes the drag gesture but no windows have reported handling the
+     * drop.  The DragEvent is populated with the drag surface for the listener to animate.  The
+     * listener *MUST* call the provided callback exactly once when it has finished handling the
+     * drop.  If the listener calls the callback with `true` then it is responsible for removing
+     * and releasing the drag surface passed through the DragEvent.
+     */
+    void onUnhandledDrop(in DragEvent event, in IUnhandledDragCallback callback);
+}
diff --git a/services/core/java/com/android/server/wm/DragDropController.java b/services/core/java/com/android/server/wm/DragDropController.java
index 6a3cf43..a3e2869 100644
--- a/services/core/java/com/android/server/wm/DragDropController.java
+++ b/services/core/java/com/android/server/wm/DragDropController.java
@@ -16,6 +16,9 @@
 
 package com.android.server.wm;
 
+import static android.view.View.DRAG_FLAG_GLOBAL;
+import static android.view.View.DRAG_FLAG_GLOBAL_SAME_APPLICATION;
+
 import static com.android.input.flags.Flags.enablePointerChoreographer;
 import static com.android.server.wm.WindowManagerDebugConfig.DEBUG_DRAG;
 import static com.android.server.wm.WindowManagerDebugConfig.SHOW_LIGHT_TRANSACTIONS;
@@ -30,15 +33,20 @@
 import android.os.IBinder;
 import android.os.Looper;
 import android.os.Message;
+import android.os.RemoteException;
 import android.util.Slog;
 import android.view.Display;
+import android.view.DragEvent;
 import android.view.IWindow;
 import android.view.InputDevice;
 import android.view.PointerIcon;
 import android.view.SurfaceControl;
 import android.view.View;
 import android.view.accessibility.AccessibilityManager;
+import android.window.IUnhandledDragCallback;
+import android.window.IUnhandledDragListener;
 
+import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.wm.WindowManagerInternal.IDragDropCallback;
 
 import java.util.Objects;
@@ -59,6 +67,7 @@
     static final int MSG_TEAR_DOWN_DRAG_AND_DROP_INPUT = 1;
     static final int MSG_ANIMATION_END = 2;
     static final int MSG_REMOVE_DRAG_SURFACE_TIMEOUT = 3;
+    static final int MSG_UNHANDLED_DROP_LISTENER_TIMEOUT = 4;
 
     /**
      * Drag state per operation.
@@ -72,6 +81,21 @@
     private WindowManagerService mService;
     private final Handler mHandler;
 
+    // The unhandled drag listener for handling cross-window drags that end with no target window
+    private IUnhandledDragListener mUnhandledDragListener;
+    private final IBinder.DeathRecipient mUnhandledDragListenerDeathRecipient =
+            new IBinder.DeathRecipient() {
+        @Override
+        public void binderDied() {
+            synchronized (mService.mGlobalLock) {
+                if (hasPendingUnhandledDropCallback()) {
+                    onUnhandledDropCallback(false /* consumedByListeners */);
+                }
+                setUnhandledDragListener(null);
+            }
+        }
+    };
+
     /**
      * Callback which is used to sync drag state with the vendor-specific code.
      */
@@ -83,10 +107,16 @@
         mHandler = new DragHandler(service, looper);
     }
 
+    @VisibleForTesting
+    Handler getHandler() {
+        return mHandler;
+    }
+
     boolean dragDropActiveLocked() {
         return mDragState != null && !mDragState.isClosing();
     }
 
+    @VisibleForTesting
     boolean dragSurfaceRelinquishedToDropTarget() {
         return mDragState != null && mDragState.mRelinquishDragSurfaceToDropTarget;
     }
@@ -96,6 +126,32 @@
         mCallback.set(callback);
     }
 
+    /**
+     * Sets the listener for unhandled cross-window drags.
+     */
+    public void setUnhandledDragListener(IUnhandledDragListener listener) {
+        if (mUnhandledDragListener != null && mUnhandledDragListener.asBinder() != null) {
+            mUnhandledDragListener.asBinder().unlinkToDeath(
+                    mUnhandledDragListenerDeathRecipient, 0);
+        }
+        mUnhandledDragListener = listener;
+        if (listener != null && listener.asBinder() != null) {
+            try {
+                mUnhandledDragListener.asBinder().linkToDeath(
+                        mUnhandledDragListenerDeathRecipient, 0);
+            } catch (RemoteException e) {
+                mUnhandledDragListener = null;
+            }
+        }
+    }
+
+    /**
+     * Returns whether there is an unhandled drag listener set.
+     */
+    boolean hasUnhandledDragListener() {
+        return mUnhandledDragListener != null;
+    }
+
     void sendDragStartedIfNeededLocked(WindowState window) {
         mDragState.sendDragStartedIfNeededLocked(window);
     }
@@ -247,6 +303,10 @@
         }
     }
 
+    /**
+     * This is called from the drop target window that received ACTION_DROP
+     * (see DragState#reportDropWindowLock()).
+     */
     void reportDropResult(IWindow window, boolean consumed) {
         IBinder token = window.asBinder();
         if (DEBUG_DRAG) {
@@ -273,22 +333,89 @@
                 // so be sure to halt the timeout even if the later WindowState
                 // lookup fails.
                 mHandler.removeMessages(MSG_DRAG_END_TIMEOUT, window.asBinder());
+
                 WindowState callingWin = mService.windowForClientLocked(null, window, false);
                 if (callingWin == null) {
                     Slog.w(TAG_WM, "Bad result-reporting window " + window);
                     return;  // !!! TODO: throw here?
                 }
 
-                mDragState.mDragResult = consumed;
-                mDragState.mRelinquishDragSurfaceToDropTarget = consumed
-                        && mDragState.targetInterceptsGlobalDrag(callingWin);
-                mDragState.endDragLocked();
+                // If the drop was not consumed by the target window, then check if it should be
+                // consumed by the system unhandled drag listener
+                if (!consumed && notifyUnhandledDrop(mDragState.mUnhandledDropEvent,
+                        "window-drop")) {
+                    // If the unhandled drag listener is notified, then defer ending the drag until
+                    // the listener calls back
+                    return;
+                }
+
+                final boolean relinquishDragSurfaceToDropTarget =
+                        consumed && mDragState.targetInterceptsGlobalDrag(callingWin);
+                mDragState.endDragLocked(consumed, relinquishDragSurfaceToDropTarget);
             }
         } finally {
             mCallback.get().postReportDropResult();
         }
     }
 
+    /**
+     * Notifies the unhandled drag listener if needed.
+     * @return whether the listener was notified and subsequent drag completion should be deferred
+     *         until the listener calls back
+     */
+    boolean notifyUnhandledDrop(DragEvent dropEvent, String reason) {
+        final boolean isLocalDrag =
+                (mDragState.mFlags & (DRAG_FLAG_GLOBAL_SAME_APPLICATION | DRAG_FLAG_GLOBAL)) == 0;
+        if (!com.android.window.flags.Flags.delegateUnhandledDrags()
+                || mUnhandledDragListener == null
+                || isLocalDrag) {
+            // Skip if the flag is disabled, there is no unhandled-drag listener, or if this is a
+            // purely local drag
+            if (DEBUG_DRAG) Slog.d(TAG_WM, "Skipping unhandled listener "
+                    + "(listener=" + mUnhandledDragListener + ", flags=" + mDragState.mFlags + ")");
+            return false;
+        }
+        if (DEBUG_DRAG) Slog.d(TAG_WM, "Sending DROP to unhandled listener (" + reason + ")");
+        try {
+            // Schedule timeout for the unhandled drag listener to call back
+            sendTimeoutMessage(MSG_UNHANDLED_DROP_LISTENER_TIMEOUT, null, DRAG_TIMEOUT_MS);
+            mUnhandledDragListener.onUnhandledDrop(dropEvent, new IUnhandledDragCallback.Stub() {
+                @Override
+                public void notifyUnhandledDropComplete(boolean consumedByListener) {
+                    if (DEBUG_DRAG) Slog.d(TAG_WM, "Unhandled listener finished handling DROP");
+                    synchronized (mService.mGlobalLock) {
+                        onUnhandledDropCallback(consumedByListener);
+                    }
+                }
+            });
+            return true;
+        } catch (RemoteException e) {
+            Slog.e(TAG_WM, "Failed to call unhandled drag listener", e);
+            return false;
+        }
+    }
+
+    /**
+     * Called when the unhandled drag listener has completed handling the drop
+     * (if it was notififed).
+     */
+    @VisibleForTesting
+    void onUnhandledDropCallback(boolean consumedByListener) {
+        mHandler.removeMessages(MSG_UNHANDLED_DROP_LISTENER_TIMEOUT, null);
+        // If handled, then the listeners assume responsibility of cleaning up the drag surface
+        mDragState.mDragResult = consumedByListener;
+        mDragState.mRelinquishDragSurfaceToDropTarget = consumedByListener;
+        mDragState.closeLocked();
+    }
+
+    /**
+     * Returns whether we are currently waiting for the unhandled drag listener to callback after
+     * it was notified of an unhandled drag.
+     */
+    boolean hasPendingUnhandledDropCallback() {
+        return mHandler.hasMessages(MSG_UNHANDLED_DROP_LISTENER_TIMEOUT);
+    }
+
     void cancelDragAndDrop(IBinder dragToken, boolean skipAnimation) {
         if (DEBUG_DRAG) {
             Slog.d(TAG_WM, "cancelDragAndDrop");
@@ -436,8 +563,8 @@
                     synchronized (mService.mGlobalLock) {
                         // !!! TODO: ANR the drag-receiving app
                         if (mDragState != null) {
-                            mDragState.mDragResult = false;
-                            mDragState.endDragLocked();
+                            mDragState.endDragLocked(false /* consumed */,
+                                    false /* relinquishDragSurfaceToDropTarget */);
                         }
                     }
                     break;
@@ -473,6 +600,13 @@
                     }
                     break;
                 }
+
+                case MSG_UNHANDLED_DROP_LISTENER_TIMEOUT: {
+                    synchronized (mService.mGlobalLock) {
+                        onUnhandledDropCallback(false /* consumedByListener */);
+                    }
+                    break;
+                }
             }
         }
     }
diff --git a/services/core/java/com/android/server/wm/DragState.java b/services/core/java/com/android/server/wm/DragState.java
index d302f06..76038b9 100644
--- a/services/core/java/com/android/server/wm/DragState.java
+++ b/services/core/java/com/android/server/wm/DragState.java
@@ -147,6 +147,11 @@
      */
     private boolean mIsClosing;
 
+    // Stores the last drop event which was reported to a valid drop target window, or null
+    // otherwise.  This drop event will contain private info and should only be consumed by the
+    // unhandled drag listener.
+    DragEvent mUnhandledDropEvent;
+
     DragState(WindowManagerService service, DragDropController controller, IBinder token,
             SurfaceControl surface, int flags, IBinder localWin) {
         mService = service;
@@ -287,14 +292,54 @@
         mData = null;
         mThumbOffsetX = mThumbOffsetY = 0;
         mNotifiedWindows = null;
+        if (mUnhandledDropEvent != null) {
+            mUnhandledDropEvent.recycle();
+            mUnhandledDropEvent = null;
+        }
 
         // Notifies the controller that the drag state is closed.
         mDragDropController.onDragStateClosedLocked(this);
     }
 
     /**
+     * Creates the drop event for this drag gesture.  If `touchedWin` is null, then the drop event
+     * will be created for dispatching to the unhandled drag and the drag surface will be provided
+     * as a part of the dispatched event.
+     */
+    private DragEvent createDropEvent(float x, float y, @Nullable WindowState touchedWin,
+            boolean includeDragSurface) {
+        if (touchedWin != null) {
+            final int targetUserId = UserHandle.getUserId(touchedWin.getOwningUid());
+            final DragAndDropPermissionsHandler dragAndDropPermissions;
+            if ((mFlags & View.DRAG_FLAG_GLOBAL) != 0 && (mFlags & DRAG_FLAGS_URI_ACCESS) != 0
+                    && mData != null) {
+                dragAndDropPermissions = new DragAndDropPermissionsHandler(mService.mGlobalLock,
+                        mData,
+                        mUid,
+                        touchedWin.getOwningPackage(),
+                        mFlags & DRAG_FLAGS_URI_PERMISSIONS,
+                        mSourceUserId,
+                        targetUserId);
+            } else {
+                dragAndDropPermissions = null;
+            }
+            if (mSourceUserId != targetUserId) {
+                if (mData != null) {
+                    mData.fixUris(mSourceUserId);
+                }
+            }
+            return obtainDragEvent(DragEvent.ACTION_DROP, x, y, mData,
+                    targetInterceptsGlobalDrag(touchedWin), dragAndDropPermissions);
+        } else {
+            return obtainDragEvent(DragEvent.ACTION_DROP, x, y, mData,
+                    includeDragSurface /* includeDragSurface */, null /* dragAndDropPermissions */);
+        }
+    }
+
+    /**
      * Notify the drop target and tells it about the data. If the drop event is not sent to the
-     * target, invokes {@code endDragLocked} immediately.
+     * target, invokes {@code endDragLocked} after the unhandled drag listener gets a chance to
+     * handle the drop.
      */
     boolean reportDropWindowLock(IBinder token, float x, float y) {
         if (mAnimator != null) {
@@ -302,41 +347,27 @@
         }
 
         final WindowState touchedWin = mService.mInputToWindowMap.get(token);
+        final DragEvent unhandledDropEvent = createDropEvent(x, y, null /* touchedWin */,
+                true /* includePrivateInfo */);
         if (!isWindowNotified(touchedWin)) {
-            // "drop" outside a valid window -- no recipient to apply a
-            // timeout to, and we can send the drag-ended message immediately.
-            mDragResult = false;
-            endDragLocked();
+            // Delegate to the unhandled drag listener as a first pass
+            if (mDragDropController.notifyUnhandledDrop(unhandledDropEvent, "unhandled-drop")) {
+                // The unhandled drag listener will call back to notify whether it has consumed
+                // the drag, so return here
+                return true;
+            }
+
+            // "drop" outside a valid window -- no recipient to apply a timeout to, and we can send
+            // the drag-ended message immediately.
+            endDragLocked(false /* consumed */, false /* relinquishDragSurfaceToDropTarget */);
             if (DEBUG_DRAG) Slog.d(TAG_WM, "Drop outside a valid window " + touchedWin);
             return false;
         }
 
         if (DEBUG_DRAG) Slog.d(TAG_WM, "sending DROP to " + touchedWin);
 
-        final int targetUserId = UserHandle.getUserId(touchedWin.getOwningUid());
-
-        final DragAndDropPermissionsHandler dragAndDropPermissions;
-        if ((mFlags & View.DRAG_FLAG_GLOBAL) != 0 && (mFlags & DRAG_FLAGS_URI_ACCESS) != 0
-                && mData != null) {
-            dragAndDropPermissions = new DragAndDropPermissionsHandler(mService.mGlobalLock,
-                    mData,
-                    mUid,
-                    touchedWin.getOwningPackage(),
-                    mFlags & DRAG_FLAGS_URI_PERMISSIONS,
-                    mSourceUserId,
-                    targetUserId);
-        } else {
-            dragAndDropPermissions = null;
-        }
-        if (mSourceUserId != targetUserId) {
-            if (mData != null) {
-                mData.fixUris(mSourceUserId);
-            }
-        }
         final IBinder clientToken = touchedWin.mClient.asBinder();
-        final DragEvent event = obtainDragEvent(DragEvent.ACTION_DROP, x, y,
-                mData, targetInterceptsGlobalDrag(touchedWin),
-                dragAndDropPermissions);
+        final DragEvent event = createDropEvent(x, y, touchedWin, false /* includePrivateInfo */);
         try {
             touchedWin.mClient.dispatchDragEvent(event);
 
@@ -345,7 +376,7 @@
                     DragDropController.DRAG_TIMEOUT_MS);
         } catch (RemoteException e) {
             Slog.w(TAG_WM, "can't send drop notification to win " + touchedWin);
-            endDragLocked();
+            endDragLocked(false /* consumed */, false /* relinquishDragSurfaceToDropTarget */);
             return false;
         } finally {
             if (MY_PID != touchedWin.mSession.mPid) {
@@ -353,6 +384,7 @@
             }
         }
         mToken = clientToken;
+        mUnhandledDropEvent = unhandledDropEvent;
         return true;
     }
 
@@ -478,6 +510,9 @@
             boolean containsAppExtras) {
         final boolean interceptsGlobalDrag = targetInterceptsGlobalDrag(newWin);
         if (mDragInProgress && isValidDropTarget(newWin, containsAppExtras, interceptsGlobalDrag)) {
+            if (DEBUG_DRAG) {
+                Slog.d(TAG_WM, "Sending DRAG_STARTED to new window " + newWin);
+            }
             // Only allow the extras to be dispatched to a global-intercepting drag target
             ClipData data = interceptsGlobalDrag ? mData.copyForTransferWithActivityInfo() : null;
             DragEvent event = obtainDragEvent(DragEvent.ACTION_DRAG_STARTED,
@@ -523,14 +558,25 @@
             return false;
         }
         if (!targetWin.isPotentialDragTarget(interceptsGlobalDrag)) {
+            // Window should not be a target
             return false;
         }
-        if ((mFlags & View.DRAG_FLAG_GLOBAL) == 0 || !targetWindowSupportsGlobalDrag(targetWin)) {
+        final boolean isGlobalSameAppDrag = (mFlags & View.DRAG_FLAG_GLOBAL_SAME_APPLICATION) != 0;
+        final boolean isGlobalDrag = (mFlags & View.DRAG_FLAG_GLOBAL) != 0;
+        final boolean isAnyGlobalDrag = isGlobalDrag || isGlobalSameAppDrag;
+        if (!isAnyGlobalDrag || !targetWindowSupportsGlobalDrag(targetWin)) {
             // Drag is limited to the current window.
             if (!isLocalWindow) {
                 return false;
             }
         }
+        if (isGlobalSameAppDrag) {
+            // Drag is limited to app windows from the same uid or windows that can intercept global
+            // drag
+            if (!interceptsGlobalDrag && mUid != targetWin.getUid()) {
+                return false;
+            }
+        }
 
         return interceptsGlobalDrag
                 || mCrossProfileCopyAllowed
@@ -547,7 +593,10 @@
     /**
      * @return whether the given window {@param targetWin} can intercept global drags.
      */
-    public boolean targetInterceptsGlobalDrag(WindowState targetWin) {
+    public boolean targetInterceptsGlobalDrag(@Nullable WindowState targetWin) {
+        if (targetWin == null) {
+            return false;
+        }
         return (targetWin.mAttrs.privateFlags & PRIVATE_FLAG_INTERCEPT_GLOBAL_DRAG_AND_DROP) != 0;
     }
 
@@ -561,9 +610,6 @@
             if (isWindowNotified(newWin)) {
                 return;
             }
-            if (DEBUG_DRAG) {
-                Slog.d(TAG_WM, "need to send DRAG_STARTED to new window " + newWin);
-            }
             sendDragStartedLocked(newWin, mCurrentX, mCurrentY,
                     containsApplicationExtras(mDataDescription));
         }
@@ -578,7 +624,13 @@
         return false;
     }
 
-    void endDragLocked() {
+    /**
+     * Ends the current drag, animating the drag surface back to the source if the drop was not
+     * consumed by the receiving window.
+     */
+    void endDragLocked(boolean dropConsumed, boolean relinquishDragSurfaceToDropTarget) {
+        mDragResult = dropConsumed;
+        mRelinquishDragSurfaceToDropTarget = relinquishDragSurfaceToDropTarget;
         if (mAnimator != null) {
             return;
         }
diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java
index 4ea76e1..de8d9f9 100644
--- a/services/core/java/com/android/server/wm/WindowManagerService.java
+++ b/services/core/java/com/android/server/wm/WindowManagerService.java
@@ -310,6 +310,7 @@
 import android.window.ISurfaceSyncGroupCompletedListener;
 import android.window.ITaskFpsCallback;
 import android.window.ITrustedPresentationListener;
+import android.window.IUnhandledDragListener;
 import android.window.InputTransferToken;
 import android.window.ScreenCapture;
 import android.window.SystemPerformanceHinter;
@@ -10026,4 +10027,16 @@
     void onProcessActivityVisibilityChanged(int uid, boolean visible) {
         mScreenRecordingCallbackController.onProcessActivityVisibilityChanged(uid, visible);
     }
+
+    /**
+     * Sets the listener to be called back when a cross-window drag and drop operation is unhandled
+     * (ie. not handled by any window which can handle the drag).
+     */
+    @Override
+    public void setUnhandledDragListener(IUnhandledDragListener listener) throws RemoteException {
+        mAtmService.enforceTaskPermission("setUnhandledDragListener");
+        synchronized (mGlobalLock) {
+            mDragDropController.setUnhandledDragListener(listener);
+        }
+    }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/DragDropControllerTests.java b/services/tests/wmtests/src/com/android/server/wm/DragDropControllerTests.java
index 1fb7cd8..9e2b1ec 100644
--- a/services/tests/wmtests/src/com/android/server/wm/DragDropControllerTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/DragDropControllerTests.java
@@ -32,14 +32,17 @@
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.when;
+import static com.android.server.wm.DragDropController.MSG_UNHANDLED_DROP_LISTENER_TIMEOUT;
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
 import android.app.PendingIntent;
@@ -49,9 +52,12 @@
 import android.content.pm.ShortcutServiceInternal;
 import android.graphics.PixelFormat;
 import android.os.Binder;
+import android.os.Handler;
 import android.os.IBinder;
 import android.os.Looper;
+import android.os.Message;
 import android.os.Parcelable;
+import android.os.RemoteException;
 import android.os.UserHandle;
 import android.platform.test.annotations.Presubmit;
 import android.view.DragEvent;
@@ -61,6 +67,7 @@
 import android.view.View;
 import android.view.WindowManager;
 import android.view.accessibility.AccessibilityManager;
+import android.window.IUnhandledDragListener;
 
 import androidx.test.filters.SmallTest;
 
@@ -533,14 +540,98 @@
                 });
     }
 
+    @Test
+    public void testUnhandledDragListenerNotCalledForNormalDrags() throws RemoteException {
+        assumeTrue(com.android.window.flags.Flags.delegateUnhandledDrags());
+
+        final IUnhandledDragListener listener = mock(IUnhandledDragListener.class);
+        doReturn(mock(Binder.class)).when(listener).asBinder();
+        mTarget.setUnhandledDragListener(listener);
+        doDragAndDrop(0, ClipData.newPlainText("label", "Test"), 0, 0);
+        verify(listener, times(0)).onUnhandledDrop(any(), any());
+    }
+
+    @Test
+    public void testUnhandledDragListenerReceivesUnhandledDropOverWindow() {
+        assumeTrue(com.android.window.flags.Flags.delegateUnhandledDrags());
+
+        final IUnhandledDragListener listener = mock(IUnhandledDragListener.class);
+        doReturn(mock(Binder.class)).when(listener).asBinder();
+        mTarget.setUnhandledDragListener(listener);
+        final int invalidXY = 100_000;
+        startDrag(View.DRAG_FLAG_GLOBAL, ClipData.newPlainText("label", "Test"), () -> {
+            // Notify the unhandled drag listener
+            mTarget.reportDropWindow(mWindow.mInputChannelToken, invalidXY, invalidXY);
+            mTarget.handleMotionEvent(false /* keepHandling */, invalidXY, invalidXY);
+            mTarget.reportDropResult(mWindow.mClient, false);
+            mTarget.onUnhandledDropCallback(true);
+            mToken = null;
+            try {
+                verify(listener, times(1)).onUnhandledDrop(any(), any());
+            } catch (RemoteException e) {
+                fail("Failed to verify unhandled drop: " + e);
+            }
+        });
+    }
+
+    @Test
+    public void testUnhandledDragListenerReceivesUnhandledDropOverNoValidWindow() {
+        assumeTrue(com.android.window.flags.Flags.delegateUnhandledDrags());
+
+        final IUnhandledDragListener listener = mock(IUnhandledDragListener.class);
+        doReturn(mock(Binder.class)).when(listener).asBinder();
+        mTarget.setUnhandledDragListener(listener);
+        final int invalidXY = 100_000;
+        startDrag(View.DRAG_FLAG_GLOBAL, ClipData.newPlainText("label", "Test"), () -> {
+            // Notify the unhandled drag listener
+            mTarget.reportDropWindow(mock(IBinder.class), invalidXY, invalidXY);
+            mTarget.handleMotionEvent(false /* keepHandling */, invalidXY, invalidXY);
+            mTarget.onUnhandledDropCallback(true);
+            mToken = null;
+            try {
+                verify(listener, times(1)).onUnhandledDrop(any(), any());
+            } catch (RemoteException e) {
+                fail("Failed to verify unhandled drop: " + e);
+            }
+        });
+    }
+
+    @Test
+    public void testUnhandledDragListenerCallbackTimeout() {
+        assumeTrue(com.android.window.flags.Flags.delegateUnhandledDrags());
+
+        final IUnhandledDragListener listener = mock(IUnhandledDragListener.class);
+        doReturn(mock(Binder.class)).when(listener).asBinder();
+        mTarget.setUnhandledDragListener(listener);
+        final int invalidXY = 100_000;
+        startDrag(View.DRAG_FLAG_GLOBAL, ClipData.newPlainText("label", "Test"), () -> {
+            // Notify the unhandled drag listener
+            mTarget.reportDropWindow(mock(IBinder.class), invalidXY, invalidXY);
+            mTarget.handleMotionEvent(false /* keepHandling */, invalidXY, invalidXY);
+
+            // Verify that the unhandled drop listener callback timeout has been scheduled
+            final Handler handler = mTarget.getHandler();
+            assertTrue(handler.hasMessages(MSG_UNHANDLED_DROP_LISTENER_TIMEOUT));
+
+            // Force trigger the timeout and verify that it actually cleans up the drag & timeout
+            handler.handleMessage(Message.obtain(handler, MSG_UNHANDLED_DROP_LISTENER_TIMEOUT));
+            assertFalse(handler.hasMessages(MSG_UNHANDLED_DROP_LISTENER_TIMEOUT));
+            assertFalse(mTarget.dragDropActiveLocked());
+            mToken = null;
+        });
+    }
+
     private void doDragAndDrop(int flags, ClipData data, float dropX, float dropY) {
         startDrag(flags, data, () -> {
             mTarget.reportDropWindow(mWindow.mInputChannelToken, dropX, dropY);
-            mTarget.handleMotionEvent(false, dropX, dropY);
+            mTarget.handleMotionEvent(false /* keepHandling */, dropX, dropY);
             mToken = mWindow.mClient.asBinder();
         });
     }
 
+    /**
+     * Starts a drag with the given parameters, calls Runnable `r` after drag is started.
+     */
     private void startDrag(int flag, ClipData data, Runnable r) {
         final SurfaceSession appSession = new SurfaceSession();
         try {