Merge "Adding feature flag to enable new external proximity sensor API" into main
diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java
index 7b45600..7a6c292 100644
--- a/core/java/android/view/ViewRootImpl.java
+++ b/core/java/android/view/ViewRootImpl.java
@@ -130,7 +130,6 @@
 import android.graphics.HardwareRenderer;
 import android.graphics.HardwareRenderer.FrameDrawingCallback;
 import android.graphics.HardwareRendererObserver;
-import android.graphics.Insets;
 import android.graphics.Matrix;
 import android.graphics.Paint;
 import android.graphics.PixelFormat;
@@ -206,7 +205,6 @@
 import android.view.accessibility.IAccessibilityInteractionConnectionCallback;
 import android.view.animation.AccelerateDecelerateInterpolator;
 import android.view.animation.Interpolator;
-import android.view.autofill.AutofillId;
 import android.view.autofill.AutofillManager;
 import android.view.contentcapture.ContentCaptureManager;
 import android.view.contentcapture.ContentCaptureSession;
@@ -4029,56 +4027,20 @@
     }
 
     private void notifyContentCaptureEvents() {
-        try {
-            if (!isContentCaptureEnabled()) {
-                if (DEBUG_CONTENT_CAPTURE) {
-                    Log.d(mTag, "notifyContentCaptureEvents while disabled");
-                }
-                mAttachInfo.mContentCaptureEvents = null;
-                return;
-            }
-            if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) {
-                Trace.traceBegin(Trace.TRACE_TAG_VIEW, "notifyContentCaptureEvents");
-            }
-            MainContentCaptureSession mainSession = mAttachInfo.mContentCaptureManager
-                    .getMainContentCaptureSession();
-            for (int i = 0; i < mAttachInfo.mContentCaptureEvents.size(); i++) {
-                int sessionId = mAttachInfo.mContentCaptureEvents.keyAt(i);
-                mainSession.notifyViewTreeEvent(sessionId, /* started= */ true);
-                ArrayList<Object> events = mAttachInfo.mContentCaptureEvents
-                        .valueAt(i);
-                for_each_event: for (int j = 0; j < events.size(); j++) {
-                    Object event = events.get(j);
-                    if (event instanceof AutofillId) {
-                        mainSession.notifyViewDisappeared(sessionId, (AutofillId) event);
-                    } else if (event instanceof View) {
-                        View view = (View) event;
-                        ContentCaptureSession session = view.getContentCaptureSession();
-                        if (session == null) {
-                            Log.w(mTag, "no content capture session on view: " + view);
-                            continue for_each_event;
-                        }
-                        int actualId = session.getId();
-                        if (actualId != sessionId) {
-                            Log.w(mTag, "content capture session mismatch for view (" + view
-                                    + "): was " + sessionId + " before, it's " + actualId + " now");
-                            continue for_each_event;
-                        }
-                        ViewStructure structure = session.newViewStructure(view);
-                        view.onProvideContentCaptureStructure(structure, /* flags= */ 0);
-                        session.notifyViewAppeared(structure);
-                    } else if (event instanceof Insets) {
-                        mainSession.notifyViewInsetsChanged(sessionId, (Insets) event);
-                    } else {
-                        Log.w(mTag, "invalid content capture event: " + event);
-                    }
-                }
-                mainSession.notifyViewTreeEvent(sessionId, /* started= */ false);
+        if (!isContentCaptureEnabled()) {
+            if (DEBUG_CONTENT_CAPTURE) {
+                Log.d(mTag, "notifyContentCaptureEvents while disabled");
             }
             mAttachInfo.mContentCaptureEvents = null;
-        } finally {
-            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
+            return;
         }
+
+        final ContentCaptureManager manager = mAttachInfo.mContentCaptureManager;
+        if (manager != null && mAttachInfo.mContentCaptureEvents != null) {
+            final MainContentCaptureSession session = manager.getMainContentCaptureSession();
+            session.notifyContentCaptureEvents(mAttachInfo.mContentCaptureEvents);
+        }
+        mAttachInfo.mContentCaptureEvents = null;
     }
 
     private void notifyHolderSurfaceDestroyed() {
diff --git a/core/java/android/view/contentcapture/ContentCaptureManager.java b/core/java/android/view/contentcapture/ContentCaptureManager.java
index 5a058ff..a829747 100644
--- a/core/java/android/view/contentcapture/ContentCaptureManager.java
+++ b/core/java/android/view/contentcapture/ContentCaptureManager.java
@@ -18,6 +18,7 @@
 import static android.view.contentcapture.ContentCaptureHelper.sDebug;
 import static android.view.contentcapture.ContentCaptureHelper.sVerbose;
 import static android.view.contentcapture.ContentCaptureHelper.toSet;
+import static android.view.contentcapture.flags.Flags.runOnBackgroundThreadEnabled;
 
 import android.annotation.CallbackExecutor;
 import android.annotation.IntDef;
@@ -52,6 +53,7 @@
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.os.BackgroundThread;
 import com.android.internal.util.RingBuffer;
 import com.android.internal.util.SyncResultReceiver;
 
@@ -495,10 +497,9 @@
     @GuardedBy("mLock")
     private int mFlags;
 
-    // TODO(b/119220549): use UI Thread directly (as calls are one-way) or a shared thread / handler
-    // held at the Application level
-    @NonNull
-    private final Handler mHandler;
+    @Nullable
+    @GuardedBy("mLock")
+    private Handler mHandler;
 
     @GuardedBy("mLock")
     private MainContentCaptureSession mMainSession;
@@ -562,11 +563,6 @@
 
         if (sVerbose) Log.v(TAG, "Constructor for " + context.getPackageName());
 
-        // TODO(b/119220549): we might not even need a handler, as the IPCs are oneway. But if we
-        // do, then we should optimize it to run the tests after the Choreographer finishes the most
-        // important steps of the frame.
-        mHandler = Handler.createAsync(Looper.getMainLooper());
-
         mDataShareAdapterResourceManager = new LocalDataShareAdapterResourceManager();
 
         if (mOptions.contentProtectionOptions.enableReceiver
@@ -594,13 +590,27 @@
     public MainContentCaptureSession getMainContentCaptureSession() {
         synchronized (mLock) {
             if (mMainSession == null) {
-                mMainSession = new MainContentCaptureSession(mContext, this, mHandler, mService);
+                mMainSession = new MainContentCaptureSession(
+                        mContext, this, prepareContentCaptureHandler(), mService);
                 if (sVerbose) Log.v(TAG, "getMainContentCaptureSession(): created " + mMainSession);
             }
             return mMainSession;
         }
     }
 
+    @NonNull
+    @GuardedBy("mLock")
+    private Handler prepareContentCaptureHandler() {
+        if (mHandler == null) {
+            if (runOnBackgroundThreadEnabled()) {
+                mHandler = BackgroundThread.getHandler();
+            } else {
+                mHandler = Handler.createAsync(Looper.getMainLooper());
+            }
+        }
+        return mHandler;
+    }
+
     /** @hide */
     @UiThread
     public void onActivityCreated(@NonNull IBinder applicationToken,
diff --git a/core/java/android/view/contentcapture/MainContentCaptureSession.java b/core/java/android/view/contentcapture/MainContentCaptureSession.java
index d9b0f80..542c783c 100644
--- a/core/java/android/view/contentcapture/MainContentCaptureSession.java
+++ b/core/java/android/view/contentcapture/MainContentCaptureSession.java
@@ -34,7 +34,6 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.annotation.UiThread;
 import android.content.ComponentName;
 import android.content.pm.ParceledListSlice;
 import android.graphics.Insets;
@@ -50,7 +49,10 @@
 import android.text.TextUtils;
 import android.util.LocalLog;
 import android.util.Log;
+import android.util.SparseArray;
 import android.util.TimeUtils;
+import android.view.View;
+import android.view.ViewStructure;
 import android.view.autofill.AutofillId;
 import android.view.contentcapture.ViewNode.ViewStructureImpl;
 import android.view.contentprotection.ContentProtectionEventProcessor;
@@ -207,7 +209,8 @@
             } else {
                 binder = null;
             }
-            mainSession.mHandler.post(() -> mainSession.onSessionStarted(resultCode, binder));
+            mainSession.mHandler.post(() ->
+                    mainSession.onSessionStarted(resultCode, binder));
         }
     }
 
@@ -244,9 +247,14 @@
     /**
      * Starts this session.
      */
-    @UiThread
     void start(@NonNull IBinder token, @NonNull IBinder shareableActivityToken,
             @NonNull ComponentName component, int flags) {
+        runOnContentCaptureThread(() -> startImpl(token, shareableActivityToken, component, flags));
+    }
+
+    private void startImpl(@NonNull IBinder token, @NonNull IBinder shareableActivityToken,
+               @NonNull ComponentName component, int flags) {
+        checkOnContentCaptureThread();
         if (!isContentCaptureEnabled()) return;
 
         if (sVerbose) {
@@ -280,17 +288,15 @@
             Log.w(TAG, "Error starting session for " + component.flattenToShortString() + ": " + e);
         }
     }
-
     @Override
     void onDestroy() {
-        mHandler.removeMessages(MSG_FLUSH);
-        mHandler.post(() -> {
+        clearAndRunOnContentCaptureThread(() -> {
             try {
                 flush(FLUSH_REASON_SESSION_FINISHED);
             } finally {
                 destroySession();
             }
-        });
+        }, MSG_FLUSH);
     }
 
     /**
@@ -302,8 +308,8 @@
      * @hide
      */
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
-    @UiThread
     public void onSessionStarted(int resultCode, @Nullable IBinder binder) {
+        checkOnContentCaptureThread();
         if (binder != null) {
             mDirectServiceInterface = IContentCaptureDirectManager.Stub.asInterface(binder);
             mDirectServiceVulture = () -> {
@@ -347,13 +353,12 @@
 
     /** @hide */
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
-    @UiThread
     public void sendEvent(@NonNull ContentCaptureEvent event) {
         sendEvent(event, /* forceFlush= */ false);
     }
 
-    @UiThread
     private void sendEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) {
+        checkOnContentCaptureThread();
         final int eventType = event.getType();
         if (sVerbose) Log.v(TAG, "handleSendEvent(" + getDebugState() + "): " + event);
         if (!hasStarted() && eventType != ContentCaptureEvent.TYPE_SESSION_STARTED
@@ -396,15 +401,15 @@
         }
     }
 
-    @UiThread
     private void sendContentProtectionEvent(@NonNull ContentCaptureEvent event) {
+        checkOnContentCaptureThread();
         if (mContentProtectionEventProcessor != null) {
             mContentProtectionEventProcessor.processEvent(event);
         }
     }
 
-    @UiThread
     private void sendContentCaptureEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) {
+        checkOnContentCaptureThread();
         final int eventType = event.getType();
         final int maxBufferSize = mManager.mOptions.maxBufferSize;
         if (mEvents == null) {
@@ -538,13 +543,13 @@
         flush(flushReason);
     }
 
-    @UiThread
     private boolean hasStarted() {
+        checkOnContentCaptureThread();
         return mState != UNKNOWN_STATE;
     }
 
-    @UiThread
     private void scheduleFlush(@FlushReason int reason, boolean checkExisting) {
+        checkOnContentCaptureThread();
         if (sVerbose) {
             Log.v(TAG, "handleScheduleFlush(" + getDebugState(reason)
                     + ", checkExisting=" + checkExisting);
@@ -588,8 +593,8 @@
         mHandler.postDelayed(() -> flushIfNeeded(reason), MSG_FLUSH, flushFrequencyMs);
     }
 
-    @UiThread
     private void flushIfNeeded(@FlushReason int reason) {
+        checkOnContentCaptureThread();
         if (mEvents == null || mEvents.isEmpty()) {
             if (sVerbose) Log.v(TAG, "Nothing to flush");
             return;
@@ -600,8 +605,12 @@
     /** @hide */
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
     @Override
-    @UiThread
     public void flush(@FlushReason int reason) {
+        runOnContentCaptureThread(() -> flushImpl(reason));
+    }
+
+    private void flushImpl(@FlushReason int reason) {
+        checkOnContentCaptureThread();
         if (mEvents == null || mEvents.size() == 0) {
             if (sVerbose) {
                 Log.v(TAG, "Don't flush for empty event buffer.");
@@ -669,8 +678,8 @@
      * Resets the buffer and return a {@link ParceledListSlice} with the previous events.
      */
     @NonNull
-    @UiThread
     private ParceledListSlice<ContentCaptureEvent> clearEvents() {
+        checkOnContentCaptureThread();
         // NOTE: we must save a reference to the current mEvents and then set it to to null,
         // otherwise clearing it would clear it in the receiving side if the service is also local.
         if (mEvents == null) {
@@ -684,8 +693,8 @@
 
     /** hide */
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
-    @UiThread
     public void destroySession() {
+        checkOnContentCaptureThread();
         if (sDebug) {
             Log.d(TAG, "Destroying session (ctx=" + mContext + ", id=" + mId + ") with "
                     + (mEvents == null ? 0 : mEvents.size()) + " event(s) for "
@@ -710,8 +719,8 @@
     // clearings out.
     /** @hide */
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
-    @UiThread
     public void resetSession(int newState) {
+        checkOnContentCaptureThread();
         if (sVerbose) {
             Log.v(TAG, "handleResetSession(" + getActivityName() + "): from "
                     + getStateAsString(mState) + " to " + getStateAsString(newState));
@@ -794,24 +803,26 @@
     // change should also get get rid of the "internalNotifyXXXX" methods above
     void notifyChildSessionStarted(int parentSessionId, int childSessionId,
             @NonNull ContentCaptureContext clientContext) {
-        mHandler.post(() -> sendEvent(new ContentCaptureEvent(childSessionId, TYPE_SESSION_STARTED)
+        runOnContentCaptureThread(
+                () -> sendEvent(new ContentCaptureEvent(childSessionId, TYPE_SESSION_STARTED)
                 .setParentSessionId(parentSessionId).setClientContext(clientContext),
                 FORCE_FLUSH));
     }
 
     void notifyChildSessionFinished(int parentSessionId, int childSessionId) {
-        mHandler.post(() -> sendEvent(new ContentCaptureEvent(childSessionId, TYPE_SESSION_FINISHED)
+        runOnContentCaptureThread(
+                () -> sendEvent(new ContentCaptureEvent(childSessionId, TYPE_SESSION_FINISHED)
                 .setParentSessionId(parentSessionId), FORCE_FLUSH));
     }
 
     void notifyViewAppeared(int sessionId, @NonNull ViewStructureImpl node) {
-        mHandler.post(() -> sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_APPEARED)
+        runOnContentCaptureThread(() ->
+                sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_APPEARED)
                 .setViewNode(node.mNode)));
     }
 
-    /** Public because is also used by ViewRootImpl */
-    public void notifyViewDisappeared(int sessionId, @NonNull AutofillId id) {
-        mHandler.post(() -> sendEvent(
+    void notifyViewDisappeared(int sessionId, @NonNull AutofillId id) {
+        runOnContentCaptureThread(() -> sendEvent(
                 new ContentCaptureEvent(sessionId, TYPE_VIEW_DISAPPEARED).setAutofillId(id)));
     }
 
@@ -836,52 +847,102 @@
 
         final int startIndex = Selection.getSelectionStart(text);
         final int endIndex = Selection.getSelectionEnd(text);
-        mHandler.post(() -> sendEvent(
+        runOnContentCaptureThread(() -> sendEvent(
                 new ContentCaptureEvent(sessionId, TYPE_VIEW_TEXT_CHANGED)
                         .setAutofillId(id).setText(eventText)
                         .setComposingIndex(composingStart, composingEnd)
                         .setSelectionIndex(startIndex, endIndex)));
     }
 
-    /** Public because is also used by ViewRootImpl */
-    public void notifyViewInsetsChanged(int sessionId, @NonNull Insets viewInsets) {
-        mHandler.post(() -> sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_INSETS_CHANGED)
+    void notifyViewInsetsChanged(int sessionId, @NonNull Insets viewInsets) {
+        runOnContentCaptureThread(() ->
+                sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_INSETS_CHANGED)
                 .setInsets(viewInsets)));
     }
 
-    /** Public because is also used by ViewRootImpl */
-    public void notifyViewTreeEvent(int sessionId, boolean started) {
+    void notifyViewTreeEvent(int sessionId, boolean started) {
         final int type = started ? TYPE_VIEW_TREE_APPEARING : TYPE_VIEW_TREE_APPEARED;
         final boolean disableFlush = mManager.getFlushViewTreeAppearingEventDisabled();
 
-        mHandler.post(() -> sendEvent(
+        runOnContentCaptureThread(() -> sendEvent(
                 new ContentCaptureEvent(sessionId, type),
                 disableFlush ? !started : FORCE_FLUSH));
     }
 
     void notifySessionResumed(int sessionId) {
-        mHandler.post(() -> sendEvent(
+        runOnContentCaptureThread(() -> sendEvent(
                 new ContentCaptureEvent(sessionId, TYPE_SESSION_RESUMED), FORCE_FLUSH));
     }
 
     void notifySessionPaused(int sessionId) {
-        mHandler.post(() -> sendEvent(
+        runOnContentCaptureThread(() -> sendEvent(
                 new ContentCaptureEvent(sessionId, TYPE_SESSION_PAUSED), FORCE_FLUSH));
     }
 
     void notifyContextUpdated(int sessionId, @Nullable ContentCaptureContext context) {
-        mHandler.post(() -> sendEvent(new ContentCaptureEvent(sessionId, TYPE_CONTEXT_UPDATED)
+        runOnContentCaptureThread(() ->
+                sendEvent(new ContentCaptureEvent(sessionId, TYPE_CONTEXT_UPDATED)
                 .setClientContext(context), FORCE_FLUSH));
     }
 
     /** public because is also used by ViewRootImpl */
     public void notifyWindowBoundsChanged(int sessionId, @NonNull Rect bounds) {
-        mHandler.post(() -> sendEvent(
+        runOnContentCaptureThread(() -> sendEvent(
                 new ContentCaptureEvent(sessionId, TYPE_WINDOW_BOUNDS_CHANGED)
                 .setBounds(bounds)
         ));
     }
 
+    /** public because is also used by ViewRootImpl */
+    public void notifyContentCaptureEvents(
+            @NonNull SparseArray<ArrayList<Object>> contentCaptureEvents) {
+        runOnContentCaptureThread(() -> notifyContentCaptureEventsImpl(contentCaptureEvents));
+    }
+
+    private void notifyContentCaptureEventsImpl(
+            @NonNull SparseArray<ArrayList<Object>> contentCaptureEvents) {
+        checkOnContentCaptureThread();
+        try {
+            if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) {
+                Trace.traceBegin(Trace.TRACE_TAG_VIEW, "notifyContentCaptureEvents");
+            }
+            for (int i = 0; i < contentCaptureEvents.size(); i++) {
+                int sessionId = contentCaptureEvents.keyAt(i);
+                notifyViewTreeEvent(sessionId, /* started= */ true);
+                ArrayList<Object> events = contentCaptureEvents.valueAt(i);
+                for_each_event: for (int j = 0; j < events.size(); j++) {
+                    Object event = events.get(j);
+                    if (event instanceof AutofillId) {
+                        notifyViewDisappeared(sessionId, (AutofillId) event);
+                    } else if (event instanceof View) {
+                        View view = (View) event;
+                        ContentCaptureSession session = view.getContentCaptureSession();
+                        if (session == null) {
+                            Log.w(TAG, "no content capture session on view: " + view);
+                            continue for_each_event;
+                        }
+                        int actualId = session.getId();
+                        if (actualId != sessionId) {
+                            Log.w(TAG, "content capture session mismatch for view (" + view
+                                    + "): was " + sessionId + " before, it's " + actualId + " now");
+                            continue for_each_event;
+                        }
+                        ViewStructure structure = session.newViewStructure(view);
+                        view.onProvideContentCaptureStructure(structure, /* flags= */ 0);
+                        session.notifyViewAppeared(structure);
+                    } else if (event instanceof Insets) {
+                        notifyViewInsetsChanged(sessionId, (Insets) event);
+                    } else {
+                        Log.w(TAG, "invalid content capture event: " + event);
+                    }
+                }
+                notifyViewTreeEvent(sessionId, /* started= */ false);
+            }
+        } finally {
+            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
+        }
+    }
+
     @Override
     void dump(@NonNull String prefix, @NonNull PrintWriter pw) {
         super.dump(prefix, pw);
@@ -960,17 +1021,14 @@
         return getDebugState() + ", reason=" + getFlushReasonAsString(reason);
     }
 
-    @UiThread
     private boolean isContentProtectionReceiverEnabled() {
         return mManager.mOptions.contentProtectionOptions.enableReceiver;
     }
 
-    @UiThread
     private boolean isContentCaptureReceiverEnabled() {
         return mManager.mOptions.enableReceiver;
     }
 
-    @UiThread
     private boolean isContentProtectionEnabled() {
         // Should not be possible for mComponentName to be null here but check anyway
         // Should not be possible for groups to be empty if receiver is enabled but check anyway
@@ -980,4 +1038,42 @@
                 && (!mManager.mOptions.contentProtectionOptions.requiredGroups.isEmpty()
                         || !mManager.mOptions.contentProtectionOptions.optionalGroups.isEmpty());
     }
+
+    /**
+     * Checks that the current work is running on the assigned thread from {@code mHandler}.
+     *
+     * <p>It is not guaranteed that the callers always invoke function from a single thread.
+     * Therefore, accessing internal properties in {@link MainContentCaptureSession} should
+     * always delegate to the assigned thread from {@code mHandler} for synchronization.</p>
+     */
+    private void checkOnContentCaptureThread() {
+        // TODO(b/309411951): Add metrics to track the issue instead.
+        final boolean onContentCaptureThread = mHandler.getLooper().isCurrentThread();
+        if (!onContentCaptureThread) {
+            Log.e(TAG, "MainContentCaptureSession running on " + Thread.currentThread());
+        }
+    }
+
+    /**
+     * Ensures that {@code r} will be running on the assigned thread.
+     *
+     * <p>This is to prevent unnecessary delegation to Handler that results in fragmented runnable.
+     * </p>
+     */
+    private void runOnContentCaptureThread(@NonNull Runnable r) {
+        if (!mHandler.getLooper().isCurrentThread()) {
+            mHandler.post(r);
+        } else {
+            r.run();
+        }
+    }
+
+    private void clearAndRunOnContentCaptureThread(@NonNull Runnable r, int what) {
+        if (!mHandler.getLooper().isCurrentThread()) {
+            mHandler.removeMessages(what);
+            mHandler.post(r);
+        } else {
+            r.run();
+        }
+    }
 }
diff --git a/core/java/android/view/contentprotection/ContentProtectionEventProcessor.java b/core/java/android/view/contentprotection/ContentProtectionEventProcessor.java
index aaf90bd..858401a9 100644
--- a/core/java/android/view/contentprotection/ContentProtectionEventProcessor.java
+++ b/core/java/android/view/contentprotection/ContentProtectionEventProcessor.java
@@ -18,7 +18,6 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.annotation.UiThread;
 import android.content.ContentCaptureOptions;
 import android.content.pm.ParceledListSlice;
 import android.os.Handler;
@@ -102,7 +101,6 @@
     }
 
     /** Main entry point for {@link ContentCaptureEvent} processing. */
-    @UiThread
     public void processEvent(@NonNull ContentCaptureEvent event) {
         if (EVENT_TYPES_TO_STORE.contains(event.getType())) {
             storeEvent(event);
@@ -112,7 +110,6 @@
         }
     }
 
-    @UiThread
     private void storeEvent(@NonNull ContentCaptureEvent event) {
         // Ensure receiver gets the package name which might not be set
         ViewNode viewNode = (event.getViewNode() != null) ? event.getViewNode() : new ViewNode();
@@ -121,7 +118,6 @@
         mEventBuffer.append(event);
     }
 
-    @UiThread
     private void processViewAppearedEvent(@NonNull ContentCaptureEvent event) {
         ViewNode viewNode = event.getViewNode();
         String eventText = ContentProtectionUtils.getEventTextLower(event);
@@ -154,7 +150,6 @@
         }
     }
 
-    @UiThread
     private void loginDetected() {
         if (mLastFlushTime == null
                 || Instant.now().isAfter(mLastFlushTime.plus(MIN_DURATION_BETWEEN_FLUSHING))) {
@@ -163,13 +158,11 @@
         resetLoginFlags();
     }
 
-    @UiThread
     private void resetLoginFlags() {
         mGroupsAll.forEach(group -> group.mFound = false);
         mAnyGroupFound = false;
     }
 
-    @UiThread
     private void maybeResetLoginFlags() {
         if (mAnyGroupFound) {
             if (mResetLoginRemainingEventsToProcess <= 0) {
@@ -183,7 +176,6 @@
         }
     }
 
-    @UiThread
     private void flush() {
         mLastFlushTime = Instant.now();
 
@@ -192,7 +184,6 @@
         mHandler.post(() -> handlerOnLoginDetected(events));
     }
 
-    @UiThread
     @NonNull
     private ParceledListSlice<ContentCaptureEvent> clearEvents() {
         List<ContentCaptureEvent> events = Arrays.asList(mEventBuffer.toArray());
diff --git a/core/tests/coretests/src/android/view/contentcapture/MainContentCaptureSessionTest.java b/core/tests/coretests/src/android/view/contentcapture/MainContentCaptureSessionTest.java
index d47d789..1cdcb37 100644
--- a/core/tests/coretests/src/android/view/contentcapture/MainContentCaptureSessionTest.java
+++ b/core/tests/coretests/src/android/view/contentcapture/MainContentCaptureSessionTest.java
@@ -17,11 +17,15 @@
 package android.view.contentcapture;
 
 import static android.view.contentcapture.ContentCaptureEvent.TYPE_SESSION_STARTED;
+import static android.view.contentcapture.ContentCaptureSession.FLUSH_REASON_VIEW_TREE_APPEARED;
+import static android.view.contentcapture.ContentCaptureSession.FLUSH_REASON_VIEW_TREE_APPEARING;
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyZeroInteractions;
 
@@ -29,14 +33,20 @@
 import android.content.ContentCaptureOptions;
 import android.content.Context;
 import android.content.pm.ParceledListSlice;
+import android.graphics.Insets;
 import android.os.Handler;
-import android.os.Looper;
+import android.os.RemoteException;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.autofill.AutofillId;
 import android.view.contentprotection.ContentProtectionEventProcessor;
 
 import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -56,8 +66,9 @@
  * <p>Run with: {@code atest
  * FrameworksCoreTests:android.view.contentcapture.MainContentCaptureSessionTest}
  */
-@RunWith(AndroidJUnit4.class)
+@RunWith(AndroidTestingRunner.class)
 @SmallTest
+@TestableLooper.RunWithLooper
 public class MainContentCaptureSessionTest {
 
     private static final int BUFFER_SIZE = 100;
@@ -75,6 +86,8 @@
     private static final ContentCaptureManager.StrippedContext sStrippedContext =
             new ContentCaptureManager.StrippedContext(sContext);
 
+    private TestableLooper mTestableLooper;
+
     @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
 
     @Mock private IContentCaptureManager mMockSystemServerInterface;
@@ -83,12 +96,18 @@
 
     @Mock private IContentCaptureDirectManager mMockContentCaptureDirectManager;
 
+    @Before
+    public void setup() {
+        mTestableLooper = TestableLooper.get(this);
+    }
+
     @Test
     public void onSessionStarted_contentProtectionEnabled_processorCreated() {
         MainContentCaptureSession session = createSession();
         assertThat(session.mContentProtectionEventProcessor).isNull();
 
         session.onSessionStarted(/* resultCode= */ 0, /* binder= */ null);
+        mTestableLooper.processAllMessages();
 
         assertThat(session.mContentProtectionEventProcessor).isNotNull();
     }
@@ -102,6 +121,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.onSessionStarted(/* resultCode= */ 0, /* binder= */ null);
+        mTestableLooper.processAllMessages();
 
         assertThat(session.mContentProtectionEventProcessor).isNull();
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
@@ -122,6 +142,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.onSessionStarted(/* resultCode= */ 0, /* binder= */ null);
+        mTestableLooper.processAllMessages();
 
         assertThat(session.mContentProtectionEventProcessor).isNull();
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
@@ -142,6 +163,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.onSessionStarted(/* resultCode= */ 0, /* binder= */ null);
+        mTestableLooper.processAllMessages();
 
         assertThat(session.mContentProtectionEventProcessor).isNull();
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
@@ -153,6 +175,7 @@
         session.mComponentName = null;
 
         session.onSessionStarted(/* resultCode= */ 0, /* binder= */ null);
+        mTestableLooper.processAllMessages();
 
         assertThat(session.mContentProtectionEventProcessor).isNull();
     }
@@ -166,6 +189,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.sendEvent(EVENT);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         assertThat(session.mEvents).isNull();
@@ -180,6 +204,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.sendEvent(EVENT);
+        mTestableLooper.processAllMessages();
 
         verify(mMockContentProtectionEventProcessor).processEvent(EVENT);
         assertThat(session.mEvents).isNull();
@@ -194,6 +219,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.sendEvent(EVENT);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         assertThat(session.mEvents).isNotNull();
@@ -206,6 +232,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.sendEvent(EVENT);
+        mTestableLooper.processAllMessages();
 
         verify(mMockContentProtectionEventProcessor).processEvent(EVENT);
         assertThat(session.mEvents).isNotNull();
@@ -220,6 +247,7 @@
                         /* enableContentProtectionReceiver= */ true);
 
         session.sendEvent(EVENT);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         assertThat(session.mEvents).isNull();
@@ -236,6 +264,7 @@
         session.mDirectServiceInterface = mMockContentCaptureDirectManager;
 
         session.flush(REASON);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         verifyZeroInteractions(mMockContentCaptureDirectManager);
@@ -252,6 +281,7 @@
         session.mDirectServiceInterface = mMockContentCaptureDirectManager;
 
         session.flush(REASON);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         verifyZeroInteractions(mMockContentCaptureDirectManager);
@@ -269,6 +299,7 @@
         session.mDirectServiceInterface = mMockContentCaptureDirectManager;
 
         session.flush(REASON);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         assertThat(session.mEvents).isEmpty();
@@ -286,6 +317,7 @@
         session.mDirectServiceInterface = mMockContentCaptureDirectManager;
 
         session.flush(REASON);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
         assertThat(session.mEvents).isEmpty();
@@ -298,6 +330,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.destroySession();
+        mTestableLooper.processAllMessages();
 
         verify(mMockSystemServerInterface).finishSession(anyInt());
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
@@ -311,6 +344,7 @@
         session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
 
         session.resetSession(/* newState= */ 0);
+        mTestableLooper.processAllMessages();
 
         verifyZeroInteractions(mMockSystemServerInterface);
         verifyZeroInteractions(mMockContentProtectionEventProcessor);
@@ -318,6 +352,111 @@
         assertThat(session.mContentProtectionEventProcessor).isNull();
     }
 
+    @Test
+    @SuppressWarnings("GuardedBy")
+    public void notifyContentCaptureEvents_notStarted_ContentCaptureDisabled_ProtectionDisabled() {
+        ContentCaptureOptions options =
+                createOptions(
+                        /* enableContentCaptureReceiver= */ false,
+                        /* enableContentProtectionReceiver= */ false);
+        MainContentCaptureSession session = createSession(options);
+
+        notifyContentCaptureEvents(session);
+        mTestableLooper.processAllMessages();
+
+        verifyZeroInteractions(mMockContentCaptureDirectManager);
+        verifyZeroInteractions(mMockContentProtectionEventProcessor);
+        assertThat(session.mEvents).isNull();
+    }
+
+    @Test
+    @SuppressWarnings("GuardedBy")
+    public void notifyContentCaptureEvents_started_ContentCaptureDisabled_ProtectionDisabled() {
+        ContentCaptureOptions options =
+                createOptions(
+                        /* enableContentCaptureReceiver= */ false,
+                        /* enableContentProtectionReceiver= */ false);
+        MainContentCaptureSession session = createSession(options);
+
+        session.onSessionStarted(0x2, null);
+        notifyContentCaptureEvents(session);
+        mTestableLooper.processAllMessages();
+
+        verifyZeroInteractions(mMockContentCaptureDirectManager);
+        verifyZeroInteractions(mMockContentProtectionEventProcessor);
+        assertThat(session.mEvents).isNull();
+    }
+
+    @Test
+    @SuppressWarnings("GuardedBy")
+    public void notifyContentCaptureEvents_notStarted_ContentCaptureEnabled_ProtectionEnabled() {
+        ContentCaptureOptions options =
+                createOptions(
+                        /* enableContentCaptureReceiver= */ true,
+                        /* enableContentProtectionReceiver= */ true);
+        MainContentCaptureSession session = createSession(options);
+        session.mDirectServiceInterface = mMockContentCaptureDirectManager;
+        session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
+
+        notifyContentCaptureEvents(session);
+        mTestableLooper.processAllMessages();
+
+        verifyZeroInteractions(mMockContentCaptureDirectManager);
+        verifyZeroInteractions(mMockContentProtectionEventProcessor);
+        assertThat(session.mEvents).isNull();
+    }
+
+    @Test
+    @SuppressWarnings("GuardedBy")
+    public void notifyContentCaptureEvents_started_ContentCaptureEnabled_ProtectionEnabled()
+            throws RemoteException {
+        ContentCaptureOptions options =
+                createOptions(
+                        /* enableContentCaptureReceiver= */ true,
+                        /* enableContentProtectionReceiver= */ true);
+        MainContentCaptureSession session = createSession(options);
+        session.mDirectServiceInterface = mMockContentCaptureDirectManager;
+
+        session.onSessionStarted(0x2, null);
+        // Override the processor for interaction verification.
+        session.mContentProtectionEventProcessor = mMockContentProtectionEventProcessor;
+        notifyContentCaptureEvents(session);
+        mTestableLooper.processAllMessages();
+
+        // Force flush will happen twice.
+        verify(mMockContentCaptureDirectManager, times(1))
+                .sendEvents(any(), eq(FLUSH_REASON_VIEW_TREE_APPEARING), any());
+        verify(mMockContentCaptureDirectManager, times(1))
+                .sendEvents(any(), eq(FLUSH_REASON_VIEW_TREE_APPEARED), any());
+        // Other than the five view events, there will be two additional tree appearing events.
+        verify(mMockContentProtectionEventProcessor, times(7)).processEvent(any());
+        assertThat(session.mEvents).isEmpty();
+    }
+
+    /** Simulates the regular content capture events sequence. */
+    private void notifyContentCaptureEvents(final MainContentCaptureSession session) {
+        final ArrayList<Object> events = new ArrayList<>(
+                List.of(
+                        prepareView(session),
+                        prepareView(session),
+                        new AutofillId(0),
+                        prepareView(session),
+                        Insets.of(0, 0, 0, 0)
+                )
+        );
+
+        final SparseArray<ArrayList<Object>> contentCaptureEvents = new SparseArray<>();
+        contentCaptureEvents.set(session.getId(), events);
+
+        session.notifyContentCaptureEvents(contentCaptureEvents);
+    }
+
+    private View prepareView(final MainContentCaptureSession session) {
+        final View view = new View(sContext);
+        view.setContentCaptureSession(session);
+        return view;
+    }
+
     private static ContentCaptureOptions createOptions(
             boolean enableContentCaptureReceiver,
             ContentCaptureOptions.ContentProtectionOptions contentProtectionOptions) {
@@ -354,7 +493,7 @@
                 new MainContentCaptureSession(
                         sStrippedContext,
                         manager,
-                        new Handler(Looper.getMainLooper()),
+                        Handler.createAsync(mTestableLooper.getLooper()),
                         mMockSystemServerInterface);
         session.mComponentName = COMPONENT_NAME;
         return session;
diff --git a/services/core/java/com/android/server/devicestate/DeviceStateProvider.java b/services/core/java/com/android/server/devicestate/DeviceStateProvider.java
index af33de0..50ab3f8 100644
--- a/services/core/java/com/android/server/devicestate/DeviceStateProvider.java
+++ b/services/core/java/com/android/server/devicestate/DeviceStateProvider.java
@@ -63,13 +63,27 @@
      */
     int SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED = 5;
 
+    /**
+     * Indicating that the supported device states have changed because an external display was
+     * added.
+     */
+    int SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED = 6;
+
+    /**
+     * Indicating that the supported device states have changed because an external display was
+     * removed.
+     */
+    int SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_REMOVED = 7;
+
     @IntDef(prefix = { "SUPPORTED_DEVICE_STATES_CHANGED_" }, value = {
             SUPPORTED_DEVICE_STATES_CHANGED_DEFAULT,
             SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED,
             SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_NORMAL,
             SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_CRITICAL,
             SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_ENABLED,
-            SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED
+            SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED,
+            SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED,
+            SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_REMOVED
     })
     @Retention(RetentionPolicy.SOURCE)
     @interface SupportedStatesUpdatedReason {}
diff --git a/services/foldables/devicestateprovider/Android.bp b/services/foldables/devicestateprovider/Android.bp
index 34737ef..56daea7 100644
--- a/services/foldables/devicestateprovider/Android.bp
+++ b/services/foldables/devicestateprovider/Android.bp
@@ -5,9 +5,12 @@
 java_library {
     name: "foldable-device-state-provider",
     srcs: [
-        "src/**/*.java"
+        "src/**/*.java",
     ],
     libs: [
         "services",
     ],
+    static_libs: [
+        "device_state_flags_lib",
+    ],
 }
diff --git a/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java b/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java
index aea46d1..4c487a7 100644
--- a/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java
+++ b/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java
@@ -21,6 +21,7 @@
 import static android.hardware.devicestate.DeviceStateManager.MAXIMUM_DEVICE_STATE;
 import static android.hardware.devicestate.DeviceStateManager.MINIMUM_DEVICE_STATE;
 import static android.view.Display.DEFAULT_DISPLAY;
+import static android.view.Display.TYPE_EXTERNAL;
 
 import android.annotation.IntRange;
 import android.annotation.NonNull;
@@ -33,11 +34,14 @@
 import android.hardware.SensorEvent;
 import android.hardware.SensorEventListener;
 import android.hardware.SensorManager;
-import android.os.PowerManager;
 import android.hardware.display.DisplayManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.PowerManager;
 import android.os.Trace;
 import android.util.Slog;
 import android.util.SparseArray;
+import android.util.SparseBooleanArray;
 import android.view.Display;
 
 import com.android.internal.annotations.GuardedBy;
@@ -45,24 +49,26 @@
 import com.android.internal.util.Preconditions;
 import com.android.server.devicestate.DeviceState;
 import com.android.server.devicestate.DeviceStateProvider;
+import com.android.server.policy.feature.flags.FeatureFlags;
+import com.android.server.policy.feature.flags.FeatureFlagsImpl;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
 import java.util.function.BooleanSupplier;
-import java.util.function.Function;
+import java.util.function.Predicate;
 
 /**
  * Device state provider for foldable devices.
- *
+ * <p>
  * It is an implementation of {@link DeviceStateProvider} tailored specifically for
  * foldable devices and allows simple callback-based configuration with hall sensor
  * and hinge angle sensor values.
  */
 public final class FoldableDeviceStateProvider implements DeviceStateProvider,
         SensorEventListener, PowerManager.OnThermalStatusChangedListener,
-       DisplayManager.DisplayListener  {
+        DisplayManager.DisplayListener {
 
     private static final String TAG = "FoldableDeviceStateProvider";
     private static final boolean DEBUG = false;
@@ -77,9 +83,17 @@
     // are met for the device to be in the state.
     private final SparseArray<BooleanSupplier> mStateConditions = new SparseArray<>();
 
+    // Map of state identifier to a boolean supplier that returns true when the device state has all
+    // the conditions needed for availability.
+    private final SparseArray<BooleanSupplier> mStateAvailabilityConditions = new SparseArray<>();
+
+    @GuardedBy("mLock")
+    private final SparseBooleanArray mExternalDisplaysConnected = new SparseBooleanArray();
+
     private final Sensor mHingeAngleSensor;
     private final DisplayManager mDisplayManager;
     private final Sensor mHallSensor;
+    private static final Predicate<FoldableDeviceStateProvider> ALLOWED = p -> true;
 
     @Nullable
     @GuardedBy("mLock")
@@ -99,7 +113,23 @@
     @GuardedBy("mLock")
     private boolean mPowerSaveModeEnabled;
 
-    public FoldableDeviceStateProvider(@NonNull Context context,
+    private final boolean mIsDualDisplayBlockingEnabled;
+
+    public FoldableDeviceStateProvider(
+            @NonNull Context context,
+            @NonNull SensorManager sensorManager,
+            @NonNull Sensor hingeAngleSensor,
+            @NonNull Sensor hallSensor,
+            @NonNull DisplayManager displayManager,
+            @NonNull DeviceStateConfiguration[] deviceStateConfigurations) {
+        this(new FeatureFlagsImpl(), context, sensorManager, hingeAngleSensor, hallSensor,
+                displayManager, deviceStateConfigurations);
+    }
+
+    @VisibleForTesting
+    public FoldableDeviceStateProvider(
+            @NonNull FeatureFlags featureFlags,
+            @NonNull Context context,
             @NonNull SensorManager sensorManager,
             @NonNull Sensor hingeAngleSensor,
             @NonNull Sensor hallSensor,
@@ -112,6 +142,7 @@
         mHingeAngleSensor = hingeAngleSensor;
         mHallSensor = hallSensor;
         mDisplayManager = displayManager;
+        mIsDualDisplayBlockingEnabled = featureFlags.enableDualDisplayBlocking();
 
         sensorManager.registerListener(this, mHingeAngleSensor, SENSOR_DELAY_FASTEST);
         sensorManager.registerListener(this, mHallSensor, SENSOR_DELAY_FASTEST);
@@ -121,20 +152,15 @@
             final DeviceStateConfiguration configuration = deviceStateConfigurations[i];
             mOrderedStates[i] = configuration.mDeviceState;
 
-            if (mStateConditions.get(configuration.mDeviceState.getIdentifier()) != null) {
-                throw new IllegalArgumentException("Device state configurations must have unique"
-                        + " device state identifiers, found duplicated identifier: " +
-                        configuration.mDeviceState.getIdentifier());
-            }
-
-            mStateConditions.put(configuration.mDeviceState.getIdentifier(), () ->
-                    configuration.mPredicate.apply(this));
+            assertUniqueDeviceStateIdentifier(configuration);
+            initialiseStateConditions(configuration);
+            initialiseStateAvailabilityConditions(configuration);
         }
 
+        Handler handler = new Handler(Looper.getMainLooper());
         mDisplayManager.registerDisplayListener(
                 /* listener = */ this,
-                /* handler= */ null,
-                /* eventsMask= */ DisplayManager.EVENT_FLAG_DISPLAY_CHANGED);
+                /* handler= */ handler);
 
         Arrays.sort(mOrderedStates, Comparator.comparingInt(DeviceState::getIdentifier));
 
@@ -167,6 +193,24 @@
         }
     }
 
+    private void assertUniqueDeviceStateIdentifier(DeviceStateConfiguration configuration) {
+        if (mStateConditions.get(configuration.mDeviceState.getIdentifier()) != null) {
+            throw new IllegalArgumentException("Device state configurations must have unique"
+                    + " device state identifiers, found duplicated identifier: "
+                    + configuration.mDeviceState.getIdentifier());
+        }
+    }
+
+    private void initialiseStateConditions(DeviceStateConfiguration configuration) {
+        mStateConditions.put(configuration.mDeviceState.getIdentifier(), () ->
+                configuration.mActiveStatePredicate.test(this));
+    }
+
+    private void initialiseStateAvailabilityConditions(DeviceStateConfiguration configuration) {
+            mStateAvailabilityConditions.put(configuration.mDeviceState.getIdentifier(), () ->
+                    configuration.mAvailabilityPredicate.test(this));
+    }
+
     @Override
     public void setListener(Listener listener) {
         synchronized (mLock) {
@@ -189,16 +233,9 @@
             }
             listener = mListener;
             for (DeviceState deviceState : mOrderedStates) {
-                if (isThermalStatusCriticalOrAbove(mThermalStatus)
-                        && deviceState.hasFlag(
-                        DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL)) {
-                    continue;
+                if (isStateSupported(deviceState)) {
+                    supportedStates.add(deviceState);
                 }
-                if (mPowerSaveModeEnabled && deviceState.hasFlag(
-                        DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE)) {
-                    continue;
-                }
-                supportedStates.add(deviceState);
             }
         }
 
@@ -206,6 +243,26 @@
                 supportedStates.toArray(new DeviceState[supportedStates.size()]), reason);
     }
 
+    @GuardedBy("mLock")
+    private boolean isStateSupported(DeviceState deviceState) {
+        if (isThermalStatusCriticalOrAbove(mThermalStatus)
+                && deviceState.hasFlag(
+                DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL)) {
+            return false;
+        }
+        if (mPowerSaveModeEnabled && deviceState.hasFlag(
+                DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE)) {
+            return false;
+        }
+        if (mIsDualDisplayBlockingEnabled
+                && mStateAvailabilityConditions.contains(deviceState.getIdentifier())) {
+            return mStateAvailabilityConditions
+                    .get(deviceState.getIdentifier())
+                    .getAsBoolean();
+        }
+        return true;
+    }
+
     /** Computes the current device state and notifies the listener of a change, if needed. */
     void notifyDeviceStateChangedIfNeeded() {
         int stateToReport = INVALID_DEVICE_STATE;
@@ -294,7 +351,7 @@
     private void dumpSensorValues() {
         Slog.i(TAG, "Sensor values:");
         dumpSensorValues("Hall Sensor", mHallSensor, mLastHallSensorEvent);
-        dumpSensorValues("Hinge Angle Sensor",mHingeAngleSensor, mLastHingeAngleSensorEvent);
+        dumpSensorValues("Hinge Angle Sensor", mHingeAngleSensor, mLastHingeAngleSensorEvent);
         Slog.i(TAG, "isScreenOn: " + isScreenOn());
     }
 
@@ -307,12 +364,35 @@
 
     @Override
     public void onDisplayAdded(int displayId) {
+        // TODO(b/312397262): consider virtual displays cases
+        synchronized (mLock) {
+            if (mIsDualDisplayBlockingEnabled
+                    && !mExternalDisplaysConnected.get(displayId, false)
+                    && mDisplayManager.getDisplay(displayId).getType() == TYPE_EXTERNAL) {
+                mExternalDisplaysConnected.put(displayId, true);
 
+                // Only update the supported state when going from 0 external display to 1
+                if (mExternalDisplaysConnected.size() == 1) {
+                    notifySupportedStatesChanged(
+                            SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED);
+                }
+            }
+        }
     }
 
     @Override
     public void onDisplayRemoved(int displayId) {
+        synchronized (mLock) {
+            if (mIsDualDisplayBlockingEnabled && mExternalDisplaysConnected.get(displayId, false)) {
+                mExternalDisplaysConnected.delete(displayId);
 
+                // Only update the supported states when going from 1 external display to 0
+                if (mExternalDisplaysConnected.size() == 0) {
+                    notifySupportedStatesChanged(
+                            SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_REMOVED);
+                }
+            }
+        }
     }
 
     @Override
@@ -338,48 +418,71 @@
      */
     public static class DeviceStateConfiguration {
         private final DeviceState mDeviceState;
-        private final Function<FoldableDeviceStateProvider, Boolean> mPredicate;
+        private final Predicate<FoldableDeviceStateProvider> mActiveStatePredicate;
+        private final Predicate<FoldableDeviceStateProvider> mAvailabilityPredicate;
 
-        private DeviceStateConfiguration(DeviceState deviceState,
-                Function<FoldableDeviceStateProvider, Boolean> predicate) {
+        private DeviceStateConfiguration(
+                @NonNull DeviceState deviceState,
+                @NonNull Predicate<FoldableDeviceStateProvider> predicate) {
+            this(deviceState, predicate, ALLOWED);
+        }
+
+        private DeviceStateConfiguration(
+                @NonNull DeviceState deviceState,
+                @NonNull Predicate<FoldableDeviceStateProvider> activeStatePredicate,
+                @NonNull Predicate<FoldableDeviceStateProvider> availabilityPredicate) {
+
             mDeviceState = deviceState;
-            mPredicate = predicate;
+            mActiveStatePredicate = activeStatePredicate;
+            mAvailabilityPredicate = availabilityPredicate;
         }
 
         public static DeviceStateConfiguration createConfig(
                 @IntRange(from = MINIMUM_DEVICE_STATE, to = MAXIMUM_DEVICE_STATE) int identifier,
                 @NonNull String name,
                 @DeviceState.DeviceStateFlags int flags,
-                Function<FoldableDeviceStateProvider, Boolean> predicate
+                @NonNull Predicate<FoldableDeviceStateProvider> activeStatePredicate
         ) {
             return new DeviceStateConfiguration(new DeviceState(identifier, name, flags),
-                    predicate);
+                    activeStatePredicate);
         }
 
         public static DeviceStateConfiguration createConfig(
                 @IntRange(from = MINIMUM_DEVICE_STATE, to = MAXIMUM_DEVICE_STATE) int identifier,
                 @NonNull String name,
-                Function<FoldableDeviceStateProvider, Boolean> predicate
+                @NonNull Predicate<FoldableDeviceStateProvider> activeStatePredicate
         ) {
             return new DeviceStateConfiguration(new DeviceState(identifier, name, /* flags= */ 0),
-                    predicate);
+                    activeStatePredicate);
+        }
+
+        /** Create a configuration with availability predicate **/
+        public static DeviceStateConfiguration createConfig(
+                @IntRange(from = MINIMUM_DEVICE_STATE, to = MAXIMUM_DEVICE_STATE) int identifier,
+                @NonNull String name,
+                @DeviceState.DeviceStateFlags int flags,
+                @NonNull Predicate<FoldableDeviceStateProvider> activeStatePredicate,
+                @NonNull Predicate<FoldableDeviceStateProvider> availabilityPredicate
+        ) {
+            return new DeviceStateConfiguration(new DeviceState(identifier, name, flags),
+                    activeStatePredicate, availabilityPredicate);
         }
 
         /**
          * Creates a device state configuration for a closed tent-mode aware state.
-         *
+         * <p>
          * During tent mode:
          * - The inner display is OFF
          * - The outer display is ON
          * - The device is partially unfolded (left and right edges could be on the table)
          * In this mode the device the device so it could be used in a posture where both left
          * and right edges of the unfolded device are on the table.
-         *
+         * <p>
          * The predicate returns false after the hinge angle reaches
          * {@code tentModeSwitchAngleDegrees}. Then it switches back only when the hinge angle
          * becomes less than {@code maxClosedAngleDegrees}. Hinge angle is 0 degrees when the device
          * is fully closed and 180 degrees when it is fully unfolded.
-         *
+         * <p>
          * For example, when tentModeSwitchAngleDegrees = 90 and maxClosedAngleDegrees = 5 degrees:
          *  - when unfolding the device from fully closed posture (last state == closed or it is
          *    undefined yet) this state will become not matching after reaching the angle
@@ -435,6 +538,15 @@
     }
 
     /**
+     * @return Whether there is an external connected display.
+     */
+    public boolean hasNoConnectedExternalDisplay() {
+        synchronized (mLock) {
+            return mExternalDisplaysConnected.size() == 0;
+        }
+    }
+
+    /**
      * @return Whether the screen is on.
      */
     public boolean isScreenOn() {
@@ -442,6 +554,7 @@
             return mIsScreenOn;
         }
     }
+
     /**
      * @return current hinge angle value of a foldable device
      */
diff --git a/services/foldables/devicestateprovider/src/com/android/server/policy/TentModeDeviceStatePolicy.java b/services/foldables/devicestateprovider/src/com/android/server/policy/TentModeDeviceStatePolicy.java
index 5f2cf3c..5968b63 100644
--- a/services/foldables/devicestateprovider/src/com/android/server/policy/TentModeDeviceStatePolicy.java
+++ b/services/foldables/devicestateprovider/src/com/android/server/policy/TentModeDeviceStatePolicy.java
@@ -33,6 +33,10 @@
 import com.android.server.devicestate.DeviceStatePolicy;
 import com.android.server.devicestate.DeviceStateProvider;
 import com.android.server.policy.FoldableDeviceStateProvider.DeviceStateConfiguration;
+import com.android.server.policy.feature.flags.FeatureFlags;
+import com.android.server.policy.feature.flags.FeatureFlagsImpl;
+
+import java.util.function.Predicate;
 
 /**
  * Device state policy for a foldable device that supports tent mode: a mode when the device
@@ -55,6 +59,10 @@
 
     private final DeviceStateProvider mProvider;
 
+    private final boolean mIsDualDisplayBlockingEnabled;
+    private static final Predicate<FoldableDeviceStateProvider> ALLOWED = p -> true;
+    private static final Predicate<FoldableDeviceStateProvider> NOT_ALLOWED = p -> false;
+
     /**
      * Creates TentModeDeviceStatePolicy
      *
@@ -67,6 +75,12 @@
      */
     public TentModeDeviceStatePolicy(@NonNull Context context,
             @NonNull Sensor hingeAngleSensor, @NonNull Sensor hallSensor, int closeAngleDegrees) {
+        this(new FeatureFlagsImpl(), context, hingeAngleSensor, hallSensor, closeAngleDegrees);
+    }
+
+    public TentModeDeviceStatePolicy(@NonNull FeatureFlags featureFlags, @NonNull Context context,
+                                     @NonNull Sensor hingeAngleSensor, @NonNull Sensor hallSensor,
+                                     int closeAngleDegrees) {
         super(context);
 
         final SensorManager sensorManager = mContext.getSystemService(SensorManager.class);
@@ -74,8 +88,10 @@
 
         final DeviceStateConfiguration[] configuration = createConfiguration(closeAngleDegrees);
 
-        mProvider = new FoldableDeviceStateProvider(mContext, sensorManager, hingeAngleSensor,
-                hallSensor, displayManager, configuration);
+        mIsDualDisplayBlockingEnabled = featureFlags.enableDualDisplayBlocking();
+
+        mProvider = new FoldableDeviceStateProvider(mContext, sensorManager,
+                hingeAngleSensor, hallSensor, displayManager, configuration);
     }
 
     private DeviceStateConfiguration[] createConfiguration(int closeAngleDegrees) {
@@ -83,24 +99,27 @@
                 createClosedConfiguration(closeAngleDegrees),
                 createConfig(DEVICE_STATE_HALF_OPENED,
                         /* name= */ "HALF_OPENED",
-                        (provider) -> {
+                        /* activeStatePredicate= */ (provider) -> {
                             final float hingeAngle = provider.getHingeAngle();
                             return hingeAngle >= MAX_CLOSED_ANGLE_DEGREES
                                     && hingeAngle <= TABLE_TOP_MODE_SWITCH_ANGLE_DEGREES;
                         }),
                 createConfig(DEVICE_STATE_OPENED,
                         /* name= */ "OPENED",
-                        (provider) -> true),
+                        /* activeStatePredicate= */ ALLOWED),
                 createConfig(DEVICE_STATE_REAR_DISPLAY_STATE,
                         /* name= */ "REAR_DISPLAY_STATE",
                         /* flags= */ FLAG_EMULATED_ONLY,
-                        (provider) -> false),
+                        /* activeStatePredicate= */ NOT_ALLOWED),
                 createConfig(DEVICE_STATE_CONCURRENT_INNER_DEFAULT,
                         /* name= */ "CONCURRENT_INNER_DEFAULT",
                         /* flags= */ FLAG_EMULATED_ONLY | FLAG_CANCEL_WHEN_REQUESTER_NOT_ON_TOP
                                 | FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
                                 | FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE,
-                        (provider) -> false)
+                        /* activeStatePredicate= */ NOT_ALLOWED,
+                        /* availabilityPredicate= */
+                        provider -> !mIsDualDisplayBlockingEnabled
+                                || provider.hasNoConnectedExternalDisplay())
         };
     }
 
@@ -111,7 +130,7 @@
                     DEVICE_STATE_CLOSED,
                     /* name= */ "CLOSED",
                     /* flags= */ FLAG_CANCEL_OVERRIDE_REQUESTS,
-                    (provider) -> {
+                    /* activeStatePredicate= */ (provider) -> {
                         final float hingeAngle = provider.getHingeAngle();
                         return hingeAngle <= closeAngleDegrees;
                     }
diff --git a/services/foldables/devicestateprovider/src/com/android/server/policy/feature/Android.bp b/services/foldables/devicestateprovider/src/com/android/server/policy/feature/Android.bp
new file mode 100644
index 0000000..6ad8d79
--- /dev/null
+++ b/services/foldables/devicestateprovider/src/com/android/server/policy/feature/Android.bp
@@ -0,0 +1,12 @@
+aconfig_declarations {
+    name: "device_state_flags",
+    package: "com.android.server.policy.feature.flags",
+    srcs: [
+        "device_state_flags.aconfig",
+    ],
+}
+
+java_aconfig_library {
+    name: "device_state_flags_lib",
+    aconfig_declarations: "device_state_flags",
+}
diff --git a/services/foldables/devicestateprovider/src/com/android/server/policy/feature/device_state_flags.aconfig b/services/foldables/devicestateprovider/src/com/android/server/policy/feature/device_state_flags.aconfig
new file mode 100644
index 0000000..47c2a1b
--- /dev/null
+++ b/services/foldables/devicestateprovider/src/com/android/server/policy/feature/device_state_flags.aconfig
@@ -0,0 +1,8 @@
+package: "com.android.server.policy.feature.flags"
+
+flag {
+    name: "enable_dual_display_blocking"
+    namespace: "display_manager"
+    description: "Feature flag for dual display blocking"
+    bug: "278667199"
+}
\ No newline at end of file
diff --git a/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java b/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java
index 8fa4ce5..ddf4a08 100644
--- a/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java
+++ b/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java
@@ -17,18 +17,21 @@
 package com.android.server.policy;
 
 
+import static android.view.Display.DEFAULT_DISPLAY;
+import static android.view.Display.STATE_OFF;
+import static android.view.Display.STATE_ON;
+import static android.view.Display.TYPE_EXTERNAL;
+import static android.view.Display.TYPE_INTERNAL;
+
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED;
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_REMOVED;
 import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED;
 import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED;
 import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_ENABLED;
 import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_CRITICAL;
 import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_NORMAL;
-import com.android.server.policy.FoldableDeviceStateProvider.DeviceStateConfiguration;
-
-import static android.view.Display.DEFAULT_DISPLAY;
-import static android.view.Display.STATE_OFF;
-import static android.view.Display.STATE_ON;
-
 import static com.android.server.policy.FoldableDeviceStateProvider.DeviceStateConfiguration.createConfig;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertArrayEquals;
@@ -36,12 +39,11 @@
 import static org.junit.Assert.assertThrows;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.nullable;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -51,20 +53,21 @@
 import android.hardware.SensorManager;
 import android.hardware.display.DisplayManager;
 import android.hardware.input.InputSensorInfo;
-import android.os.PowerManager;
 import android.os.Handler;
+import android.os.PowerManager;
 import android.testing.AndroidTestingRunner;
 import android.view.Display;
 
 import com.android.server.devicestate.DeviceState;
-import com.android.server.devicestate.DeviceStateProvider;
 import com.android.server.devicestate.DeviceStateProvider.Listener;
+import com.android.server.policy.FoldableDeviceStateProvider.DeviceStateConfiguration;
+import com.android.server.policy.feature.flags.FakeFeatureFlagsImpl;
+import com.android.server.policy.feature.flags.Flags;
 
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
-import org.mockito.ArgumentMatchers;
 import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
@@ -95,10 +98,16 @@
     @Mock
     private DisplayManager mDisplayManager;
     private FoldableDeviceStateProvider mProvider;
+    @Mock
+    private Display mDefaultDisplay;
+    @Mock
+    private Display mExternalDisplay;
 
+    private final FakeFeatureFlagsImpl mFakeFeatureFlags = new FakeFeatureFlagsImpl();
     @Before
     public void setup() {
         MockitoAnnotations.initMocks(this);
+        mFakeFeatureFlags.setFlag(Flags.FLAG_ENABLE_DUAL_DISPLAY_BLOCKING, true);
 
         mHallSensor = new Sensor(mInputSensorInfo);
         mHingeAngleSensor = new Sensor(mInputSensorInfo);
@@ -473,6 +482,133 @@
         assertThat(mProvider.isScreenOn()).isFalse();
     }
 
+    @Test
+    public void test_dualScreenDisabledWhenExternalScreenIsConnected() throws Exception {
+        when(mDisplayManager.getDisplays()).thenReturn(new Display[]{mDefaultDisplay});
+        when(mDefaultDisplay.getType()).thenReturn(TYPE_INTERNAL);
+
+        createProvider(createConfig(/* identifier= */ 1, /* name= */ "CLOSED",
+                        (c) -> c.getHingeAngle() < 5f),
+                createConfig(/* identifier= */ 2, /* name= */ "HALF_OPENED",
+                        (c) -> c.getHingeAngle() < 90f),
+                createConfig(/* identifier= */ 3, /* name= */ "OPENED",
+                        (c) -> c.getHingeAngle() < 180f),
+                createConfig(/* identifier= */ 4, /* name= */ "DUAL_DISPLAY", /* flags */ 0,
+                        (c) -> false, FoldableDeviceStateProvider::hasNoConnectedExternalDisplay));
+
+        Listener listener = mock(Listener.class);
+        mProvider.setListener(listener);
+        verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+                eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+        assertThat(mDeviceStateArrayCaptor.getValue()).asList().containsExactly(
+                new DeviceState[]{
+                        new DeviceState(1, "CLOSED", 0 /* flags */),
+                        new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+                        new DeviceState(3, "OPENED", 0 /* flags */),
+                        new DeviceState(4, "DUAL_DISPLAY", 0 /* flags */)}).inOrder();
+
+        clearInvocations(listener);
+
+        when(mDisplayManager.getDisplays())
+                .thenReturn(new Display[]{mDefaultDisplay, mExternalDisplay});
+        when(mDisplayManager.getDisplay(1)).thenReturn(mExternalDisplay);
+        when(mExternalDisplay.getType()).thenReturn(TYPE_EXTERNAL);
+
+        // The DUAL_DISPLAY state should be disabled.
+        mProvider.onDisplayAdded(1);
+        verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+                eq(SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED));
+        assertThat(mDeviceStateArrayCaptor.getValue()).asList().containsExactly(
+                new DeviceState[]{
+                        new DeviceState(1, "CLOSED", 0 /* flags */),
+                        new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+                        new DeviceState(3, "OPENED", 0 /* flags */)}).inOrder();
+        clearInvocations(listener);
+
+        // The DUAL_DISPLAY state should be re-enabled.
+        when(mDisplayManager.getDisplays()).thenReturn(new Display[]{mDefaultDisplay});
+        mProvider.onDisplayRemoved(1);
+        verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+                eq(SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_REMOVED));
+        assertThat(mDeviceStateArrayCaptor.getValue()).asList().containsExactly(
+                new DeviceState[]{
+                        new DeviceState(1, "CLOSED", 0 /* flags */),
+                        new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+                        new DeviceState(3, "OPENED", 0 /* flags */),
+                        new DeviceState(4, "DUAL_DISPLAY", 0 /* flags */)}).inOrder();
+    }
+
+    @Test
+    public void test_notifySupportedStatesChangedCalledOnlyOnInitialExternalScreenAddition() {
+        when(mDisplayManager.getDisplays()).thenReturn(new Display[]{mDefaultDisplay});
+        when(mDefaultDisplay.getType()).thenReturn(TYPE_INTERNAL);
+
+        createProvider(createConfig(/* identifier= */ 1, /* name= */ "CLOSED",
+                        (c) -> c.getHingeAngle() < 5f),
+                createConfig(/* identifier= */ 2, /* name= */ "HALF_OPENED",
+                        (c) -> c.getHingeAngle() < 90f),
+                createConfig(/* identifier= */ 3, /* name= */ "OPENED",
+                        (c) -> c.getHingeAngle() < 180f),
+                createConfig(/* identifier= */ 4, /* name= */ "DUAL_DISPLAY", /* flags */ 0,
+                        (c) -> false, FoldableDeviceStateProvider::hasNoConnectedExternalDisplay));
+
+        Listener listener = mock(Listener.class);
+        mProvider.setListener(listener);
+        verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+                eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+        assertThat(mDeviceStateArrayCaptor.getValue()).asList().containsExactly(
+                new DeviceState[]{
+                        new DeviceState(1, "CLOSED", 0 /* flags */),
+                        new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+                        new DeviceState(3, "OPENED", 0 /* flags */),
+                        new DeviceState(4, "DUAL_DISPLAY", 0 /* flags */)}).inOrder();
+
+        clearInvocations(listener);
+
+        addExternalDisplay(1);
+        verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+                eq(SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED));
+        addExternalDisplay(2);
+        addExternalDisplay(3);
+        addExternalDisplay(4);
+        verify(listener, times(1))
+                .onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+                eq(SUPPORTED_DEVICE_STATES_CHANGED_EXTERNAL_DISPLAY_ADDED));
+    }
+
+    @Test
+    public void hasNoConnectedDisplay_afterExternalDisplayAdded_returnsFalse() {
+        createProvider(
+                createConfig(
+                        /* identifier= */ 1, /* name= */ "ONE",
+                        /* flags= */0, (c) -> true,
+                        FoldableDeviceStateProvider::hasNoConnectedExternalDisplay)
+        );
+
+        addExternalDisplay(/* displayId */ 1);
+
+        assertThat(mProvider.hasNoConnectedExternalDisplay()).isFalse();
+    }
+
+    @Test
+    public void hasNoConnectedDisplay_afterExternalDisplayAddedAndRemoved_returnsTrue() {
+        createProvider(
+                createConfig(
+                        /* identifier= */ 1, /* name= */ "ONE",
+                        /* flags= */0, (c) -> true,
+                        FoldableDeviceStateProvider::hasNoConnectedExternalDisplay)
+        );
+
+        addExternalDisplay(/* displayId */ 1);
+        mProvider.onDisplayRemoved(1);
+
+        assertThat(mProvider.hasNoConnectedExternalDisplay()).isTrue();
+    }
+    private void addExternalDisplay(int displayId) {
+        when(mDisplayManager.getDisplay(displayId)).thenReturn(mExternalDisplay);
+        when(mExternalDisplay.getType()).thenReturn(TYPE_EXTERNAL);
+        mProvider.onDisplayAdded(displayId);
+    }
     private void setScreenOn(boolean isOn) {
         Display mockDisplay = mock(Display.class);
         int state = isOn ? STATE_ON : STATE_OFF;
@@ -508,12 +644,11 @@
     }
 
     private void createProvider(DeviceStateConfiguration... configurations) {
-        mProvider = new FoldableDeviceStateProvider(mContext, mSensorManager, mHingeAngleSensor,
-                mHallSensor, mDisplayManager, configurations);
+        mProvider = new FoldableDeviceStateProvider(mFakeFeatureFlags, mContext, mSensorManager,
+                mHingeAngleSensor, mHallSensor, mDisplayManager, configurations);
         verify(mDisplayManager)
                 .registerDisplayListener(
                         mDisplayListenerCaptor.capture(),
-                        nullable(Handler.class),
-                        anyLong());
+                        nullable(Handler.class));
     }
 }