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);
}
}