1 /* 2 * Copyright 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.input.debug; 18 19 import static android.util.TypedValue.COMPLEX_UNIT_DIP; 20 import static android.util.TypedValue.COMPLEX_UNIT_SP; 21 import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; 22 23 import android.animation.LayoutTransition; 24 import android.annotation.AnyThread; 25 import android.annotation.Nullable; 26 import android.content.Context; 27 import android.graphics.Color; 28 import android.graphics.ColorFilter; 29 import android.graphics.ColorMatrixColorFilter; 30 import android.graphics.Typeface; 31 import android.util.DisplayMetrics; 32 import android.util.Pair; 33 import android.util.Slog; 34 import android.util.TypedValue; 35 import android.view.Gravity; 36 import android.view.InputDevice; 37 import android.view.KeyCharacterMap; 38 import android.view.KeyEvent; 39 import android.view.MotionEvent; 40 import android.view.RoundedCorner; 41 import android.view.View; 42 import android.view.WindowInsets; 43 import android.view.animation.AccelerateInterpolator; 44 import android.widget.HorizontalScrollView; 45 import android.widget.LinearLayout; 46 import android.widget.RelativeLayout; 47 import android.widget.TextView; 48 49 import com.android.internal.R; 50 import com.android.internal.annotations.VisibleForTesting; 51 import com.android.server.input.InputManagerService; 52 53 import java.util.HashMap; 54 import java.util.Map; 55 import java.util.function.Supplier; 56 57 /** 58 * Displays focus events, such as physical keyboard KeyEvents and non-pointer MotionEvents on 59 * the screen. 60 */ 61 public class FocusEventDebugView extends RelativeLayout { 62 63 private static final String TAG = FocusEventDebugView.class.getSimpleName(); 64 65 private static final int KEY_FADEOUT_DURATION_MILLIS = 1000; 66 private static final int KEY_TRANSITION_DURATION_MILLIS = 100; 67 68 private static final int OUTER_PADDING_DP = 16; 69 private static final int KEY_SEPARATION_MARGIN_DP = 16; 70 private static final int KEY_VIEW_SIDE_PADDING_DP = 16; 71 private static final int KEY_VIEW_VERTICAL_PADDING_DP = 8; 72 private static final int KEY_VIEW_MIN_WIDTH_DP = 32; 73 private static final int KEY_VIEW_TEXT_SIZE_SP = 12; 74 private static final double ROTATY_GRAPH_HEIGHT_FRACTION = 0.5; 75 76 private final InputManagerService mService; 77 private final int mOuterPadding; 78 private final DisplayMetrics mDm; 79 80 // Tracks all keys that are currently pressed/down. 81 private final Map<Pair<Integer /*deviceId*/, Integer /*scanCode*/>, PressedKeyView> 82 mPressedKeys = new HashMap<>(); 83 84 @Nullable 85 private FocusEventDebugGlobalMonitor mFocusEventDebugGlobalMonitor; 86 @Nullable 87 private PressedKeyContainer mPressedKeyContainer; 88 @Nullable 89 private PressedKeyContainer mPressedModifierContainer; 90 private final Supplier<RotaryInputValueView> mRotaryInputValueViewFactory; 91 @Nullable 92 private RotaryInputValueView mRotaryInputValueView; 93 private final Supplier<RotaryInputGraphView> mRotaryInputGraphViewFactory; 94 @Nullable 95 private RotaryInputGraphView mRotaryInputGraphView; 96 97 @VisibleForTesting FocusEventDebugView(Context c, InputManagerService service, Supplier<RotaryInputValueView> rotaryInputValueViewFactory, Supplier<RotaryInputGraphView> rotaryInputGraphViewFactory)98 FocusEventDebugView(Context c, InputManagerService service, 99 Supplier<RotaryInputValueView> rotaryInputValueViewFactory, 100 Supplier<RotaryInputGraphView> rotaryInputGraphViewFactory) { 101 super(c); 102 setFocusableInTouchMode(true); 103 104 mService = service; 105 mRotaryInputValueViewFactory = rotaryInputValueViewFactory; 106 mRotaryInputGraphViewFactory = rotaryInputGraphViewFactory; 107 mDm = mContext.getResources().getDisplayMetrics(); 108 mOuterPadding = (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, OUTER_PADDING_DP, mDm); 109 } 110 FocusEventDebugView(Context c, InputManagerService service)111 public FocusEventDebugView(Context c, InputManagerService service) { 112 this(c, service, () -> new RotaryInputValueView(c), () -> new RotaryInputGraphView(c)); 113 } 114 115 @Override onApplyWindowInsets(WindowInsets insets)116 public WindowInsets onApplyWindowInsets(WindowInsets insets) { 117 int paddingBottom = 0; 118 119 final RoundedCorner bottomLeft = 120 insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT); 121 if (bottomLeft != null && !insets.isRound()) { 122 paddingBottom = bottomLeft.getRadius(); 123 } 124 125 final RoundedCorner bottomRight = 126 insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT); 127 if (bottomRight != null && !insets.isRound()) { 128 paddingBottom = Math.max(paddingBottom, bottomRight.getRadius()); 129 } 130 131 if (insets.getDisplayCutout() != null) { 132 paddingBottom = 133 Math.max(paddingBottom, insets.getDisplayCutout().getSafeInsetBottom()); 134 } 135 136 setPadding(mOuterPadding, mOuterPadding, mOuterPadding, mOuterPadding + paddingBottom); 137 setClipToPadding(false); 138 invalidate(); 139 return super.onApplyWindowInsets(insets); 140 } 141 142 @Override dispatchKeyEvent(KeyEvent event)143 public boolean dispatchKeyEvent(KeyEvent event) { 144 handleKeyEvent(event); 145 return super.dispatchKeyEvent(event); 146 } 147 148 /** Determines whether to show the key presses visualization. */ 149 @AnyThread updateShowKeyPresses(boolean enabled)150 public void updateShowKeyPresses(boolean enabled) { 151 post(() -> handleUpdateShowKeyPresses(enabled)); 152 } 153 154 /** Determines whether to show the rotary input visualization. */ 155 @AnyThread updateShowRotaryInput(boolean enabled)156 public void updateShowRotaryInput(boolean enabled) { 157 post(() -> handleUpdateShowRotaryInput(enabled)); 158 } 159 handleUpdateShowKeyPresses(boolean enabled)160 private void handleUpdateShowKeyPresses(boolean enabled) { 161 if (enabled == showKeyPresses()) { 162 return; 163 } 164 165 if (!enabled) { 166 removeView(mPressedKeyContainer); 167 mPressedKeyContainer = null; 168 removeView(mPressedModifierContainer); 169 mPressedModifierContainer = null; 170 return; 171 } 172 173 mPressedKeyContainer = new PressedKeyContainer(mContext); 174 mPressedKeyContainer.setOrientation(LinearLayout.HORIZONTAL); 175 mPressedKeyContainer.setGravity(Gravity.RIGHT | Gravity.BOTTOM); 176 mPressedKeyContainer.setLayoutDirection(LAYOUT_DIRECTION_LTR); 177 final var scroller = new HorizontalScrollView(mContext); 178 scroller.addView(mPressedKeyContainer); 179 scroller.setHorizontalScrollBarEnabled(false); 180 scroller.addOnLayoutChangeListener( 181 (view, l, t, r, b, ol, ot, or, ob) -> scroller.fullScroll(View.FOCUS_RIGHT)); 182 scroller.setHorizontalFadingEdgeEnabled(true); 183 LayoutParams scrollerLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT); 184 scrollerLayoutParams.addRule(ALIGN_PARENT_BOTTOM); 185 scrollerLayoutParams.addRule(ALIGN_PARENT_RIGHT); 186 addView(scroller, scrollerLayoutParams); 187 188 mPressedModifierContainer = new PressedKeyContainer(mContext); 189 mPressedModifierContainer.setOrientation(LinearLayout.VERTICAL); 190 mPressedModifierContainer.setGravity(Gravity.LEFT | Gravity.BOTTOM); 191 LayoutParams modifierLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT); 192 modifierLayoutParams.addRule(ALIGN_PARENT_BOTTOM); 193 modifierLayoutParams.addRule(ALIGN_PARENT_LEFT); 194 modifierLayoutParams.addRule(LEFT_OF, scroller.getId()); 195 addView(mPressedModifierContainer, modifierLayoutParams); 196 } 197 198 @VisibleForTesting handleUpdateShowRotaryInput(boolean enabled)199 void handleUpdateShowRotaryInput(boolean enabled) { 200 if (enabled == showRotaryInput()) { 201 return; 202 } 203 204 if (!enabled) { 205 mFocusEventDebugGlobalMonitor.dispose(); 206 mFocusEventDebugGlobalMonitor = null; 207 removeView(mRotaryInputValueView); 208 mRotaryInputValueView = null; 209 removeView(mRotaryInputGraphView); 210 mRotaryInputGraphView = null; 211 return; 212 } 213 214 mFocusEventDebugGlobalMonitor = new FocusEventDebugGlobalMonitor(this, mService); 215 216 mRotaryInputValueView = mRotaryInputValueViewFactory.get(); 217 LayoutParams valueLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT); 218 valueLayoutParams.addRule(CENTER_HORIZONTAL); 219 valueLayoutParams.addRule(ALIGN_PARENT_BOTTOM); 220 addView(mRotaryInputValueView, valueLayoutParams); 221 222 mRotaryInputGraphView = mRotaryInputGraphViewFactory.get(); 223 LayoutParams graphLayoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, 224 (int) (ROTATY_GRAPH_HEIGHT_FRACTION * mDm.heightPixels)); 225 graphLayoutParams.addRule(CENTER_IN_PARENT); 226 addView(mRotaryInputGraphView, graphLayoutParams); 227 } 228 229 /** Report a key event to the debug view. */ 230 @AnyThread reportKeyEvent(KeyEvent event)231 public void reportKeyEvent(KeyEvent event) { 232 KeyEvent keyEvent = KeyEvent.obtain(event); 233 post(() -> handleKeyEvent(keyEvent)); 234 } 235 236 /** Report a motion event to the debug view. */ 237 @AnyThread reportMotionEvent(MotionEvent event)238 public void reportMotionEvent(MotionEvent event) { 239 if (event.getSource() != InputDevice.SOURCE_ROTARY_ENCODER) { 240 return; 241 } 242 243 MotionEvent motionEvent = MotionEvent.obtain(event); 244 post(() -> handleRotaryInput(motionEvent)); 245 } 246 handleKeyEvent(KeyEvent keyEvent)247 private void handleKeyEvent(KeyEvent keyEvent) { 248 if (!showKeyPresses()) { 249 return; 250 } 251 252 final var identifier = new Pair<>(keyEvent.getDeviceId(), keyEvent.getScanCode()); 253 final var container = KeyEvent.isModifierKey(keyEvent.getKeyCode()) 254 ? mPressedModifierContainer 255 : mPressedKeyContainer; 256 PressedKeyView pressedKeyView = mPressedKeys.get(identifier); 257 switch (keyEvent.getAction()) { 258 case KeyEvent.ACTION_DOWN: { 259 if (pressedKeyView != null) { 260 if (keyEvent.getRepeatCount() == 0) { 261 Slog.w(TAG, "Got key down for " 262 + KeyEvent.keyCodeToString(keyEvent.getKeyCode()) 263 + " that was already tracked as being down."); 264 break; 265 } 266 container.handleKeyRepeat(pressedKeyView); 267 break; 268 } 269 270 pressedKeyView = new PressedKeyView(mContext, getLabel(keyEvent)); 271 mPressedKeys.put(identifier, pressedKeyView); 272 container.handleKeyPressed(pressedKeyView); 273 break; 274 } 275 case KeyEvent.ACTION_UP: { 276 if (pressedKeyView == null) { 277 Slog.w(TAG, "Got key up for " + KeyEvent.keyCodeToString(keyEvent.getKeyCode()) 278 + " that was not tracked as being down."); 279 break; 280 } 281 mPressedKeys.remove(identifier); 282 container.handleKeyRelease(pressedKeyView); 283 break; 284 } 285 default: 286 break; 287 } 288 keyEvent.recycle(); 289 } 290 291 @VisibleForTesting handleRotaryInput(MotionEvent motionEvent)292 void handleRotaryInput(MotionEvent motionEvent) { 293 if (!showRotaryInput()) { 294 return; 295 } 296 297 float scrollAxisValue = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); 298 mRotaryInputValueView.updateValue(scrollAxisValue); 299 mRotaryInputGraphView.addValue(scrollAxisValue, motionEvent.getEventTime()); 300 301 motionEvent.recycle(); 302 } 303 getLabel(KeyEvent event)304 private static String getLabel(KeyEvent event) { 305 switch (event.getKeyCode()) { 306 case KeyEvent.KEYCODE_SPACE: 307 return "\u2423"; 308 case KeyEvent.KEYCODE_TAB: 309 return "\u21e5"; 310 case KeyEvent.KEYCODE_ENTER: 311 case KeyEvent.KEYCODE_NUMPAD_ENTER: 312 return "\u23CE"; 313 case KeyEvent.KEYCODE_DEL: 314 return "\u232B"; 315 case KeyEvent.KEYCODE_FORWARD_DEL: 316 return "\u2326"; 317 case KeyEvent.KEYCODE_ESCAPE: 318 return "esc"; 319 case KeyEvent.KEYCODE_DPAD_UP: 320 return "\u2191"; 321 case KeyEvent.KEYCODE_DPAD_DOWN: 322 return "\u2193"; 323 case KeyEvent.KEYCODE_DPAD_LEFT: 324 return "\u2190"; 325 case KeyEvent.KEYCODE_DPAD_RIGHT: 326 return "\u2192"; 327 case KeyEvent.KEYCODE_DPAD_UP_RIGHT: 328 return "\u2197"; 329 case KeyEvent.KEYCODE_DPAD_UP_LEFT: 330 return "\u2196"; 331 case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: 332 return "\u2198"; 333 case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: 334 return "\u2199"; 335 case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: 336 return "\u23ef"; 337 case KeyEvent.KEYCODE_HOME: 338 return "\u25ef"; 339 case KeyEvent.KEYCODE_BACK: 340 return "\u25c1"; 341 case KeyEvent.KEYCODE_RECENT_APPS: 342 return "\u25a1"; 343 default: 344 break; 345 } 346 347 final int unicodeChar = event.getUnicodeChar(); 348 if (unicodeChar != 0) { 349 if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) != 0) { 350 // Show combining character 351 final int combiningChar = KeyCharacterMap.getCombiningChar( 352 unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK); 353 // Return the Unicode dotted circle as part of the label as it is used is used to 354 // illustrate the effect of a combining marks 355 return "\u25cc" + String.valueOf((char) combiningChar); 356 } 357 return String.valueOf((char) unicodeChar); 358 } 359 360 final var label = KeyEvent.keyCodeToString(event.getKeyCode()); 361 if (label.startsWith("KEYCODE_")) { 362 return label.substring(8); 363 } 364 return label; 365 } 366 367 /** Determine whether to show key presses by checking one of the key-related objects. */ showKeyPresses()368 private boolean showKeyPresses() { 369 return mPressedKeyContainer != null; 370 } 371 372 /** Determine whether to show rotary input by checking one of the rotary-related objects. */ showRotaryInput()373 private boolean showRotaryInput() { 374 return mRotaryInputValueView != null; 375 } 376 377 private static class PressedKeyView extends TextView { 378 379 private static final ColorFilter sInvertColors = new ColorMatrixColorFilter(new float[]{ 380 -1.0f, 0, 0, 0, 255, // red 381 0, -1.0f, 0, 0, 255, // green 382 0, 0, -1.0f, 0, 255, // blue 383 0, 0, 0, 1.0f, 0 // alpha 384 }); 385 PressedKeyView(Context c, String label)386 PressedKeyView(Context c, String label) { 387 super(c); 388 389 final var dm = c.getResources().getDisplayMetrics(); 390 final int keyViewSidePadding = 391 (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_SIDE_PADDING_DP, dm); 392 final int keyViewVerticalPadding = 393 (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_VERTICAL_PADDING_DP, 394 dm); 395 final int keyViewMinWidth = 396 (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_VIEW_MIN_WIDTH_DP, dm); 397 final int textSize = 398 (int) TypedValue.applyDimension(COMPLEX_UNIT_SP, KEY_VIEW_TEXT_SIZE_SP, dm); 399 400 setText(label); 401 setGravity(Gravity.CENTER); 402 setMinimumWidth(keyViewMinWidth); 403 setTextSize(textSize); 404 setTypeface(Typeface.SANS_SERIF); 405 setBackgroundResource(R.drawable.focus_event_pressed_key_background); 406 setPaddingRelative(keyViewSidePadding, keyViewVerticalPadding, keyViewSidePadding, 407 keyViewVerticalPadding); 408 409 setHighlighted(true); 410 } 411 setHighlighted(boolean isHighlighted)412 void setHighlighted(boolean isHighlighted) { 413 if (isHighlighted) { 414 setTextColor(Color.BLACK); 415 getBackground().setColorFilter(sInvertColors); 416 } else { 417 setTextColor(Color.WHITE); 418 getBackground().clearColorFilter(); 419 } 420 invalidate(); 421 } 422 } 423 424 private static class PressedKeyContainer extends LinearLayout { 425 426 private final MarginLayoutParams mPressedKeyLayoutParams; 427 PressedKeyContainer(Context c)428 PressedKeyContainer(Context c) { 429 super(c); 430 431 final var dm = c.getResources().getDisplayMetrics(); 432 final int keySeparationMargin = 433 (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, KEY_SEPARATION_MARGIN_DP, dm); 434 435 final var transition = new LayoutTransition(); 436 transition.disableTransitionType(LayoutTransition.APPEARING); 437 transition.disableTransitionType(LayoutTransition.DISAPPEARING); 438 transition.disableTransitionType(LayoutTransition.CHANGE_DISAPPEARING); 439 transition.setDuration(KEY_TRANSITION_DURATION_MILLIS); 440 setLayoutTransition(transition); 441 442 mPressedKeyLayoutParams = new MarginLayoutParams(WRAP_CONTENT, WRAP_CONTENT); 443 if (getOrientation() == VERTICAL) { 444 mPressedKeyLayoutParams.setMargins(0, keySeparationMargin, 0, 0); 445 } else { 446 mPressedKeyLayoutParams.setMargins(keySeparationMargin, 0, 0, 0); 447 } 448 } 449 handleKeyPressed(PressedKeyView pressedKeyView)450 public void handleKeyPressed(PressedKeyView pressedKeyView) { 451 addView(pressedKeyView, getChildCount(), mPressedKeyLayoutParams); 452 invalidate(); 453 } 454 handleKeyRepeat(PressedKeyView repeatedKeyView)455 public void handleKeyRepeat(PressedKeyView repeatedKeyView) { 456 // Do nothing for now. 457 } 458 handleKeyRelease(PressedKeyView releasedKeyView)459 public void handleKeyRelease(PressedKeyView releasedKeyView) { 460 releasedKeyView.setHighlighted(false); 461 releasedKeyView.clearAnimation(); 462 releasedKeyView.animate() 463 .alpha(0) 464 .setDuration(KEY_FADEOUT_DURATION_MILLIS) 465 .setInterpolator(new AccelerateInterpolator()) 466 .withEndAction(this::cleanUpPressedKeyViews) 467 .start(); 468 } 469 cleanUpPressedKeyViews()470 private void cleanUpPressedKeyViews() { 471 int numChildrenToRemove = 0; 472 for (int i = 0; i < getChildCount(); i++) { 473 final View child = getChildAt(i); 474 if (child.getAlpha() != 0) { 475 break; 476 } 477 child.setVisibility(View.GONE); 478 child.clearAnimation(); 479 numChildrenToRemove++; 480 } 481 removeViews(0, numChildrenToRemove); 482 invalidate(); 483 } 484 } 485 } 486