Support rotary IME

- Let OEM specify a rotary IME. When specified, automatically switch
  IMEs when the user switches modes. Use an overlay to reliably detect
  the transition to touch mode.
- Move focus to the IME when it opens. Leave the editable field
  focused.
- Return focus to the editable field when the user closes the IME or
  nudges back up.
- Close the IME when the user navigates away from the editable field.

Bug: 131421840
Bug: 154777887
Test: atest CarRotaryControllerRoboTests
Change-Id: Id04aa785d6bc90047502f69ad4196334790d4b82
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9cf205a..8b07af9 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -26,6 +26,12 @@
     <uses-permission android:name="android.permission.INJECT_EVENTS"
                      android:protectionLevel="signature"/>
 
+    <!-- Allows us to toggle between touch and rotary IME. -->
+    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"/>
+
+    <!-- Allows us to have an overlay to capture touch events. -->
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+
     <!-- RotaryService needs to be directBootAware so that it can start before the user is unlocked. -->
     <application>
         <service
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 0000000..53ec71d
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<resources>
+    <!-- Component name of rotary IME. Empty if none. -->
+    <string name="rotary_input_method" translatable="false"></string>
+</resources>
diff --git a/src/com/android/car/rotary/Navigator.java b/src/com/android/car/rotary/Navigator.java
index 5f98701..d367f21 100644
--- a/src/com/android/car/rotary/Navigator.java
+++ b/src/com/android/car/rotary/Navigator.java
@@ -104,27 +104,45 @@
     }
 
     /**
+     * Returns the target focusable for a nudge. Convenience method for when the {@code editNode} is
+     * null.
+     *
+     * @see #findNudgeTarget(List, AccessibilityNodeInfo, int, AccessibilityNodeInfo)
+     */
+    @Nullable
+    AccessibilityNodeInfo findNudgeTarget(@NonNull List<AccessibilityWindowInfo> windows,
+            @NonNull AccessibilityNodeInfo sourceNode, int direction) {
+        return findNudgeTarget(windows, sourceNode, direction, null);
+    }
+
+    /**
      * Returns the target focusable for a nudge:
      * <ol>
      *     <li>If the HUN is present and the nudge is towards it, a focusable in the HUN is
      *         returned. See {@link #findHunNudgeTarget} for details.
+     *     <li>If the nudge is leaving the IME, return focus to the view that was left focused when
+     *         the IME appeared.
      *     <li>Otherwise, a target focus area is chosen, either from the focus area history or by
      *         choosing the best candidate. See {@link #findNudgeTargetFocusArea} for details.
      *     <li>Finally a focusable view within the chosen focus area is chosen, either from the
      *         focus history or by choosing the best candidate.
      * </ol>
-     * The caller is responsible for recycling the result.
+     * Saves nudge history except when nudging out of the IME. The caller is responsible for
+     * recycling the result.
      *
      * @param windows    a list of windows to search from
      * @param sourceNode the current focus
      * @param direction  nudge direction, must be {@link View#FOCUS_UP}, {@link View#FOCUS_DOWN},
      *                   {@link View#FOCUS_LEFT}, or {@link View#FOCUS_RIGHT}
+     * @param editNode   node currently being edited by the IME, if any
      * @return a view that can take focus (visible, focusable and enabled) within another {@link
      *         FocusArea}, which is in the given {@code direction} from the current {@link
      *         FocusArea}, or null if not found
      */
