• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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