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>