1 /*
2  * Copyright (C) 2020 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.systemui.navigationbar.buttons;
18 
19 import static android.view.Display.INVALID_DISPLAY;
20 import static android.view.KeyEvent.KEYCODE_UNKNOWN;
21 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK;
22 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK;
23 
24 import android.app.ActivityManager;
25 import android.content.Context;
26 import android.content.res.Configuration;
27 import android.content.res.TypedArray;
28 import android.graphics.Canvas;
29 import android.graphics.Paint;
30 import android.graphics.drawable.Drawable;
31 import android.graphics.drawable.Icon;
32 import android.hardware.input.InputManager;
33 import android.media.AudioManager;
34 import android.metrics.LogMaker;
35 import android.os.AsyncTask;
36 import android.os.Bundle;
37 import android.os.SystemClock;
38 import android.util.AttributeSet;
39 import android.util.Log;
40 import android.util.TypedValue;
41 import android.view.HapticFeedbackConstants;
42 import android.view.InputDevice;
43 import android.view.KeyCharacterMap;
44 import android.view.KeyEvent;
45 import android.view.MotionEvent;
46 import android.view.SoundEffectConstants;
47 import android.view.View;
48 import android.view.ViewConfiguration;
49 import android.view.accessibility.AccessibilityEvent;
50 import android.view.accessibility.AccessibilityNodeInfo;
51 import android.widget.ImageView;
52 
53 import com.android.internal.annotations.VisibleForTesting;
54 import com.android.internal.logging.MetricsLogger;
55 import com.android.internal.logging.UiEvent;
56 import com.android.internal.logging.UiEventLogger;
57 import com.android.internal.logging.UiEventLoggerImpl;
58 import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
59 import com.android.systemui.Dependency;
60 import com.android.systemui.R;
61 import com.android.systemui.recents.OverviewProxyService;
62 import com.android.systemui.shared.system.QuickStepContract;
63 
64 public class KeyButtonView extends ImageView implements ButtonInterface {
65     private static final String TAG = KeyButtonView.class.getSimpleName();
66 
67     private final boolean mPlaySounds;
68     private final UiEventLogger mUiEventLogger;
69     private int mContentDescriptionRes;
70     private long mDownTime;
71     private int mCode;
72     private int mTouchDownX;
73     private int mTouchDownY;
74     private boolean mIsVertical;
75     private AudioManager mAudioManager;
76     private boolean mGestureAborted;
77     @VisibleForTesting boolean mLongClicked;
78     private OnClickListener mOnClickListener;
79     private final KeyButtonRipple mRipple;
80     private final OverviewProxyService mOverviewProxyService;
81     private final MetricsLogger mMetricsLogger = Dependency.get(MetricsLogger.class);
82     private final InputManager mInputManager;
83     private final Paint mOvalBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
84     private float mDarkIntensity;
85     private boolean mHasOvalBg = false;
86 
87     @VisibleForTesting
88     public enum NavBarButtonEvent implements UiEventLogger.UiEventEnum {
89 
90         @UiEvent(doc = "The home button was pressed in the navigation bar.")
91         NAVBAR_HOME_BUTTON_TAP(533),
92 
93         @UiEvent(doc = "The back button was pressed in the navigation bar.")
94         NAVBAR_BACK_BUTTON_TAP(534),
95 
96         @UiEvent(doc = "The overview button was pressed in the navigation bar.")
97         NAVBAR_OVERVIEW_BUTTON_TAP(535),
98 
99         @UiEvent(doc = "The ime switcher button was pressed in the navigation bar.")
100         NAVBAR_IME_SWITCHER_BUTTON_TAP(923),
101 
102         @UiEvent(doc = "The home button was long-pressed in the navigation bar.")
103         NAVBAR_HOME_BUTTON_LONGPRESS(536),
104 
105         @UiEvent(doc = "The back button was long-pressed in the navigation bar.")
106         NAVBAR_BACK_BUTTON_LONGPRESS(537),
107 
108         @UiEvent(doc = "The overview button was long-pressed in the navigation bar.")
109         NAVBAR_OVERVIEW_BUTTON_LONGPRESS(538),
110 
111         NONE(0);  // an event we should not log
112 
113         private final int mId;
114 
NavBarButtonEvent(int id)115         NavBarButtonEvent(int id) {
116             mId = id;
117         }
118 
119         @Override
getId()120         public int getId() {
121             return mId;
122         }
123     }
124     private final Runnable mCheckLongPress = new Runnable() {
125         public void run() {
126             if (isPressed()) {
127                 // Log.d("KeyButtonView", "longpressed: " + this);
128                 if (isLongClickable()) {
129                     // Just an old-fashioned ImageView
130                     performLongClick();
131                     mLongClicked = true;
132                 } else {
133                     if (mCode != KEYCODE_UNKNOWN) {
134                         sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
135                         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
136                     }
137                     mLongClicked = true;
138                 }
139             }
140         }
141     };
142 
KeyButtonView(Context context, AttributeSet attrs)143     public KeyButtonView(Context context, AttributeSet attrs) {
144         this(context, attrs, 0);
145     }
146 
KeyButtonView(Context context, AttributeSet attrs, int defStyle)147     public KeyButtonView(Context context, AttributeSet attrs, int defStyle) {
148         this(context, attrs, defStyle, InputManager.getInstance(), new UiEventLoggerImpl());
149     }
150 
151     @VisibleForTesting
KeyButtonView(Context context, AttributeSet attrs, int defStyle, InputManager manager, UiEventLogger uiEventLogger)152     public KeyButtonView(Context context, AttributeSet attrs, int defStyle, InputManager manager,
153             UiEventLogger uiEventLogger) {
154         super(context, attrs);
155         mUiEventLogger = uiEventLogger;
156 
157         TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.KeyButtonView,
158                 defStyle, 0);
159 
160         mCode = a.getInteger(R.styleable.KeyButtonView_keyCode, KEYCODE_UNKNOWN);
161 
162         mPlaySounds = a.getBoolean(R.styleable.KeyButtonView_playSound, true);
163 
164         TypedValue value = new TypedValue();
165         if (a.getValue(R.styleable.KeyButtonView_android_contentDescription, value)) {
166             mContentDescriptionRes = value.resourceId;
167         }
168 
169         a.recycle();
170 
171         setClickable(true);
172         mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
173 
174         mRipple = new KeyButtonRipple(context, this, R.dimen.key_button_ripple_max_width);
175         mOverviewProxyService = Dependency.get(OverviewProxyService.class);
176         mInputManager = manager;
177         setBackground(mRipple);
178         setWillNotDraw(false);
179         forceHasOverlappingRendering(false);
180     }
181 
182     @Override
isClickable()183     public boolean isClickable() {
184         return mCode != KEYCODE_UNKNOWN || super.isClickable();
185     }
186 
setCode(int code)187     public void setCode(int code) {
188         mCode = code;
189     }
190 
191     @Override
setOnClickListener(OnClickListener onClickListener)192     public void setOnClickListener(OnClickListener onClickListener) {
193         super.setOnClickListener(onClickListener);
194         mOnClickListener = onClickListener;
195     }
196 
loadAsync(Icon icon)197     public void loadAsync(Icon icon) {
198         new AsyncTask<Icon, Void, Drawable>() {
199             @Override
200             protected Drawable doInBackground(Icon... params) {
201                 return params[0].loadDrawable(mContext);
202             }
203 
204             @Override
205             protected void onPostExecute(Drawable drawable) {
206                 setImageDrawable(drawable);
207             }
208         }.execute(icon);
209     }
210 
211     @Override
onConfigurationChanged(Configuration newConfig)212     protected void onConfigurationChanged(Configuration newConfig) {
213         super.onConfigurationChanged(newConfig);
214 
215         if (mContentDescriptionRes != 0) {
216             setContentDescription(mContext.getString(mContentDescriptionRes));
217         }
218     }
219 
220     @Override
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)221     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
222         super.onInitializeAccessibilityNodeInfo(info);
223         if (mCode != KEYCODE_UNKNOWN) {
224             info.addAction(new AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null));
225             if (isLongClickable()) {
226                 info.addAction(
227                         new AccessibilityNodeInfo.AccessibilityAction(ACTION_LONG_CLICK, null));
228             }
229         }
230     }
231 
232     @Override
onWindowVisibilityChanged(int visibility)233     protected void onWindowVisibilityChanged(int visibility) {
234         super.onWindowVisibilityChanged(visibility);
235         if (visibility != View.VISIBLE) {
236             jumpDrawablesToCurrentState();
237         }
238     }
239 
240     @Override
performAccessibilityActionInternal(int action, Bundle arguments)241     public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
242         if (action == ACTION_CLICK && mCode != KEYCODE_UNKNOWN) {
243             sendEvent(KeyEvent.ACTION_DOWN, 0, SystemClock.uptimeMillis());
244             sendEvent(KeyEvent.ACTION_UP, 0);
245             sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
246             playSoundEffect(SoundEffectConstants.CLICK);
247             return true;
248         } else if (action == ACTION_LONG_CLICK && mCode != KEYCODE_UNKNOWN) {
249             sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
250             sendEvent(KeyEvent.ACTION_UP, 0);
251             sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
252             return true;
253         }
254         return super.performAccessibilityActionInternal(action, arguments);
255     }
256 
257     @Override
onTouchEvent(MotionEvent ev)258     public boolean onTouchEvent(MotionEvent ev) {
259         final boolean showSwipeUI = mOverviewProxyService.shouldShowSwipeUpUI();
260         final int action = ev.getAction();
261         int x, y;
262         if (action == MotionEvent.ACTION_DOWN) {
263             mGestureAborted = false;
264         }
265         if (mGestureAborted) {
266             setPressed(false);
267             return false;
268         }
269 
270         switch (action) {
271             case MotionEvent.ACTION_DOWN:
272                 mDownTime = SystemClock.uptimeMillis();
273                 mLongClicked = false;
274                 setPressed(true);
275 
276                 // Use raw X and Y to detect gestures in case a parent changes the x and y values
277                 mTouchDownX = (int) ev.getRawX();
278                 mTouchDownY = (int) ev.getRawY();
279                 if (mCode != KEYCODE_UNKNOWN) {
280                     sendEvent(KeyEvent.ACTION_DOWN, 0, mDownTime);
281                 } else {
282                     // Provide the same haptic feedback that the system offers for virtual keys.
283                     performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
284                 }
285                 if (!showSwipeUI) {
286                     playSoundEffect(SoundEffectConstants.CLICK);
287                 }
288                 removeCallbacks(mCheckLongPress);
289                 postDelayed(mCheckLongPress, ViewConfiguration.getLongPressTimeout());
290                 break;
291             case MotionEvent.ACTION_MOVE:
292                 x = (int)ev.getRawX();
293                 y = (int)ev.getRawY();
294 
295                 float slop = QuickStepContract.getQuickStepTouchSlopPx(getContext());
296                 if (Math.abs(x - mTouchDownX) > slop || Math.abs(y - mTouchDownY) > slop) {
297                     // When quick step is enabled, prevent animating the ripple triggered by
298                     // setPressed and decide to run it on touch up
299                     setPressed(false);
300                     removeCallbacks(mCheckLongPress);
301                 }
302                 break;
303             case MotionEvent.ACTION_CANCEL:
304                 setPressed(false);
305                 if (mCode != KEYCODE_UNKNOWN) {
306                     sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
307                 }
308                 removeCallbacks(mCheckLongPress);
309                 break;
310             case MotionEvent.ACTION_UP:
311                 final boolean doIt = isPressed() && !mLongClicked;
312                 setPressed(false);
313                 final boolean doHapticFeedback = (SystemClock.uptimeMillis() - mDownTime) > 150;
314                 if (showSwipeUI) {
315                     if (doIt) {
316                         // Apply haptic feedback on touch up since there is none on touch down
317                         performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
318                         playSoundEffect(SoundEffectConstants.CLICK);
319                     }
320                 } else if (doHapticFeedback && !mLongClicked) {
321                     // Always send a release ourselves because it doesn't seem to be sent elsewhere
322                     // and it feels weird to sometimes get a release haptic and other times not.
323                     performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE);
324                 }
325                 if (mCode != KEYCODE_UNKNOWN) {
326                     if (doIt) {
327                         sendEvent(KeyEvent.ACTION_UP, 0);
328                         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
329                     } else {
330                         sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
331                     }
332                 } else {
333                     // no key code, just a regular ImageView
334                     if (doIt && mOnClickListener != null) {
335                         mOnClickListener.onClick(this);
336                         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
337                     }
338                 }
339                 removeCallbacks(mCheckLongPress);
340                 break;
341         }
342 
343         return true;
344     }
345 
346     @Override
setImageDrawable(Drawable drawable)347     public void setImageDrawable(Drawable drawable) {
348         super.setImageDrawable(drawable);
349 
350         if (drawable == null) {
351             return;
352         }
353         KeyButtonDrawable keyButtonDrawable = (KeyButtonDrawable) drawable;
354         keyButtonDrawable.setDarkIntensity(mDarkIntensity);
355         mHasOvalBg = keyButtonDrawable.hasOvalBg();
356         if (mHasOvalBg) {
357             mOvalBgPaint.setColor(keyButtonDrawable.getDrawableBackgroundColor());
358         }
359         mRipple.setType(keyButtonDrawable.hasOvalBg() ? KeyButtonRipple.Type.OVAL
360                 : KeyButtonRipple.Type.ROUNDED_RECT);
361     }
362 
playSoundEffect(int soundConstant)363     public void playSoundEffect(int soundConstant) {
364         if (!mPlaySounds) return;
365         mAudioManager.playSoundEffect(soundConstant, ActivityManager.getCurrentUser());
366     }
367 
sendEvent(int action, int flags)368     public void sendEvent(int action, int flags) {
369         sendEvent(action, flags, SystemClock.uptimeMillis());
370     }
371 
logSomePresses(int action, int flags)372     private void logSomePresses(int action, int flags) {
373         boolean longPressSet = (flags & KeyEvent.FLAG_LONG_PRESS) != 0;
374         NavBarButtonEvent uiEvent = NavBarButtonEvent.NONE;
375         if (action == MotionEvent.ACTION_UP && mLongClicked) {
376             return;  // don't log the up after a long press
377         }
378         if (action == MotionEvent.ACTION_DOWN && !longPressSet) {
379             return;  // don't log a down unless it is also the long press marker
380         }
381         if ((flags & KeyEvent.FLAG_CANCELED) != 0
382                 || (flags & KeyEvent.FLAG_CANCELED_LONG_PRESS) != 0) {
383             return;  // don't log various cancels
384         }
385         switch(mCode) {
386             case KeyEvent.KEYCODE_BACK:
387                 uiEvent = longPressSet
388                         ? NavBarButtonEvent.NAVBAR_BACK_BUTTON_LONGPRESS
389                         : NavBarButtonEvent.NAVBAR_BACK_BUTTON_TAP;
390                 break;
391             case KeyEvent.KEYCODE_HOME:
392                 uiEvent = longPressSet
393                         ? NavBarButtonEvent.NAVBAR_HOME_BUTTON_LONGPRESS
394                         : NavBarButtonEvent.NAVBAR_HOME_BUTTON_TAP;
395                 break;
396             case KeyEvent.KEYCODE_APP_SWITCH:
397                 uiEvent = longPressSet
398                         ? NavBarButtonEvent.NAVBAR_OVERVIEW_BUTTON_LONGPRESS
399                         : NavBarButtonEvent.NAVBAR_OVERVIEW_BUTTON_TAP;
400                 break;
401         }
402         if (uiEvent != NavBarButtonEvent.NONE) {
403             mUiEventLogger.log(uiEvent);
404         }
405     }
406 
sendEvent(int action, int flags, long when)407     private void sendEvent(int action, int flags, long when) {
408         mMetricsLogger.write(new LogMaker(MetricsEvent.ACTION_NAV_BUTTON_EVENT)
409                 .setType(MetricsEvent.TYPE_ACTION)
410                 .setSubtype(mCode)
411                 .addTaggedData(MetricsEvent.FIELD_NAV_ACTION, action)
412                 .addTaggedData(MetricsEvent.FIELD_FLAGS, flags));
413         logSomePresses(action, flags);
414         if (mCode == KeyEvent.KEYCODE_BACK && flags != KeyEvent.FLAG_LONG_PRESS) {
415             Log.i(TAG, "Back button event: " + KeyEvent.actionToString(action));
416             if (action == MotionEvent.ACTION_UP) {
417                 mOverviewProxyService.notifyBackAction((flags & KeyEvent.FLAG_CANCELED) == 0,
418                         -1, -1, true /* isButton */, false /* gestureSwipeLeft */);
419             }
420         }
421         final int repeatCount = (flags & KeyEvent.FLAG_LONG_PRESS) != 0 ? 1 : 0;
422         final KeyEvent ev = new KeyEvent(mDownTime, when, action, mCode, repeatCount,
423                 0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0,
424                 flags | KeyEvent.FLAG_FROM_SYSTEM | KeyEvent.FLAG_VIRTUAL_HARD_KEY,
425                 InputDevice.SOURCE_KEYBOARD);
426 
427         int displayId = INVALID_DISPLAY;
428 
429         // Make KeyEvent work on multi-display environment
430         if (getDisplay() != null) {
431             displayId = getDisplay().getDisplayId();
432         }
433         if (displayId != INVALID_DISPLAY) {
434             ev.setDisplayId(displayId);
435         }
436         mInputManager.injectInputEvent(ev, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
437     }
438 
439     @Override
abortCurrentGesture()440     public void abortCurrentGesture() {
441         Log.d("b/63783866", "KeyButtonView.abortCurrentGesture");
442         if (mCode != KeyEvent.KEYCODE_UNKNOWN) {
443             sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
444         }
445         setPressed(false);
446         mRipple.abortDelayedRipple();
447         mGestureAborted = true;
448     }
449 
450     @Override
setDarkIntensity(float darkIntensity)451     public void setDarkIntensity(float darkIntensity) {
452         mDarkIntensity = darkIntensity;
453 
454         Drawable drawable = getDrawable();
455         if (drawable != null) {
456             ((KeyButtonDrawable) drawable).setDarkIntensity(darkIntensity);
457             // Since we reuse the same drawable for multiple views, we need to invalidate the view
458             // manually.
459             invalidate();
460         }
461         mRipple.setDarkIntensity(darkIntensity);
462     }
463 
464     @Override
setDelayTouchFeedback(boolean shouldDelay)465     public void setDelayTouchFeedback(boolean shouldDelay) {
466         mRipple.setDelayTouchFeedback(shouldDelay);
467     }
468 
469     @Override
draw(Canvas canvas)470     public void draw(Canvas canvas) {
471         if (mHasOvalBg) {
472             int d = Math.min(getWidth(), getHeight());
473             canvas.drawOval(0, 0, d, d, mOvalBgPaint);
474         }
475         super.draw(canvas);
476     }
477 
478     @Override
setVertical(boolean vertical)479     public void setVertical(boolean vertical) {
480         mIsVertical = vertical;
481     }
482 }
483