+    @Nullable
     AccessibilityNodeInfo findNudgeTarget(@NonNull List<AccessibilityWindowInfo> windows,
-            @NonNull AccessibilityNodeInfo sourceNode, int direction) {
+            @NonNull AccessibilityNodeInfo sourceNode, int direction,
+            @Nullable AccessibilityNodeInfo editNode) {
         // If the user is trying to nudge to the HUN, search for a focus area in the HUN window.
         AccessibilityNodeInfo hunNudgeTarget = findHunNudgeTarget(windows, sourceNode, direction);
         if (hunNudgeTarget != null) {
@@ -135,15 +153,36 @@
         AccessibilityNodeInfo currentFocusArea = getAncestorFocusArea(sourceNode);
         AccessibilityNodeInfo targetFocusArea =
                 findNudgeTargetFocusArea(windows, sourceNode, currentFocusArea, direction);
-        Utils.recycleNode(currentFocusArea);
         if (targetFocusArea == null) {
+            Utils.recycleNode(currentFocusArea);
             return null;
         }
 
+        // If the user is nudging out of an IME, return to the field they were editing, if any.
+        // Don't save nudge history in this case.
+        AccessibilityWindowInfo sourceWindow =
+                Utils.findWindowWithId(windows, sourceNode.getWindowId());
+        AccessibilityWindowInfo targetWindow =
+                Utils.findWindowWithId(windows, targetFocusArea.getWindowId());
+        if (sourceWindow != null && targetWindow != null
+                && sourceWindow.getType() == AccessibilityWindowInfo.TYPE_INPUT_METHOD
+                && targetWindow.getType() != AccessibilityWindowInfo.TYPE_INPUT_METHOD) {
+            if (editNode != null && editNode.getWindowId() == targetWindow.getId()) {
+                Utils.recycleNode(currentFocusArea);
+                Utils.recycleNode(targetFocusArea);
+                return copyNode(editNode);
+            }
+        }
+
         // Return the recently focused node within the target focus area, if any.
         AccessibilityNodeInfo cachedFocusedNode =
                 mRotaryCache.getFocusedNode(targetFocusArea, elapsedRealtime);
         if (cachedFocusedNode != null) {
+            // Save nudge history.
+            mRotaryCache.saveTargetFocusArea(
+                    currentFocusArea, targetFocusArea, direction, elapsedRealtime);
+
+            Utils.recycleNode(currentFocusArea);
             Utils.recycleNode(targetFocusArea);
             return cachedFocusedNode;
         }
@@ -156,6 +195,13 @@
         AccessibilityNodeInfo bestCandidate =
                 chooseBestNudgeCandidate(sourceNode, candidateNodes, direction);
 
+        // Save nudge history if we're going to move focus.
+        if (bestCandidate != null) {
+            mRotaryCache.saveTargetFocusArea(
+                    currentFocusArea, targetFocusArea, direction, elapsedRealtime);
+        }
+
+        Utils.recycleNode(currentFocusArea);
         Utils.recycleNodes(candidateNodes);
         Utils.recycleNode(targetFocusArea);
         return bestCandidate;
@@ -183,6 +229,7 @@
      *         if the HUN isn't present, the nudge isn't in the direction of the HUN, or the HUN
      *         contains no views that can take focus
      */
+    @Nullable
     private AccessibilityNodeInfo findHunNudgeTarget(@NonNull List<AccessibilityWindowInfo> windows,
             @NonNull AccessibilityNodeInfo sourceNode, int direction) {
         if (direction != mHunNudgeDirection) {
@@ -446,8 +493,8 @@
     /**
      * Returns the target focus area for a nudge in the given {@code direction} from the current
      * focus, or null if not found. Checks the cache first. If nothing is found in the cache,
-     * returns the best nudge target from among all the candidate focus areas. In all cases, the
-     * nudge back is saved in the cache. The caller is responsible for recycling the result.
+     * returns the best nudge target from among all the candidate focus areas. The caller is
+     * responsible for updating the cache and recycling the result.
      */
     private AccessibilityNodeInfo findNudgeTargetFocusArea(
             @NonNull List<AccessibilityWindowInfo> windows,
@@ -459,10 +506,6 @@
         AccessibilityNodeInfo cachedTargetFocusArea =
                 mRotaryCache.getTargetFocusArea(currentFocusArea, direction, elapsedRealtime);
         if (cachedTargetFocusArea != null && Utils.canHaveFocus(cachedTargetFocusArea)) {
-            // We already got nudge history in the cache. Before nudging back, let's save "nudge
-            // back" history.
-            mRotaryCache.saveTargetFocusArea(
-                    currentFocusArea, cachedTargetFocusArea, direction, elapsedRealtime);
             return cachedTargetFocusArea;
         }
         Utils.recycleNode(cachedTargetFocusArea);
@@ -503,13 +546,6 @@
         AccessibilityNodeInfo targetFocusArea =
                 chooseBestNudgeCandidate(focusedNode, candidateFocusAreas, direction);
         Utils.recycleNodes(candidateFocusAreas);
-
-        if (targetFocusArea != null) {
-            // Save nudge history.
-            mRotaryCache.saveTargetFocusArea(
-                    currentFocusArea, targetFocusArea, direction, elapsedRealtime);
-        }
-
         return targetFocusArea;
     }
 
@@ -701,6 +737,7 @@
      *
      * @param candidates could be a list of {@link FocusArea}s, or a list of focusable views
      */
+    @Nullable
     private AccessibilityNodeInfo chooseBestNudgeCandidate(
             @NonNull AccessibilityNodeInfo sourceNode,
             @NonNull List<AccessibilityNodeInfo> candidates,
@@ -785,6 +822,11 @@
         return result;
     }
 
+    /** An interface for a lambda that returns an {@link AccessibilityNodeInfo}. */
+    interface NodeProvider {
+        AccessibilityNodeInfo provideNode();
+    }
+
     /** Result from {@link #findRotateTarget}. */
     static class FindRotateTargetResult {
         @NonNull final AccessibilityNodeInfo node;
diff --git a/src/com/android/car/rotary/RotaryService.java b/src/com/android/car/rotary/RotaryService.java
index a65db33..862b8fd 100644
--- a/src/com/android/car/rotary/RotaryService.java
+++ b/src/com/android/car/rotary/RotaryService.java
@@ -16,7 +16,10 @@
 package com.android.car.rotary;
 
 import static android.accessibilityservice.AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS;
+import static android.provider.Settings.Secure.DEFAULT_INPUT_METHOD;
 import static android.view.Display.DEFAULT_DISPLAY;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
+import static android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
 import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
 import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED;
 import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED;
@@ -25,6 +28,7 @@
 import static android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SCROLLED;
 import static android.view.accessibility.AccessibilityEvent.TYPE_WINDOWS_CHANGED;
 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_CLICK;
 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS;
@@ -32,21 +36,30 @@
 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK;
 import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD;
 import static android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD;
+import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
+import static android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD;
 
 import android.accessibilityservice.AccessibilityService;
 import android.accessibilityservice.AccessibilityServiceInfo;
 import android.car.Car;
 import android.car.input.CarInputManager;
 import android.car.input.RotaryEvent;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Resources;
+import android.database.ContentObserver;
+import android.graphics.PixelFormat;
 import android.hardware.display.DisplayManager;
 import android.hardware.input.InputManager;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
 import android.os.SystemClock;
+import android.provider.Settings;
 import android.text.TextUtils;
 import android.view.Display;
+import android.view.Gravity;
 import android.view.InputDevice;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
@@ -55,6 +68,7 @@
 import android.view.accessibility.AccessibilityEvent;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityWindowInfo;
+import android.widget.FrameLayout;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -145,6 +159,13 @@
     private AccessibilityNodeInfo mFocusedNode = null;
 
     /**
+     * The node being edited by the IME, if any. When focus moves to the IME, if it's moving from an
+     * editable node, we leave it focused. This variable is used to keep track of it so that we can
+     * return to it when the user nudges out of the IME.
+     */
+    private AccessibilityNodeInfo mEditNode = null;
+
+    /**
      * The focus area that contains the {@link #mFocusedNode}. It's null if {@link #mFocusedNode} is
      * null.
      */
@@ -191,6 +212,16 @@
      */
     private long mIgnoreViewClickedUntil;
 
+    /** Component name of rotary IME. Empty if none. */
+    private String mRotaryInputMethod;
+
+    /** Component name of IME used in touch mode. Null until first observed. */
+    @Nullable
+    private String mTouchInputMethod;
+
+    /** Observer to update {@link #mTouchInputMethod} when the user switches IMEs. */
+    private ContentObserver mInputMethodObserver;
+
     /**
      * Possible actions to do after receiving {@link AccessibilityEvent#TYPE_VIEW_SCROLLED}.
      *
@@ -339,6 +370,8 @@
                 hunLeft,
                 hunRight,
                 showHunOnBottom);
+
+        mRotaryInputMethod = res.getString(R.string.rotary_input_method);
     }
 
     /**
@@ -392,6 +425,12 @@
         }
 
         mInputManager = getSystemService(InputManager.class);
+
+        // Add an overlay to capture touch events.
+        addTouchOverlay();
+
+        // Register an observer to update mTouchInputMethod whenever the user switches IMEs.
+        registerInputMethodObserver();
     }
 
     @Override
@@ -401,6 +440,7 @@
 
     @Override
     public void onDestroy() {
+        unregisterInputMethodObserver();
         if (mCarInputManager != null) {
             mCarInputManager.releaseInputEventCapture(CarInputManager.TARGET_DISPLAY_TYPE_MAIN);
         }
@@ -439,7 +479,17 @@
                 break;
             }
             case TYPE_WINDOWS_CHANGED: {
-                handleWindowsChangedEvent(event);
+                // Ignore these events if we're in touch mode.
+                if (!mInRotaryMode) {
+                    return;
+                }
+
+                if ((event.getWindowChanges() & WINDOWS_CHANGE_REMOVED) != 0) {
+                    handleWindowRemovedEvent(event);
+                }
+                if ((event.getWindowChanges() & WINDOWS_CHANGE_ADDED) != 0) {
+                    handleWindowAddedEvent(event);
+                }
                 break;
             }
             default:
@@ -495,6 +545,89 @@
         // Do nothing.
     }
 
+    /**
+     * Adds an overlay to capture touch events. The overlay has zero width and height so
+     * it doesn't prevent other windows from receiving touch events. It sets
+     * {@link WindowManager.LayoutParams#FLAG_WATCH_OUTSIDE_TOUCH} so it receives
+     * {@link MotionEvent#ACTION_OUTSIDE} events for touches anywhere on the screen. This
+     * is used to exit rotary mode when the user touches the screen, even if the touch
+     * isn't considered a click.
+     */
+    private void addTouchOverlay() {
+        FrameLayout frameLayout = new FrameLayout(this);
+        FrameLayout.LayoutParams frameLayoutParams =
+                new FrameLayout.LayoutParams(/* width= */ 0, /* height= */ 0);
+        frameLayout.setLayoutParams(frameLayoutParams);
+        frameLayout.setOnTouchListener((view, event) -> {
+            // We're trying to identify real touches from the user's fingers, but using the rotary
+            // controller to press keys in the rotary IME also triggers this touch listener, so we
+            // ignore these touches.
+            if (mIgnoreViewClickedNode == null
+                    || event.getEventTime() >= mIgnoreViewClickedUntil) {
+                onTouchEvent();
+            }
+            return false;
+        });
+        WindowManager.LayoutParams windowLayoutParams = new WindowManager.LayoutParams(
+                /* w= */ 0,
+                /* h= */ 0,
+                TYPE_APPLICATION_OVERLAY,
+                FLAG_NOT_FOCUSABLE | FLAG_WATCH_OUTSIDE_TOUCH,
+                PixelFormat.TRANSPARENT);
+        windowLayoutParams.gravity = Gravity.RIGHT | Gravity.TOP;
+        WindowManager windowManager = getSystemService(WindowManager.class);
+        windowManager.addView(frameLayout, windowLayoutParams);
+    }
+
+    private void onTouchEvent() {
+        if (!mInRotaryMode) {
+            return;
+        }
+
+        // Enter touch mode once the user touches the screen.
+        setInRotaryMode(false);
+
+        // Explicitly clear focus when user uses touch.
+        if (mFocusedNode != null) {
+            clearFocusInCurrentWindow();
+        }
+    }
+
+    /**
+     * Registers an observer to updates {@link #mTouchInputMethod} whenever the user switches IMEs.
+     */
+    private void registerInputMethodObserver() {
+        if (mInputMethodObserver != null) {
+            throw new IllegalStateException("Input method observer already registered");
+        }
+        ContentResolver contentResolver = getContentResolver();
+        mInputMethodObserver = new ContentObserver(new Handler(Looper.myLooper())) {
+            @Override
+            public void onChange(boolean selfChange) {
+                // Either the user switched input methods or we did. In the former case, update
+                // mTouchInputMethod so we can switch back after switching to the rotary input
+                // method.
+                String inputMethod =
+                        Settings.Secure.getString(contentResolver, DEFAULT_INPUT_METHOD);
+                if (inputMethod != null && !inputMethod.equals(mRotaryInputMethod)) {
+                    mTouchInputMethod = inputMethod;
+                }
+            }
+        };
+        contentResolver.registerContentObserver(
+                Settings.Secure.getUriFor(DEFAULT_INPUT_METHOD),
+                /* notifyForDescendants= */ false,
+                mInputMethodObserver);
+    }
+
+    /** Unregisters the observer registered by {@link #registerInputMethodObserver}. */
+    private void unregisterInputMethodObserver() {
+        if (mInputMethodObserver != null) {
+            getContentResolver().unregisterContentObserver(mInputMethodObserver);
+            mInputMethodObserver = null;
+        }
+    }
+
     private static boolean isValidDisplayId(int displayId) {
         if (displayId == CarInputManager.TARGET_DISPLAY_TYPE_MAIN) {
             return true;
@@ -550,6 +683,9 @@
                     handleCenterButtonEvent(action, /* longClick= */ true);
                 }
                 return true;
+            case KeyEvent.KEYCODE_G:
+                handleCenterButtonEvent(action, /* longClick= */ true);
+                return true;
             case KeyEvent.KEYCODE_BACK:
                 if (mInDirectManipulationMode) {
                     handleBackButtonEvent(action);
@@ -623,9 +759,8 @@
     private void handleViewClickedEvent(@NonNull AccessibilityEvent event) {
         // A view was clicked. If we triggered the click via performAction(ACTION_CLICK) or
         // by injecting KEYCODE_DPAD_CENTER, we ignore it. Otherwise, we assume the user
-        // touched the screen. In this case, we exit rotary mode if necessary, update
-        // mLastTouchedNode, and clear the focus if the user touched a view in a different
-        // window.
+        // touched the screen. In this case, we update mLastTouchedNode, and clear the focus
+        // if the user touched a view in a different window.
         // To decide whether the click was triggered by us, we can compare the source node
         // in the event with mIgnoreViewClickedNode. If they're equal, the click was
         // triggered by us. But there is a corner case. If a dialog shows up after we
@@ -640,17 +775,9 @@
                 && event.getEventTime() < mIgnoreViewClickedUntil
                 && ((sourceNode == null) || mIgnoreViewClickedNode.equals(sourceNode))) {
             setIgnoreViewClickedNode(null);
-        } else {
-            // Enter touch mode once the user touches the screen.
-            mInRotaryMode = false;
-            if (sourceNode != null) {
-                // Explicitly clear focus when user uses touch in another window.
-                maybeClearFocusInCurrentWindow(sourceNode);
-
-                if (!sourceNode.equals(mLastTouchedNode)) {
-                    setLastTouchedNode(sourceNode);
-                }
-            }
+        } else if (sourceNode != null && !sourceNode.equals(mLastTouchedNode)) {
+            // Update mLastTouchedNode.
+            setLastTouchedNode(sourceNode);
         }
         Utils.recycleNode(sourceNode);
     }
@@ -714,21 +841,78 @@
         Utils.recycleNode(sourceNode);
     }
 
-    /** Handles {@link AccessibilityEvent#TYPE_WINDOWS_CHANGED} event. */
-    private void handleWindowsChangedEvent(@NonNull AccessibilityEvent event) {
-        if ((event.getWindowChanges() & WINDOWS_CHANGE_REMOVED) != 0
-                && mInRotaryMode
-                && mFocusedNode != null
-                && mFocusedNode.getWindowId() == event.getWindowId()) {
-            // The window containing the focused node is gone. Restore focus to the last
-            // focused node in the last focused window.
-            setFocusedNode(null);
-            AccessibilityNodeInfo newFocus = mNavigator.getMostRecentFocus();
-            if (newFocus != null) {
-                performFocusAction(newFocus);
-                newFocus.recycle();
-            }
+    /**
+     * Handles a {@link AccessibilityEvent#TYPE_WINDOWS_CHANGED} event indicating that a window was
+     * removed. Attempts to restore the most recent focus when the window containing
+     * {@link #mFocusedNode} is removed.
+     */
+    private void handleWindowRemovedEvent(@NonNull AccessibilityEvent event) {
+        // We only care about this event when the window that was removed contains the focused node.
+        // Ignore other events.
+        if (mFocusedNode == null || mFocusedNode.getWindowId() != event.getWindowId()) {
+            return;
         }
+
+        // Restore focus to the last focused node in the last focused window.
+        setFocusedNode(null);
+        AccessibilityNodeInfo newFocus = mNavigator.getMostRecentFocus();
+        if (newFocus != null) {
+            // If the user closed the IME, focus will return to the most recent focus which will be
+            // the node being edited. In this case, we no longer need to keep track of it.
+            mEditNode = Utils.refreshNode(mEditNode);
+            if (newFocus.equals(mEditNode)) {
+                setEditNode(null);
+            }
+            performFocusAction(newFocus);
+            newFocus.recycle();
+        }
+    }
+
+    /**
+     * Handles a {@link AccessibilityEvent#TYPE_WINDOWS_CHANGED} event indicating that a window was
+     * added. Moves focus to the IME window when it appears.
+     */
+    private void handleWindowAddedEvent(@NonNull AccessibilityEvent event) {
+        // We only care about this event when the window that was added doesn't contains the focused
+        // node. Ignore other events.
+        if (mFocusedNode != null && mFocusedNode.getWindowId() == event.getWindowId()) {
+            return;
+        }
+
+        // We only care about this event when the new window is an IME. Ignore other events.
+        List<AccessibilityWindowInfo> windows = getWindows();
+        AccessibilityWindowInfo window = Utils.findWindowWithId(windows, event.getWindowId());
+        if (window == null) {
+            L.w("Can't find added window");
+            Utils.recycleWindows(windows);
+            return;
+        }
+        if (window.getType() != TYPE_INPUT_METHOD) {
+            Utils.recycleWindows(windows);
+            return;
+        }
+
+        // Move focus to the IME.
+        AccessibilityNodeInfo root = window.getRoot();
+        if (root == null) {
+            L.w("No root node in " + window);
+            Utils.recycleWindows(windows);
+            return;
+        }
+        // TODO: Use app:defaultFocus
+        AccessibilityNodeInfo nodeToFocus = mNavigator.findFirstFocusDescendant(root);
+        if (nodeToFocus != null) {
+            L.d("Move focus to IME");
+            // If the focused node is editable, save it so that we can return to it when the user
+            // nudges out of the IME.
+            if  (mFocusedNode != null && mFocusedNode.isEditable()) {
+                setEditNode(mFocusedNode);
+            }
+            performFocusAction(nodeToFocus);
+            nodeToFocus.recycle();
+        }
+        root.recycle();
+        Utils.recycleWindows(windows);
     }
 
     private static int getKeyCode(KeyEvent event) {
@@ -811,14 +995,21 @@
         // If the focused node is not in direct manipulation mode, move the focus.
         // TODO(b/152438801): sometimes getWindows() takes 10s after boot.
         List<AccessibilityWindowInfo> windows = getWindows();
+        mEditNode = Utils.refreshNode(mEditNode);
         AccessibilityNodeInfo targetNode =
-                mNavigator.findNudgeTarget(windows, mFocusedNode, direction);
+                mNavigator.findNudgeTarget(windows, mFocusedNode, direction, mEditNode);
         Utils.recycleWindows(windows);
         if (targetNode == null) {
             L.w("Failed to find nudge target");
             return;
         }
 
+        // If the user is nudging out of the IME to the node being edited, we no longer need to keep
+        // track of the node being edited.
+        if (targetNode.equals(mEditNode)) {
+            setEditNode(null);
+        }
+
         // Android doesn't clear focus automatically when focus is set in another window.
         maybeClearFocusInCurrentWindow(targetNode);
 
@@ -971,7 +1162,7 @@
             L.w("Failed to get window of " + node);
             return false;
         }
-        boolean result = window.getType() == AccessibilityWindowInfo.TYPE_APPLICATION;
+        boolean result = window.getType() == TYPE_APPLICATION;
         Utils.recycleWindow(window);
         return result;
     }
@@ -1119,6 +1310,7 @@
      */
     private void refreshSavedNodes() {
         mFocusedNode = Utils.refreshNode(mFocusedNode);
+        mEditNode = Utils.refreshNode(mEditNode);
         mLastTouchedNode = Utils.refreshNode(mLastTouchedNode);
         mScrollableContainer = Utils.refreshNode(mScrollableContainer);
         mPreviousFocusedNode = Utils.refreshNode(mPreviousFocusedNode);
@@ -1143,7 +1335,7 @@
      */
     private boolean initFocus() {
         refreshSavedNodes();
-        mInRotaryMode = true;
+        setInRotaryMode(true);
         if (mFocusedNode != null) {
             return false;
         }
@@ -1161,12 +1353,31 @@
         return true;
     }
 
-    /** Clears the current rotary focus if {@code targetFocus} is in a different window. */
+    /**
+     * Clears the current rotary focus if {@code targetFocus} is in a different window unless focus
+     * is moving from an editable field to the IME.
+     */
     private void maybeClearFocusInCurrentWindow(@NonNull AccessibilityNodeInfo targetFocus) {
         if (mFocusedNode == null || !mFocusedNode.isFocused()
                 || mFocusedNode.getWindowId() == targetFocus.getWindowId()) {
             return;
         }
+
+        // If we're moving from an editable node to the IME, don't clear focus, but save the
+        // editable node so that we can return to it when the user nudges out of the IME.
+        if (mFocusedNode.isEditable()) {
+            AccessibilityWindowInfo targetWindow = targetFocus.getWindow();
+            if (targetWindow != null) {
+                boolean isTargetInIme = targetWindow.getType() == TYPE_INPUT_METHOD;
+                targetWindow.recycle();
+                if (isTargetInIme) {
+                    L.d("Leaving editable field focused");
+                    setEditNode(mFocusedNode);
+                    return;
+                }
+            }
+        }
+
         if (clearFocusInCurrentWindow()) {
             setFocusedNode(null);
         }
@@ -1273,6 +1484,9 @@
             L.d("Exit direct manipulation mode since there is no focused node");
         }
 
+        // Close the IME when navigating from an editable view to a non-editable view.
+        maybeCloseIme(focusedNode);
+
         // Recycle mPreviousFocusedNode only when it's not the same with focusedNode.
         if (mPreviousFocusedNode != focusedNode) {
             Utils.recycleNode(mPreviousFocusedNode);
@@ -1302,6 +1516,60 @@
         }
     }
 
+    private void setEditNode(@Nullable AccessibilityNodeInfo editNode) {
+        if ((mEditNode == null && editNode == null) ||
+                (mEditNode != null && mEditNode.equals(editNode))) {
+            return;
+        }
+        Utils.recycleNode(mEditNode);
+        mEditNode = copyNode(editNode);
+    }
+
+    /**
+     * Closes the IME if {@code newFocusedNode} isn't editable and isn't in the IME, and the
+     * previously focused node is editable.
+     */
+    private void maybeCloseIme(@Nullable AccessibilityNodeInfo newFocusedNode) {
+        // The previously focused node is {@link #mFocusedNode} unless it's null, in which case
+        // it's {@link #mPreviousFocusedNode}. This logic is needed because {@link #mFocusedNode}
+        // is null briefly when navigating between windows.
+        AccessibilityNodeInfo prevNode = mFocusedNode != null ? mFocusedNode : mPreviousFocusedNode;
+
+        // Don't close the IME unless we're moving from an editable view to a non-editable view.
+        if (prevNode == null || newFocusedNode == null
+                || !prevNode.isEditable() || newFocusedNode.isEditable()) {
+            return;
+        }
+
+        // Don't close the IME if we're navigating to the IME.
+        AccessibilityWindowInfo nextWindow = newFocusedNode.getWindow();
+        if (nextWindow != null && nextWindow.getType() == TYPE_INPUT_METHOD) {
+            Utils.recycleWindow(nextWindow);
+            return;
+        }
+
+        // To close the IME, we'll ask the FocusParkingView in the previous window to perform an
+        // action, so first we need to find the FocusParkingView.
+        Utils.recycleWindow(nextWindow);
+        AccessibilityWindowInfo prevWindow = prevNode.getWindow();
+        if (prevWindow == null) {
+            return;
+        }
+        AccessibilityNodeInfo focusParkingView = mNavigator.findFocusParkingView(prevWindow);
+        if (focusParkingView == null) {
+            L.e("No FocusParkingView in " + prevWindow);
+            prevWindow.recycle();
+            return;
+        }
+
+        // Ask the FocusParkingView to perform the action to close the IME.
+        prevWindow.recycle();
+        if (!focusParkingView.performAction(AccessibilityNodeInfo.ACTION_COLLAPSE)) {
+            L.w("Failed to close IME");
+        }
+        focusParkingView.recycle();
+    }
+
     private void setScrollableContainer(@Nullable AccessibilityNodeInfo scrollableContainer) {
         if ((mScrollableContainer == null && scrollableContainer == null)
                 || (mScrollableContainer != null
@@ -1345,6 +1613,32 @@
     }
 
     /**
+     * Sets {@link #mInRotaryMode}, toggling IMEs when the value changes and a rotary input method
+     * has been configured.
+     */
+    private void setInRotaryMode(boolean inRotaryMode) {
+        if (inRotaryMode == mInRotaryMode) {
+            return;
+        }
+        mInRotaryMode = inRotaryMode;
+        if (mRotaryInputMethod.isEmpty()) {
+            L.w("No rotary IME configured");
+            return;
+        }
+        if (!inRotaryMode && mTouchInputMethod == null) {
+            L.w("Touch IME not observed");
+            return;
+        }
+        // Switch to the rotary IME or the IME in use before we switched to the rotary IME.
+        String newIme = inRotaryMode ? mRotaryInputMethod : mTouchInputMethod;
+        boolean result =
+                Settings.Secure.putString(getContentResolver(), DEFAULT_INPUT_METHOD, newIme);
+        if (!result) {
+            L.w("Failed to switch IME: %s", newIme);
+        }
+    }
+
+    /**
      * Performs {@link AccessibilityNodeInfo#ACTION_FOCUS} on the given {@code targetNode}.
      *
      * @return true if {@code targetNode} was focused already or became focused after performing
diff --git a/src/com/android/car/rotary/Utils.java b/src/com/android/car/rotary/Utils.java
index 91f2cc6..9d4d5fd 100644
--- a/src/com/android/car/rotary/Utils.java
+++ b/src/com/android/car/rotary/Utils.java
@@ -187,4 +187,20 @@
             }
         }
     }
+
+    /**
+     * Returns a reference to the window with ID {@code windowId} or null if not found.
+     * <p>
+     * <strong>Note:</strong> Do not recycle the result.
+     */
+    @Nullable
+    static AccessibilityWindowInfo findWindowWithId(@NonNull List<AccessibilityWindowInfo> windows,
+            int windowId) {
+        for (AccessibilityWindowInfo window : windows) {
+            if (window.getId() == windowId) {
+                return window;
+            }
+        }
+        return null;
+    }
 }
diff --git a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
index b6e2156..ebaddc8 100644
--- a/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
+++ b/tests/robotests/src/com/android/car/rotary/NavigatorTest.java
@@ -1073,6 +1073,134 @@
     }
 
     /**
+     * Tests {@link Navigator#findNudgeTarget} in the following layout:
+     * <pre>
+     *    ********* app window **********
+     *    *                             *
+     *    *  === target focus area ===  *
+     *    *  =                       =  *
+     *    *  =       [view1]         =  *
+     *    *  =                       =  *
+     *    *  =    [target view]      =  *
+     *    *  =                       =  *
+     *    *  =       [view3]         =  *
+     *    *  =                       =  *
+     *    *  =========================  *
+     *    *                             *
+     *    *******************************
+     *
+     *    ********* IME window **********
+     *    *                             *
+     *    *  === source focus area ===  *
+     *    *  =                       =  *
+     *    *  =    [source view]      =  *
+     *    *  =                       =  *
+     *    *  =========================  *
+     *    *                             *
+     *    *******************************
+     * </pre>
+     */
+    @Test
+    public void testNudgeOutOfIme() {
+        List<AccessibilityWindowInfo> windows = new ArrayList<>();
+
+        int appWindowId = 0x42;
+        Rect appWindowBounds = new Rect(0, 0, 400, 300);
+        AccessibilityWindowInfo appWindow = new WindowBuilder()
+                .setId(appWindowId)
+                .setBoundsInScreen(appWindowBounds)
+                .setType(AccessibilityWindowInfo.TYPE_APPLICATION)
+                .build();
+        windows.add(appWindow);
+        AccessibilityNodeInfo appRoot = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(appWindow)
+                .setWindowId(appWindowId)
+                .setBoundsInScreen(appWindowBounds)
+                .build();
+        setRootNodeForWindow(appRoot, appWindow);
+        AccessibilityNodeInfo targetFocusArea = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(appWindow)
+                .setWindowId(appWindowId)
+                .setParent(appRoot)
+                .setClassName(FOCUS_AREA_CLASS_NAME)
+                .setBoundsInScreen(new Rect(0, 0, 400, 300))
+                .build();
+        AccessibilityNodeInfo view1 = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(appWindow)
+                .setWindowId(appWindowId)
+                .setParent(targetFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(0, 0, 400, 100))
+                .build();
+        AccessibilityNodeInfo targetView = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(appWindow)
+                .setWindowId(appWindowId)
+                .setParent(targetFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(0, 100, 400, 200))
+                .build();
+        AccessibilityNodeInfo view3 = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(appWindow)
+                .setWindowId(appWindowId)
+                .setParent(targetFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(0, 200, 400, 300))
+                .build();
+
+        int imeWindowId = 0x39;
+        Rect imeWindowBounds = new Rect(0, 300, 400, 400);
+        AccessibilityWindowInfo imeWindow = new WindowBuilder()
+                .setId(imeWindowId)
+                .setBoundsInScreen(imeWindowBounds)
+                .setType(AccessibilityWindowInfo.TYPE_INPUT_METHOD)
+                .build();
+        windows.add(imeWindow);
+        AccessibilityNodeInfo imeRoot = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(imeWindow)
+                .setWindowId(imeWindowId)
+                .setBoundsInScreen(imeWindowBounds)
+                .build();
+        setRootNodeForWindow(imeRoot, imeWindow);
+        AccessibilityNodeInfo sourceFocusArea = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(imeWindow)
+                .setWindowId(imeWindowId)
+                .setParent(imeRoot)
+                .setClassName(FOCUS_AREA_CLASS_NAME)
+                .setBoundsInScreen(new Rect(0, 300, 400, 400))
+                .build();
+        AccessibilityNodeInfo sourceView = new NodeBuilder()
+                .setNodeList(mNodeList)
+                .setWindow(imeWindow)
+                .setWindowId(imeWindowId)
+                .setParent(sourceFocusArea)
+                .setFocusable(true)
+                .setVisibleToUser(true)
+                .setEnabled(true)
+                .setBoundsInScreen(new Rect(0, 300, 400, 400))
+                .build();
+
+        // Nudge up from sourceView with the targetView already focused, and it should go to
+        // targetView. This is what happens when the user nudges up from the IME to go back to the
+        // EditText they were editing.
+        AccessibilityNodeInfo target
+                = mNavigator.findNudgeTarget(windows, sourceView, View.FOCUS_UP, targetView);
+        assertThat(target).isSameAs(targetView);
+    }
+
+    /**
      * Tests {@link Navigator#findFirstFocusDescendant} in the following node tree:
      * <pre>
      *                   root
diff --git a/tests/robotests/src/com/android/car/rotary/WindowBuilder.java b/tests/robotests/src/com/android/car/rotary/WindowBuilder.java
index 64e1a13..0c1dc40 100644
--- a/tests/robotests/src/com/android/car/rotary/WindowBuilder.java
+++ b/tests/robotests/src/com/android/car/rotary/WindowBuilder.java
@@ -15,6 +15,8 @@
  */
 package com.android.car.rotary;
 
+import static android.view.accessibility.AccessibilityWindowInfo.UNDEFINED_WINDOW_ID;
+
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
@@ -31,6 +33,8 @@
  * don't need to be recycled.
  */
 class WindowBuilder {
+    /** The ID of the window. */
+    private int mId = UNDEFINED_WINDOW_ID;
     /** The root node in the window's hierarchy. */
     private AccessibilityNodeInfo mRoot;
     /** The bounds of this window in the screen. */
@@ -40,6 +44,7 @@
 
     AccessibilityWindowInfo build() {
         AccessibilityWindowInfo window = mock(AccessibilityWindowInfo.class);
+        when(window.getId()).thenReturn(mId);
         if (mRoot != null) {
             // Mock AccessibilityWindowInfo#getRoot().
             when(window.getRoot()).thenReturn(mRoot);
@@ -58,6 +63,10 @@
         return window;
     }
 
+    WindowBuilder setId(int id) {
+        mId = id;
+        return this;
+    }
     WindowBuilder setRoot(@Nullable AccessibilityNodeInfo root) {
         mRoot = root;
         return this;
diff --git a/tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java b/tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java
index 4da5b82..eb2ba42 100644
--- a/tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java
+++ b/tests/robotests/src/com/android/car/rotary/WindowBuilderTest.java
@@ -33,16 +33,20 @@
         AccessibilityNodeInfo root = new NodeBuilder().build();
         Rect bounds = new Rect(100, 200, 300, 400);
         AccessibilityWindowInfo window = new WindowBuilder()
+                .setId(0x42)
                 .setRoot(root)
                 .setBoundsInScreen(bounds)
                 .setType(AccessibilityWindowInfo.TYPE_SYSTEM)
                 .build();
 
+        assertThat(window.getId()).isEqualTo(0x42);
+
         assertThat(window.getRoot()).isSameAs(root);
 
         Rect boundsInScreen = new Rect();
         window.getBoundsInScreen(boundsInScreen);
         assertThat(boundsInScreen).isEqualTo(bounds);
+
         assertThat(window.getType()).isEqualTo(AccessibilityWindowInfo.TYPE_SYSTEM);
     }
 }