Snap for 8589293 from 05a38b39945c57430103c33d11b17a1662d67d87 to sc-v2-platform-release

Change-Id: Id14559369329876f58e763f43852733c5bb1c898
diff --git a/res/values/overlayable.xml b/res/values/overlayable.xml
index f116824..1280b21 100644
--- a/res/values/overlayable.xml
+++ b/res/values/overlayable.xml
@@ -19,6 +19,7 @@
       <item type="array" name="off_screen_nudge_global_actions"/>
       <item type="array" name="off_screen_nudge_intents"/>
       <item type="array" name="off_screen_nudge_key_codes"/>
+      <item type="array" name="projected_apps"/>
       <item type="bool" name="clear_focus_area_history_when_rotating"/>
       <item type="bool" name="config_showHeadsUpNotificationOnBottom"/>
       <item type="dimen" name="notification_headsup_card_margin_horizontal"/>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 6d06a1a..624e096 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -29,4 +29,9 @@
         <item></item>
         <item></item>
     </string-array>
+
+    <!-- Package names of projected apps. -->
+    <string-array name="projected_apps" translatable="false">
+        <item>com.google.android.embedded.projection</item>
+    </string-array>
 </resources>
diff --git a/src/com/android/car/rotary/Navigator.java b/src/com/android/car/rotary/Navigator.java
index 5bf3eb2..bf1f513 100644
--- a/src/com/android/car/rotary/Navigator.java
+++ b/src/com/android/car/rotary/Navigator.java
@@ -169,13 +169,15 @@
         AccessibilityNodeInfo target = null;
         while (advancedCount < rotationCount) {
             AccessibilityNodeInfo nextCandidate = null;
-            AccessibilityNodeInfo webView = findWebViewAncestor(candidate);
-            if (webView != null) {
-                nextCandidate = findNextFocusableInWebView(webView, candidate, direction);
+            // Virtual View hierarchies like WebViews and ComposeViews do not support focusSearch().
+            AccessibilityNodeInfo virtualViewAncestor = findVirtualViewAncestor(candidate);
+            if (virtualViewAncestor != null) {
+                nextCandidate =
+                    findNextFocusableInVirtualRoot(virtualViewAncestor, candidate, direction);
             }
             if (nextCandidate == null) {
-                // If we aren't in a WebView or there aren't any more focusable nodes within the
-                // WebView, use focusSearch().
+                // If we aren't in a virtual node hierarchy, or there aren't any more focusable
+                // nodes within the virtual node hierarchy, use focusSearch().
                 nextCandidate = candidate.focusSearch(direction);
             }
             AccessibilityNodeInfo candidateFocusArea =
@@ -648,18 +650,6 @@
     }
 
     /**
-     * Returns whether the {@code window} is the main application window. A main application
-     * window is an application window on the default display that takes up the entire display.
-     */
-    boolean isMainApplicationWindow(@NonNull AccessibilityWindowInfo window) {
-        Rect windowBounds = new Rect();
-        window.getBoundsInScreen(windowBounds);
-        return window.getType() == TYPE_APPLICATION
-                && window.getDisplayId() == Display.DEFAULT_DISPLAY
-                && mAppWindowBounds.equals(windowBounds);
-    }
-
-    /**
      * Searches from the given node up through its ancestors to the containing focus area, looking
      * for a node that's marked as horizontally or vertically scrollable. Returns a copy of the
      * first such node or null if none is found. The caller is responsible for recycling the result.
@@ -812,9 +802,9 @@
                                 sourceFocusAreaBounds, candidateBounds, direction);
                 },
                 /* targetPredicate= */ candidateNode -> {
-                    // RotaryService can navigate to nodes in a WebView even when off-screen so we
-                    // use canPerformFocus() to skip the bounds check.
-                    if (isInWebView(candidateNode)) {
+                    // RotaryService can navigate to nodes in a WebView or a ComposeView even when
+                    // off-screen so we use canPerformFocus() to skip the bounds check.
+                    if (isInVirtualNodeHierarchy(candidateNode)) {
                         return Utils.canPerformFocus(candidateNode);
                     }
                     // If a node isn't visible to the user, e.g. another window is obscuring it,
@@ -892,6 +882,19 @@
         return mTreeTraverser.findNodeOrAncestor(node, Utils::isWebView);
     }
 
+    /**
+     * Returns a copy of {@code node} or the nearest ancestor that represents a {@code ComposeView}
+     * or a {@code WebView}. Returns null if {@code node} isn't a {@code ComposeView} or a
+     * {@code WebView} and is not a descendant of a {@code ComposeView} or a {@code WebView}.
+     *
+     * TODO(b/192274274): This method may not be necessary anymore if Compose supports focusSearch.
+     */
+    @Nullable
+    private AccessibilityNodeInfo findVirtualViewAncestor(@NonNull AccessibilityNodeInfo node) {
+        return mTreeTraverser.findNodeOrAncestor(node, /* targetPredicate= */ (nodeInfo) ->
+            Utils.isComposeView(nodeInfo) || Utils.isWebView(nodeInfo));
+    }
+
     /** Returns whether {@code node} is a {@code WebView} or is a descendant of one. */
     boolean isInWebView(@NonNull AccessibilityNodeInfo node) {
         AccessibilityNodeInfo webView = findWebViewAncestor(node);
@@ -903,30 +906,45 @@
     }
 
     /**
+     * Returns whether {@code node} is a {@code ComposeView}, is a {@code WebView}, or is a
+     * descendant of either.
+     */
+    boolean isInVirtualNodeHierarchy(@NonNull AccessibilityNodeInfo node) {
+        AccessibilityNodeInfo virtualViewAncestor = findVirtualViewAncestor(node);
+        if (virtualViewAncestor == null) {
+            return false;
+        }
+        virtualViewAncestor.recycle();
+        return true;
+    }
+
+    /**
      * Returns the next focusable node after {@code candidate} in {@code direction} in {@code
-     * webView} or null if none. This handles navigating into a WebView as well as within a WebView.
+     * root} or null if none. This handles navigating into a WebView as well as within a WebView.
+     * This also handles navigating into a ComposeView, as well as within a ComposeView.
      */
     @Nullable
-    private AccessibilityNodeInfo findNextFocusableInWebView(@NonNull AccessibilityNodeInfo webView,
+    private AccessibilityNodeInfo findNextFocusableInVirtualRoot(
+            @NonNull AccessibilityNodeInfo root,
             @NonNull AccessibilityNodeInfo candidate, int direction) {
-        // focusSearch() doesn't work in WebViews so use tree traversal instead.
-        if (Utils.isWebView(candidate)) {
+        // focusSearch() doesn't work in WebViews or ComposeViews so use tree traversal instead.
+        if (Utils.isWebView(candidate) || Utils.isComposeView(candidate)) {
             if (direction == View.FOCUS_FORWARD) {
-                // When entering into a WebView, find the first focusable node within the
-                // WebView if any.
-                return findFirstFocusableDescendantInWebView(candidate);
+                // When entering into the root of a virtual node hierarchy, find the first focusable
+                // child node of the root if any.
+                return findFirstFocusableDescendantInVirtualRoot(candidate);
             } else {
-                // When backing into a WebView, find the last focusable node within the
-                // WebView if any.
-                return findLastFocusableDescendantInWebView(candidate);
+                // When backing into the root of a virtual node hierarchy, find the last focusable
+                // child node of the root if any.
+                return findLastFocusableDescendantInVirtualRoot(candidate);
             }
         } else {
-            // When navigating within a WebView, find the next or previous focusable node in
-            // depth-first order.
+            // When navigating within a virtual view hierarchy, find the next or previous focusable
+            // node in depth-first order.
             if (direction == View.FOCUS_FORWARD) {
-                return findFirstFocusDescendantInWebViewAfter(webView, candidate);
+                return findFirstFocusDescendantInVirtualRootAfter(root, candidate);
             } else {
-                return findFirstFocusDescendantInWebViewBefore(webView, candidate);
+                return findFirstFocusDescendantInVirtualRootBefore(root, candidate);
             }
         }
     }
@@ -934,34 +952,34 @@
     /**
      * Returns the first descendant of {@code webView} which can perform focus. This includes off-
      * screen descendants. The nodes are searched in in depth-first order, not including
-     * {@code webView} itself. If no descendant can perform focus, null is returned. The caller is
+     * {@code root} itself. If no descendant can perform focus, null is returned. The caller is
      * responsible for recycling the result.
      */
     @Nullable
-    private AccessibilityNodeInfo findFirstFocusableDescendantInWebView(
-            @NonNull AccessibilityNodeInfo webView) {
-        return mTreeTraverser.depthFirstSearch(webView,
-                candidateNode -> candidateNode != webView && Utils.canPerformFocus(candidateNode));
+    private AccessibilityNodeInfo findFirstFocusableDescendantInVirtualRoot(
+            @NonNull AccessibilityNodeInfo root) {
+        return mTreeTraverser.depthFirstSearch(root,
+                candidateNode -> candidateNode != root && Utils.canPerformFocus(candidateNode));
     }
 
     /**
-     * Returns the last descendant of {@code webView} which can perform focus. This includes off-
+     * Returns the last descendant of {@code root} which can perform focus. This includes off-
      * screen descendants. The nodes are searched in reverse depth-first order, not including
-     * {@code webView} itself. If no descendant can perform focus, null is returned. The caller is
+     * {@code root} itself. If no descendant can perform focus, null is returned. The caller is
      * responsible for recycling the result.
      */
     @Nullable
-    private AccessibilityNodeInfo findLastFocusableDescendantInWebView(
-            @NonNull AccessibilityNodeInfo webView) {
-        return mTreeTraverser.reverseDepthFirstSearch(webView,
-                candidateNode -> candidateNode != webView && Utils.canPerformFocus(candidateNode));
+    private AccessibilityNodeInfo findLastFocusableDescendantInVirtualRoot(
+            @NonNull AccessibilityNodeInfo root) {
+        return mTreeTraverser.reverseDepthFirstSearch(root,
+                candidateNode -> candidateNode != root && Utils.canPerformFocus(candidateNode));
     }
 
     @Nullable
-    private AccessibilityNodeInfo findFirstFocusDescendantInWebViewBefore(
-            @NonNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo beforeNode) {
+    private AccessibilityNodeInfo findFirstFocusDescendantInVirtualRootBefore(
+            @NonNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo beforeNode) {
         boolean[] foundBeforeNode = new boolean[1];
-        return mTreeTraverser.reverseDepthFirstSearch(webView,
+        return mTreeTraverser.reverseDepthFirstSearch(root,
                 node -> {
                     if (foundBeforeNode[0] && Utils.canPerformFocus(node)) {
                         return true;
@@ -974,10 +992,10 @@
     }
 
     @Nullable
-    private AccessibilityNodeInfo findFirstFocusDescendantInWebViewAfter(
-            @NonNull AccessibilityNodeInfo webView, @NonNull AccessibilityNodeInfo afterNode) {
+    private AccessibilityNodeInfo findFirstFocusDescendantInVirtualRootAfter(
+            @NonNull AccessibilityNodeInfo root, @NonNull AccessibilityNodeInfo afterNode) {
         boolean[] foundAfterNode = new boolean[1];
-        return mTreeTraverser.depthFirstSearch(webView,
+        return mTreeTraverser.depthFirstSearch(root,
                 node -> {
                     if (foundAfterNode[0] && Utils.canPerformFocus(node)) {
                         return true;
diff --git a/src/com/android/car/rotary/RotaryService.java b/src/com/android/car/rotary/RotaryService.java
index 48ab21c..79c7f74 100644
--- a/src/com/android/car/rotary/RotaryService.java
+++ b/src/com/android/car/rotary/RotaryService.java
@@ -38,6 +38,7 @@
 import static android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED;
 import static android.view.accessibility.AccessibilityEvent.WINDOWS_CHANGE_ADDED;
 import static android.view.accessibility.AccessibilityEvent.WINDOWS_CHANGE_REMOVED;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLEAR_FOCUS;
 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLEAR_SELECTION;
 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK;
 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
@@ -71,6 +72,7 @@
 import android.content.IntentFilter;
 import android.content.SharedPreferences;
 import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
 import android.content.res.Resources;
@@ -117,6 +119,7 @@
 import java.io.PrintWriter;
 import java.lang.ref.WeakReference;
 import java.net.URISyntaxException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -432,6 +435,19 @@
     @VisibleForTesting
     boolean mInDirectManipulationMode;
 
+    /**
+     * Whether RotaryService is in projection mode. In this mode, events generated by a rotary
+     * controller will be converted and injected into the projected app.
+     */
+    private boolean mInProjectionMode;
+
+    /**
+     * Package names of projected apps. When the foreground app is a projected app, RotaryService
+     * will enter projection mode.
+     */
+    @NonNull
+    private List<String> mProjectedApps = new ArrayList();
+
     /** The {@link SystemClock#uptimeMillis} when the last rotary rotation event occurred. */
     private long mLastRotateEventTime;
 
@@ -472,6 +488,8 @@
 
     private static final Map<Integer, Integer> DIRECTION_TO_KEYCODE_MAP;
 
+    private static final Map<Integer, Integer> NAVIGATION_KEYCODE_TO_DPAD_KEYCODE_MAP;
+
     static {
         Map<Integer, Integer> map = new HashMap<>();
         map.put(KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT);
@@ -501,61 +519,15 @@
         DIRECTION_TO_KEYCODE_MAP = Collections.unmodifiableMap(map);
     }
 
-    private final BroadcastReceiver mHomeButtonReceiver = new BroadcastReceiver() {
-        // Should match the values in PhoneWindowManager.java
-        private static final String SYSTEM_DIALOG_REASON_KEY = "reason";
-        private static final String SYSTEM_DIALOG_REASON_HOME_KEY = "homekey";
+    static {
+        Map<Integer, Integer> map = new HashMap<>();
+        map.put(KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, KeyEvent.KEYCODE_DPAD_UP);
+        map.put(KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN);
+        map.put(KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT, KeyEvent.KEYCODE_DPAD_LEFT);
+        map.put(KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT);
 
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            String reason = intent.getStringExtra(SYSTEM_DIALOG_REASON_KEY);
-            if (!SYSTEM_DIALOG_REASON_HOME_KEY.equals(reason)) {
-                L.d("Skipping the processing of ACTION_CLOSE_SYSTEM_DIALOGS broadcast event due "
-                        + "to reason: " + reason);
-                return;
-            }
-
-            // Trigger a back action in order to exit direct manipulation mode.
-            if (mInDirectManipulationMode) {
-                handleBackButtonEvent(ACTION_DOWN);
-                handleBackButtonEvent(ACTION_UP);
-            }
-
-            List<AccessibilityWindowInfo> windows = getWindows();
-            for (AccessibilityWindowInfo window : windows) {
-                if (window == null) {
-                    continue;
-                }
-
-                if (mInRotaryMode && mNavigator.isMainApplicationWindow(window)) {
-                    // Post this in a handler so that there is no race condition between app
-                    // transitions and restoration of focus.
-                    getMainThreadHandler().post(() -> {
-                        AccessibilityNodeInfo rootView = window.getRoot();
-                        if (rootView == null) {
-                            L.e("Root view in application window no longer exists");
-                            return;
-                        }
-                        boolean result = restoreDefaultFocusInRoot(rootView);
-                        if (!result) {
-                            L.e("Failed to focus the default element in the application window");
-                        }
-                        Utils.recycleNode(rootView);
-                    });
-                } else {
-                    // Post this in a handler so that there is no race condition between app
-                    // transitions and restoration of focus.
-                    getMainThreadHandler().post(() -> {
-                        boolean result = clearFocusInWindow(window);
-                        if (!result) {
-                            L.e("Failed to clear the focus in window: " + window);
-                        }
-                    });
-                }
-            }
-            Utils.recycleWindows(windows);
-        }
-    };
+        NAVIGATION_KEYCODE_TO_DPAD_KEYCODE_MAP = Collections.unmodifiableMap(map);
+    }
 
     private Car mCar;
     private CarInputManager mCarInputManager;
@@ -669,8 +641,7 @@
             }
         }
 
-        getWindowContext().registerReceiver(mHomeButtonReceiver,
-                new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
+        mProjectedApps = Arrays.asList(res.getStringArray(R.array.projected_apps));
 
         IntentFilter filter = new IntentFilter();
         filter.addAction(Intent.ACTION_PACKAGE_ADDED);
@@ -720,6 +691,11 @@
                     if (ready) {
                         mCarInputManager =
                                 (CarInputManager) mCar.getCarManager(Car.CAR_INPUT_SERVICE);
+                        if (mCarInputManager == null) {
+                            // Do nothing if mCarInputManager is null. When it becomes not null,
+                            // this lifecycle event will be called again.
+                            return;
+                        }
                         mCarInputManager.requestInputEventCapture(
                                 CarOccupantZoneManager.DISPLAY_TYPE_MAIN,
                                 mInputTypes,
@@ -752,7 +728,6 @@
     public void onDestroy() {
         L.v("onDestroy");
         unregisterReceiver(mAppInstallUninstallReceiver);
-        getWindowContext().unregisterReceiver(mHomeButtonReceiver);
 
         unregisterInputMethodObserver();
         unregisterFilterObserver();
@@ -1078,8 +1053,13 @@
      */
     private boolean handleKeyEvent(KeyEvent event) {
         int action = event.getAction();
-        boolean isActionDown = action == ACTION_DOWN;
         int keyCode = getKeyCode(event);
+        if (mInProjectionMode) {
+            injectKeyEventForProjectedApp(keyCode, action);
+            return true;
+        }
+
+        boolean isActionDown = action == ACTION_DOWN;
         int detents = event.isShiftPressed() ? SHIFT_DETENTS : 1;
         switch (keyCode) {
             case KeyEvent.KEYCODE_Q:
@@ -1394,6 +1374,17 @@
         }
     }
 
+    private boolean restoreDefaultFocusInWindow(@NonNull AccessibilityWindowInfo window) {
+        AccessibilityNodeInfo root = window.getRoot();
+        if (root == null) {
+            L.d("No root node in window " + window);
+            return false;
+        }
+        boolean success = restoreDefaultFocusInRoot(root);
+        root.recycle();
+        return success;
+    }
+
     private boolean restoreDefaultFocusInRoot(@NonNull AccessibilityNodeInfo root) {
         AccessibilityNodeInfo fpv = mNavigator.findFocusParkingViewInRoot(root);
         // Refresh the node to ensure the focused state is up to date. The node came directly from
@@ -1681,8 +1672,7 @@
             arguments.clear();
             arguments.putInt(NUDGE_DIRECTION, direction);
             boolean success = performFocusAction(targetFocusArea, arguments);
-            L.d("Nudging to the nearest FocusArea "
-                    + (success ? "succeeded" : "failed: " + targetFocusArea));
+            L.successOrFailure("Nudging to the nearest FocusArea " + targetFocusArea, success);
             targetFocusArea.recycle();
             return;
         }
@@ -1690,8 +1680,8 @@
         // targetFocusArea is an implicit FocusArea (i.e., the root node of a window without any
         // FocusAreas), so restore the focus in it.
         boolean success = restoreDefaultFocusInRoot(targetFocusArea);
-        L.d("Nudging to the nearest implicit focus area "
-                + (success ? "succeeded" : "failed: " + targetFocusArea));
+        L.successOrFailure("Nudging to the nearest implicit focus area " + targetFocusArea,
+                success);
         targetFocusArea.recycle();
     }
 
@@ -1721,30 +1711,33 @@
      * Returns whether a custom nudge action was performed.
      */
     private boolean handleAppSpecificOffScreenNudge(@View.FocusRealDirection int direction) {
-        Bundle metaData = getForegroundActivityMetaData();
-        if (metaData == null) {
-            L.v("No metadata for " + mForegroundActivity);
-            return false;
+        Bundle activityMetaData = getForegroundActivityMetaData();
+        Bundle packageMetaData = getForegroundPackageMetaData();
+        int globalAction = getGlobalAction(activityMetaData, direction);
+        if (globalAction == INVALID_GLOBAL_ACTION) {
+            globalAction = getGlobalAction(packageMetaData, direction);
         }
-        String directionString = DIRECTION_TO_STRING.get(direction);
-        int globalAction = metaData.getInt(
-                String.format(OFF_SCREEN_NUDGE_GLOBAL_ACTION_FORMAT, directionString),
-                INVALID_GLOBAL_ACTION);
         if (globalAction != INVALID_GLOBAL_ACTION) {
             L.d("App-specific off-screen nudge: " + globalActionToString(globalAction));
             performGlobalAction(globalAction);
             return true;
         }
-        int keyCode = metaData.getInt(
-                String.format(OFF_SCREEN_NUDGE_KEY_CODE_FORMAT, directionString), KEYCODE_UNKNOWN);
+
+        int keyCode = getKeyCode(activityMetaData, direction);
+        if (keyCode == KEYCODE_UNKNOWN) {
+            keyCode = getKeyCode(packageMetaData, direction);
+        }
         if (keyCode != KEYCODE_UNKNOWN) {
             L.d("App-specific off-screen nudge: " + KeyEvent.keyCodeToString(keyCode));
             injectKeyEvent(keyCode, ACTION_DOWN);
             injectKeyEvent(keyCode, ACTION_UP);
             return true;
         }
-        String intentString = metaData.getString(
-                String.format(OFF_SCREEN_NUDGE_INTENT_FORMAT, directionString), null);
+
+        String intentString = getIntentString(activityMetaData, direction);
+        if (intentString == null) {
+            intentString = getIntentString(packageMetaData, direction);
+        }
         if (intentString == null) {
             return false;
         }
@@ -1803,6 +1796,38 @@
         return true;
     }
 
+    private static int getGlobalAction(@Nullable Bundle metaData,
+            @View.FocusRealDirection int direction) {
+        if (metaData == null) {
+            return INVALID_GLOBAL_ACTION;
+        }
+        String directionString = DIRECTION_TO_STRING.get(direction);
+        return metaData.getInt(
+                String.format(OFF_SCREEN_NUDGE_GLOBAL_ACTION_FORMAT, directionString),
+                INVALID_GLOBAL_ACTION);
+    }
+
+    private static int getKeyCode(@Nullable Bundle metaData,
+            @View.FocusRealDirection int direction) {
+        if (metaData == null) {
+            return KEYCODE_UNKNOWN;
+        }
+        String directionString = DIRECTION_TO_STRING.get(direction);
+        return metaData.getInt(
+                String.format(OFF_SCREEN_NUDGE_KEY_CODE_FORMAT, directionString), KEYCODE_UNKNOWN);
+    }
+
+    @Nullable
+    private static String getIntentString(@Nullable Bundle metaData,
+            @View.FocusRealDirection int direction) {
+        if (metaData == null) {
+            return null;
+        }
+        String directionString = DIRECTION_TO_STRING.get(direction);
+        return metaData.getString(
+                String.format(OFF_SCREEN_NUDGE_INTENT_FORMAT, directionString), null);
+    }
+
     @Nullable
     private Bundle getForegroundActivityMetaData() {
         // The foreground activity can be null in a cold boot when the user has an active
@@ -1816,6 +1841,25 @@
                     PackageManager.GET_META_DATA);
             return activityInfo.metaData;
         } catch (PackageManager.NameNotFoundException e) {
+            L.v("Failed to find activity " + mForegroundActivity);
+            return null;
+        }
+    }
+
+    @Nullable
+    private Bundle getForegroundPackageMetaData() {
+        // The foreground activity can be null in a cold boot when the user has an active
+        // lockscreen.
+        if (mForegroundActivity == null) {
+            return null;
+        }
+
+        try {
+            ApplicationInfo applicationInfo = getPackageManager().getApplicationInfo(
+                    mForegroundActivity.getPackageName(), PackageManager.GET_META_DATA);
+            return applicationInfo.metaData;
+        } catch (PackageManager.NameNotFoundException e) {
+            L.v("Failed to find package " + mForegroundActivity.getPackageName());
             return null;
         }
     }
@@ -1848,12 +1892,16 @@
     }
 
     private void handleRotateEvent(boolean clockwise, int count, long eventTime) {
+        int rotationCount = getRotateAcceleration(count, eventTime);
+        if (mInProjectionMode) {
+            L.d("Injecting MotionEvent in projected mode");
+            injectMotionEvent(DEFAULT_DISPLAY, clockwise ? rotationCount : -rotationCount);
+            return;
+        }
         if (initFocus() || mFocusedNode == null) {
             return;
         }
 
-        int rotationCount = getRotateAcceleration(count, eventTime);
-
         // If the focused node is in direct manipulation mode, manipulate it directly.
         if (mInDirectManipulationMode) {
             if (DirectManipulationHelper.supportRotateDirectly(mFocusedNode)) {
@@ -1957,6 +2005,12 @@
                     + mForegroundActivity.getPackageName() + " to " + packageName);
             mInDirectManipulationMode = false;
         }
+
+        boolean isForegroundAppProjectedApp = mProjectedApps.contains(packageName);
+        if (mInProjectionMode != isForegroundAppProjectedApp) {
+            L.d((isForegroundAppProjectedApp ? "Entering" : "Exiting") + " projection mode");
+            mInProjectionMode = isForegroundAppProjectedApp;
+        }
     }
 
     private static boolean isValidAction(int action) {
@@ -2107,13 +2161,25 @@
                 /* flags= */ 0);
 
         if (motionEvent != null) {
-            mInputManager.injectInputEvent(motionEvent,
+            boolean success = mInputManager.injectInputEvent(motionEvent,
                     InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
+            L.successOrFailure("Injecting " + motionEvent, success);
         } else {
             L.w("Unable to obtain MotionEvent");
         }
     }
 
+    private void injectKeyEventForProjectedApp(int keyCode, int action) {
+        if (NAVIGATION_KEYCODE_TO_DPAD_KEYCODE_MAP.containsKey(keyCode)) {
+            // Convert KEYCODE_SYSTEM_NAVIGATION_* event to KEYCODE_DPAD_* event.
+            // TODO(b/217577254): Allow the OEM to specify the desired key codes for each projected
+            //  app.
+            keyCode = NAVIGATION_KEYCODE_TO_DPAD_KEYCODE_MAP.get(keyCode);
+        }
+        L.v("Injecting " + keyCode + " in projection mode");
+        injectKeyEvent(keyCode, action);
+    }
+
     private void injectKeyEventForDirection(@View.FocusRealDirection int direction, int action) {
         Integer keyCode = DIRECTION_TO_KEYCODE_MAP.get(direction);
         if (keyCode == null) {
@@ -2128,7 +2194,9 @@
         long upTime = SystemClock.uptimeMillis();
         KeyEvent keyEvent = new KeyEvent(
                 /* downTime= */ upTime, /* eventTime= */ upTime, action, keyCode, /* repeat= */ 0);
-        mInputManager.injectInputEvent(keyEvent, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
+        boolean success = mInputManager.injectInputEvent(keyEvent,
+                InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
+        L.successOrFailure("Injecting " + keyEvent, success);
     }
 
     /**
@@ -2187,11 +2255,11 @@
                 L.v("mFocusedNode is already focused: " + mFocusedNode);
                 return false;
             }
-            // If the focused node represents an HTML element in a WebView, we just assume the focus
-            // is already initialized here, and we'll handle it properly when the user uses the
-            // controller next time.
-            if (mNavigator.isInWebView(mFocusedNode)) {
-                L.v("mFocusedNode is in a WebView: " + mFocusedNode);
+            // If the focused node represents an HTML element in a WebView, or a Composable in a
+            // ComposeView, we just assume the focus is already initialized here, and we'll handle
+            // it properly when the user uses the controller next time.
+            if (mNavigator.isInVirtualNodeHierarchy(mFocusedNode)) {
+                L.v("mFocusedNode is in a WebView or ComposeView: " + mFocusedNode);
                 return false;
             }
         }
@@ -2229,30 +2297,56 @@
                 .collect(Collectors.toList());
 
         // If there are any windows with a non-FocusParkingView focused, set mFocusedNode
-        // to the focused view in the first such window and clear the focus in the others.
+        // to the focused node in the first such window and clear the focus in the others.
         boolean hasFocusedNode = false;
         for (AccessibilityWindowInfo window : sortedWindows) {
             AccessibilityNodeInfo root = window.getRoot();
-            if (root != null) {
-                AccessibilityNodeInfo focusedNode = mNavigator.findFocusedNodeInRoot(root);
-                root.recycle();
-                if (focusedNode != null) {
-                    if (!hasFocusedNode) {
-                        L.v("Setting mFocusedNode to the focused node: " + focusedNode);
-                        setFocusedNode(focusedNode);
-                    } else {
-                        boolean success = clearFocusInWindow(window);
-                        L.successOrFailure("Clear focus in the window: " + window, success);
-                    }
-                    focusedNode.recycle();
-                    hasFocusedNode = true;
-                }
+            if (root == null) {
+                L.e("Root node of the window is null: " + window);
+                continue;
             }
-        }
+            AccessibilityNodeInfo focusedNode = mNavigator.findFocusedNodeInRoot(root);
+            root.recycle();
+            if (focusedNode == null) {
+                continue;
+            }
 
-        // Don't consume the event since there is a focused view already.
-        if (hasFocusedNode) {
-            return false;
+            // If this window is not the first such window, clear its focus.
+            if (hasFocusedNode) {
+                boolean success = clearFocusInWindow(window);
+                L.successOrFailure("Clear focus in the window: " + window, success);
+                focusedNode.recycle();
+                continue;
+            }
+
+            hasFocusedNode = true;
+            // This window is the first such window. There are two cases:
+            // Case 1: It's in rotary mode. Just update mFocusedNode in this case.
+            if (prevInRotaryMode) {
+                L.v("Setting mFocusedNode to the focused node: " + focusedNode);
+                setFocusedNode(focusedNode);
+                focusedNode.recycle();
+                // Don't consume the event. In rotary mode, the focused view shows a focus
+                // highlight, so the user already knows where the focus is before manipulating
+                // the rotary controller, thus we should proceed to handle the event.
+                return false;
+            }
+            // Case 2: It's in touch mode. In this case we can't just update mFocusedNode because
+            // the application is still in touch mode. Performing ACTION_FOCUS on the focused node
+            // doesn't work either because it's no-op.
+            // In order to make the application exit touch mode, the workaround is to clear its
+            // focus then focus on it again.
+            boolean success = focusedNode.performAction(ACTION_CLEAR_FOCUS)
+                                && focusedNode.performAction(ACTION_FOCUS);
+            setFocusedNode(focusedNode);
+            setPendingFocusedNode(focusedNode);
+            L.successOrFailure("Clear focus then focus on the node again " + focusedNode,
+                    success);
+            focusedNode.recycle();
+            // Consume the event. In touch mode, the focused view doesn't show a focus highlight,
+            // so the user doesn't know where the focus is before manipulating the rotary
+            // controller, thus the event should be used to make the focus highlight appear.
+            return true;
         }
 
         if (mLastTouchedNode != null && focusLastTouchedNode()) {
@@ -2261,14 +2355,10 @@
         }
 
         for (AccessibilityWindowInfo window : sortedWindows) {
-            AccessibilityNodeInfo root = window.getRoot();
-            if (root != null) {
-                boolean success = restoreDefaultFocusInRoot(root);
-                root.recycle();
-                L.successOrFailure("Initialize focus inside the window: " + window, success);
-                if (success) {
-                    return true;
-                }
+            boolean success = restoreDefaultFocusInWindow(window);
+            L.successOrFailure("Initialize focus inside the window: " + window, success);
+            if (success) {
+                return true;
             }
         }
 
@@ -2287,10 +2377,10 @@
         mFocusedNode = Utils.refreshNode(mFocusedNode);
         if (mFocusedNode == null
                 // No need to clear focus if mFocusedNode is not focused. However, when it's a node
-                // in a WebView, its state might not be up to date, so mFocusedNode.isFocused()
-                // may return false even if the view represented by mFocusedNode is focused.
-                // So don't check the focused state if it's in WebView.
-                || (!mFocusedNode.isFocused() && !mNavigator.isInWebView(mFocusedNode))
+                // in a WebView or ComposeView, its state might not be up to date,
+                // so mFocusedNode.isFocused() may return false even if the view represented by
+                // mFocusedNode is focused. So don't check the focused state if it's in WebView.
+                || (!mFocusedNode.isFocused() && !mNavigator.isInVirtualNodeHierarchy(mFocusedNode))
                 || (targetFocus != null
                         && mFocusedNode.getWindowId() == targetFocus.getWindowId())) {
             return;
@@ -2395,16 +2485,8 @@
             L.d("No HUN window to focus");
             return false;
         }
-
-        AccessibilityNodeInfo hunRoot = hunWindow.getRoot();
-        if (hunRoot == null) {
-            L.d("No root in HUN Window to focus");
-            return false;
-        }
-
-        boolean success = restoreDefaultFocusInRoot(hunRoot);
-        hunRoot.recycle();
-        L.d("HUN window focus " + (success ? "successful" : "failed"));
+        boolean success = restoreDefaultFocusInWindow(hunWindow);
+        L.successOrFailure("HUN window focus ", success);
         return success;
     }
 
@@ -2658,12 +2740,13 @@
             L.d("No need to focus on targetNode because it's already focused: " + targetNode);
             return true;
         }
-        boolean isInWebView = mNavigator.isInWebView(targetNode);
-        if (!Utils.isFocusArea(targetNode) && Utils.hasFocus(targetNode) && !isInWebView) {
+        boolean isInVirtualHierarchy = mNavigator.isInVirtualNodeHierarchy(targetNode);
+        if (!Utils.isFocusArea(targetNode) && Utils.hasFocus(targetNode) && !isInVirtualHierarchy) {
             // One of targetNode's descendants is already focused, so we can't perform ACTION_FOCUS
             // on targetNode directly unless it's a FocusArea. The workaround is to clear the focus
             // first (by focusing on the FocusParkingView), then focus on targetNode. The
-            // prohibition on focusing a node that has focus doesn't apply in WebViews.
+            // prohibition on focusing a node that has focus doesn't apply in WebViews or
+            // ComposeViews.
             L.d("One of targetNode's descendants is already focused: " + targetNode);
             if (!clearFocusInCurrentWindow()) {
                 return false;
diff --git a/src/com/android/car/rotary/Utils.java b/src/com/android/car/rotary/Utils.java
index 5d431b2..89bfa58 100644
--- a/src/com/android/car/rotary/Utils.java
+++ b/src/com/android/car/rotary/Utils.java
@@ -69,6 +69,8 @@
     @VisibleForTesting
     static final String WEB_VIEW_CLASS_NAME = WebView.class.getName();
     @VisibleForTesting
+    static final String COMPOSE_VIEW_CLASS_NAME = "androidx.compose.ui.platform.ComposeView";
+    @VisibleForTesting
     static final String SURFACE_VIEW_CLASS_NAME = SurfaceView.class.getName();
 
     private Utils() {
@@ -142,6 +144,11 @@
         // are always empty for views that are off screen.
         Rect bounds = new Rect();
         node.getBoundsInParent(bounds);
+        if (bounds.isEmpty()) {
+            // Some nodes, such as those in ComposeView hierarchies may not set bounds in parents,
+            // since the APIs are deprecated. So, check bounds in screen just in case.
+            node.getBoundsInScreen(bounds);
+        }
         return !bounds.isEmpty();
     }
 
@@ -263,6 +270,21 @@
         return className != null && WEB_VIEW_CLASS_NAME.contentEquals(className);
     }
 
+    /**
+     * Returns whether {@code node} represents a {@code ComposeView}.
+     * <p>
+     * The descendants of a node representing a {@code ComposeView} represent "Composables" rather
+     * than {@link android.view.View}s so {@link AccessibilityNodeInfo#focusSearch} currently does
+     * not work for these nodes. The outcome of b/192274274 could change this.
+     *
+     * TODO(b/192274274): This method is only necessary until {@code ComposeView} supports
+     *  {@link AccessibilityNodeInfo#focusSearch(int)}.
+     */
+    static boolean isComposeView(@NonNull AccessibilityNodeInfo node) {
+        CharSequence className = node.getClassName();
+        return className != null && COMPOSE_VIEW_CLASS_NAME.contentEquals(className);
+    }
+
     /** Returns whether the given {@code node} represents a {@link SurfaceView}. */
     static boolean isSurfaceView(@NonNull AccessibilityNodeInfo node) {
         CharSequence className = node.getClassName();
diff --git a/tests/unit/src/com/android/car/rotary/NavigatorTest.java b/tests/unit/src/com/android/car/rotary/NavigatorTest.java
index 696b83e..db2e257 100644
--- a/tests/unit/src/com/android/car/rotary/NavigatorTest.java
+++ b/tests/unit/src/com/android/car/rotary/NavigatorTest.java
@@ -28,7 +28,6 @@
 import android.app.UiAutomation;
 import android.content.Intent;
 import android.graphics.Rect;
-import android.view.Display;
 import android.view.View;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityWindowInfo;
@@ -97,9 +96,11 @@
         mDisplayBounds = new Rect(0, 0, 1080, 920);
         mHunWindowBounds = new Rect(50, 10, 950, 200);
         // The values of displayWidth and displayHeight don't affect the test, so just use 0.
-        mNavigator = new Navigator(/* displayWidth= */ mDisplayBounds.right,
-                /* displayHeight= */ mDisplayBounds.bottom,
-                mHunWindowBounds.left, mHunWindowBounds.right, /* showHunOnBottom= */ false);
+        mNavigator = new Navigator(/* displayWidth= */ 0,
+                /* displayHeight= */ 0,
+                mHunWindowBounds.left,
+                mHunWindowBounds.right,
+                /* showHunOnBottom= */ false);
         mNavigator.setNodeCopier(MockNodeCopierProvider.get());
         mNodeBuilder = new NodeBuilder(new ArrayList<>());
     }
@@ -1335,54 +1336,6 @@
         assertThat(isHunWindow).isTrue();
     }
 
-    @Test
-    public void testIsMainApplicationWindow_returnsTrue() {
-        // The only way to create an AccessibilityWindowInfo in the test is via mock.
-        AccessibilityWindowInfo window = new WindowBuilder()
-                .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
-                .setBoundsInScreen(mDisplayBounds)
-                .setDisplayId(Display.DEFAULT_DISPLAY)
-                .build();
-        boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
-        assertThat(isMainApplicationWindow).isTrue();
-    }
-
-    @Test
-    public void testIsMainApplicationWindow_wrongDisplay_returnsFalse() {
-        // The only way to create an AccessibilityWindowInfo in the test is via mock.
-        AccessibilityWindowInfo window = new WindowBuilder()
-                .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
-                .setBoundsInScreen(mDisplayBounds)
-                .setDisplayId(1)
-                .build();
-        boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
-        assertThat(isMainApplicationWindow).isFalse();
-    }
-
-    @Test
-    public void testIsMainApplicationWindow_wrongType_returnsFalse() {
-        // The only way to create an AccessibilityWindowInfo in the test is via mock.
-        AccessibilityWindowInfo window = new WindowBuilder()
-                .setType(AccessibilityWindowInfo.TYPE_SYSTEM)
-                .setBoundsInScreen(mDisplayBounds)
-                .setDisplayId(Display.DEFAULT_DISPLAY)
-                .build();
-        boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
-        assertThat(isMainApplicationWindow).isFalse();
-    }
-
-    @Test
-    public void testIsMainApplicationWindow_wrongBounds_returnsFalse() {
-        // The only way to create an AccessibilityWindowInfo in the test is via mock.
-        AccessibilityWindowInfo window = new WindowBuilder()
-                .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
-                .setBoundsInScreen(mHunWindowBounds)
-                .setDisplayId(Display.DEFAULT_DISPLAY)
-                .build();
-        boolean isMainApplicationWindow = mNavigator.isMainApplicationWindow(window);
-        assertThat(isMainApplicationWindow).isFalse();
-    }
-
     /**
      * Tests {@link Navigator#getAncestorFocusArea} in the following node tree:
      * <pre>