1 /*
2  * Copyright (C) 2022 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 android.inputmethodservice.navigationbar;
18 
19 import static android.view.Display.INVALID_DISPLAY;
20 import static android.view.KeyEvent.KEYCODE_BACK;
21 import static android.view.KeyEvent.KEYCODE_UNKNOWN;
22 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK;
23 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK;
24 
25 import android.content.Context;
26 import android.graphics.Canvas;
27 import android.graphics.Paint;
28 import android.graphics.drawable.Drawable;
29 import android.inputmethodservice.InputMethodService;
30 import android.media.AudioManager;
31 import android.os.Bundle;
32 import android.os.SystemClock;
33 import android.util.AttributeSet;
34 import android.view.HapticFeedbackConstants;
35 import android.view.InputDevice;
36 import android.view.KeyCharacterMap;
37 import android.view.KeyEvent;
38 import android.view.MotionEvent;
39 import android.view.SoundEffectConstants;
40 import android.view.View;
41 import android.view.ViewConfiguration;
42 import android.view.accessibility.AccessibilityEvent;
43 import android.view.accessibility.AccessibilityNodeInfo;
44 import android.view.inputmethod.InputConnection;
45 import android.widget.ImageView;
46 
47 import com.android.internal.annotations.VisibleForTesting;
48 
49 /**
50  * @hide
51  */
52 public class KeyButtonView extends ImageView implements ButtonInterface {
53     private static final String TAG = KeyButtonView.class.getSimpleName();
54 
55     private final boolean mPlaySounds;
56     private long mDownTime;
57     private boolean mTracking;
58     private int mCode;
59     private int mTouchDownX;
60     private int mTouchDownY;
61     private AudioManager mAudioManager;
62     private boolean mGestureAborted;
63     @VisibleForTesting boolean mLongClicked;
64     private OnClickListener mOnClickListener;
65     private final KeyButtonRipple mRipple;
66     private final Paint mOvalBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
67     private float mDarkIntensity;
68     private boolean mHasOvalBg = false;
69 
70     private final Runnable mCheckLongPress = new Runnable() {
71         public void run() {
72             if (isPressed()) {
73                 // Log.d("KeyButtonView", "longpressed: " + this);
74                 if (isLongClickable()) {
75                     // Just an old-fashioned ImageView
76                     performLongClick();
77                     mLongClicked = true;
78                 } else {
79                     if (mCode != KEYCODE_UNKNOWN) {
80                         sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
81                         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
82                     }
83                     mLongClicked = true;
84                 }
85             }
86         }
87     };
88 
KeyButtonView(Context context, AttributeSet attrs)89     public KeyButtonView(Context context, AttributeSet attrs) {
90         super(context, attrs);
91 
92         // TODO(b/215443343): Figure out better place to set this.
93         switch (getId()) {
94             case com.android.internal.R.id.input_method_nav_back:
95                 mCode = KEYCODE_BACK;
96                 break;
97             default:
98                 mCode = KEYCODE_UNKNOWN;
99                 break;
100         }
101 
102         mPlaySounds = true;
103 
104         setClickable(true);
105         mAudioManager = context.getSystemService(AudioManager.class);
106 
107         mRipple = new KeyButtonRipple(context, this,
108                 com.android.internal.R.dimen.input_method_nav_key_button_ripple_max_width);
109         setBackground(mRipple);
110         setWillNotDraw(false);
111         forceHasOverlappingRendering(false);
112     }
113 
114     @Override
isClickable()115     public boolean isClickable() {
116         return mCode != KEYCODE_UNKNOWN || super.isClickable();
117     }
118 
setCode(int code)119     public void setCode(int code) {
120         mCode = code;
121     }
122 
123     @Override
setOnClickListener(OnClickListener onClickListener)124     public void setOnClickListener(OnClickListener onClickListener) {
125         super.setOnClickListener(onClickListener);
126         mOnClickListener = onClickListener;
127     }
128 
129     @Override
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)130     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
131         super.onInitializeAccessibilityNodeInfo(info);
132         if (mCode != KEYCODE_UNKNOWN) {
133             info.addAction(new AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, null));
134             if (isLongClickable()) {
135                 info.addAction(
136                         new AccessibilityNodeInfo.AccessibilityAction(ACTION_LONG_CLICK, null));
137             }
138         }
139     }
140 
141     @Override
onWindowVisibilityChanged(int visibility)142     protected void onWindowVisibilityChanged(int visibility) {
143         super.onWindowVisibilityChanged(visibility);
144         if (visibility != View.VISIBLE) {
145             jumpDrawablesToCurrentState();
146         }
147     }
148 
149     @Override
performAccessibilityActionInternal(int action, Bundle arguments)150     public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
151         if (action == ACTION_CLICK && mCode != KEYCODE_UNKNOWN) {
152             sendEvent(KeyEvent.ACTION_DOWN, 0, SystemClock.uptimeMillis());
153             sendEvent(KeyEvent.ACTION_UP, mTracking ? KeyEvent.FLAG_TRACKING : 0);
154             mTracking = false;
155             sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
156             playSoundEffect(SoundEffectConstants.CLICK);
157             return true;
158         } else if (action == ACTION_LONG_CLICK && mCode != KEYCODE_UNKNOWN) {
159             sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
160             sendEvent(KeyEvent.ACTION_UP, mTracking ? KeyEvent.FLAG_TRACKING : 0);
161             mTracking = false;
162             sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
163             return true;
164         }
165         return super.performAccessibilityActionInternal(action, arguments);
166     }
167 
168     @Override
onTouchEvent(MotionEvent ev)169     public boolean onTouchEvent(MotionEvent ev) {
170         final boolean showSwipeUI = false; // mOverviewProxyService.shouldShowSwipeUpUI();
171         final int action = ev.getAction();
172         int x, y;
173         if (action == MotionEvent.ACTION_DOWN) {
174             mGestureAborted = false;
175         }
176         if (mGestureAborted) {
177             setPressed(false);
178             return false;
179         }
180 
181         switch (action) {
182             case MotionEvent.ACTION_DOWN:
183                 mDownTime = SystemClock.uptimeMillis();
184                 mLongClicked = false;
185                 setPressed(true);
186 
187                 // Use raw X and Y to detect gestures in case a parent changes the x and y values
188                 mTouchDownX = (int) ev.getRawX();
189                 mTouchDownY = (int) ev.getRawY();
190                 if (mCode != KEYCODE_UNKNOWN) {
191                     sendEvent(KeyEvent.ACTION_DOWN, 0, mDownTime);
192                 } else {
193                     // Provide the same haptic feedback that the system offers for virtual keys.
194                     performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
195                 }
196                 if (!showSwipeUI) {
197                     playSoundEffect(SoundEffectConstants.CLICK);
198                 }
199                 removeCallbacks(mCheckLongPress);
200                 postDelayed(mCheckLongPress, ViewConfiguration.getLongPressTimeout());
201                 break;
202             case MotionEvent.ACTION_MOVE:
203                 x = (int) ev.getRawX();
204                 y = (int) ev.getRawY();
205 
206                 float slop = getQuickStepTouchSlopPx(getContext());
207                 if (Math.abs(x - mTouchDownX) > slop || Math.abs(y - mTouchDownY) > slop) {
208                     // When quick step is enabled, prevent animating the ripple triggered by
209                     // setPressed and decide to run it on touch up
210                     setPressed(false);
211                     removeCallbacks(mCheckLongPress);
212                 }
213                 break;
214             case MotionEvent.ACTION_CANCEL:
215                 setPressed(false);
216                 if (mCode != KEYCODE_UNKNOWN) {
217                     sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
218                 }
219                 removeCallbacks(mCheckLongPress);
220                 break;
221             case MotionEvent.ACTION_UP:
222                 final boolean doIt = isPressed() && !mLongClicked;
223                 setPressed(false);
224                 final boolean doHapticFeedback = (SystemClock.uptimeMillis() - mDownTime) > 150;
225                 if (showSwipeUI) {
226                     if (doIt) {
227                         // Apply haptic feedback on touch up since there is none on touch down
228                         performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
229                         playSoundEffect(SoundEffectConstants.CLICK);
230                     }
231                 } else if (doHapticFeedback && !mLongClicked) {
232                     // Always send a release ourselves because it doesn't seem to be sent elsewhere
233                     // and it feels weird to sometimes get a release haptic and other times not.
234                     performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE);
235                 }
236                 if (mCode != KEYCODE_UNKNOWN) {
237                     if (doIt) {
238                         sendEvent(KeyEvent.ACTION_UP, mTracking ? KeyEvent.FLAG_TRACKING : 0);
239                         mTracking = false;
240                         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
241                     } else {
242                         sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
243                     }
244                 } else {
245                     // no key code, just a regular ImageView
246                     if (doIt && mOnClickListener != null) {
247                         mOnClickListener.onClick(this);
248                         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
249                     }
250                 }
251                 removeCallbacks(mCheckLongPress);
252                 break;
253         }
254 
255         return true;
256     }
257 
258     @Override
setImageDrawable(Drawable drawable)259     public void setImageDrawable(Drawable drawable) {
260         super.setImageDrawable(drawable);
261 
262         if (drawable == null) {
263             return;
264         }
265         KeyButtonDrawable keyButtonDrawable = (KeyButtonDrawable) drawable;
266         keyButtonDrawable.setDarkIntensity(mDarkIntensity);
267         mHasOvalBg = keyButtonDrawable.hasOvalBg();
268         if (mHasOvalBg) {
269             mOvalBgPaint.setColor(keyButtonDrawable.getDrawableBackgroundColor());
270         }
271         mRipple.setType(keyButtonDrawable.hasOvalBg() ? KeyButtonRipple.Type.OVAL
272                 : KeyButtonRipple.Type.ROUNDED_RECT);
273     }
274 
275     @Override
playSoundEffect(int soundConstant)276     public void playSoundEffect(int soundConstant) {
277         if (!mPlaySounds) return;
278         mAudioManager.playSoundEffect(soundConstant);
279     }
280 
sendEvent(int action, int flags)281     private void sendEvent(int action, int flags) {
282         sendEvent(action, flags, SystemClock.uptimeMillis());
283     }
284 
sendEvent(int action, int flags, long when)285     private void sendEvent(int action, int flags, long when) {
286         if (mCode == KeyEvent.KEYCODE_BACK && flags != KeyEvent.FLAG_LONG_PRESS) {
287             if (action == MotionEvent.ACTION_UP) {
288                 // TODO(b/215443343): Implement notifyBackAction();
289             }
290         }
291 
292         // TODO(b/215443343): Consolidate this logic to somewhere else.
293         if (mContext instanceof InputMethodService) {
294             final int repeatCount = (flags & KeyEvent.FLAG_LONG_PRESS) != 0 ? 1 : 0;
295             final KeyEvent ev = new KeyEvent(mDownTime, when, action, mCode, repeatCount,
296                     0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0,
297                     flags | KeyEvent.FLAG_SOFT_KEYBOARD | KeyEvent.FLAG_VIRTUAL_HARD_KEY,
298                     InputDevice.SOURCE_KEYBOARD);
299             int displayId = INVALID_DISPLAY;
300 
301             // Make KeyEvent work on multi-display environment
302             if (getDisplay() != null) {
303                 displayId = getDisplay().getDisplayId();
304             }
305             if (displayId != INVALID_DISPLAY) {
306                 ev.setDisplayId(displayId);
307             }
308             final InputMethodService ims = (InputMethodService) mContext;
309             final boolean handled;
310             switch (action) {
311                 case KeyEvent.ACTION_DOWN:
312                     handled = ims.onKeyDown(ev.getKeyCode(), ev);
313                     mTracking = handled && ev.getRepeatCount() == 0
314                             && (ev.getFlags() & KeyEvent.FLAG_START_TRACKING) != 0;
315                     break;
316                 case KeyEvent.ACTION_UP:
317                     handled = ims.onKeyUp(ev.getKeyCode(), ev);
318                     break;
319                 default:
320                     handled = false;
321                     break;
322             }
323             if (!handled) {
324                 final InputConnection ic = ims.getCurrentInputConnection();
325                 if (ic != null) {
326                     ic.sendKeyEvent(ev);
327                 }
328             }
329         }
330     }
331 
332     @Override
setDarkIntensity(float darkIntensity)333     public void setDarkIntensity(float darkIntensity) {
334         mDarkIntensity = darkIntensity;
335 
336         Drawable drawable = getDrawable();
337         if (drawable != null) {
338             ((KeyButtonDrawable) drawable).setDarkIntensity(darkIntensity);
339             // Since we reuse the same drawable for multiple views, we need to invalidate the view
340             // manually.
341             invalidate();
342         }
343         mRipple.setDarkIntensity(darkIntensity);
344     }
345 
346     @Override
setDelayTouchFeedback(boolean shouldDelay)347     public void setDelayTouchFeedback(boolean shouldDelay) {
348         mRipple.setDelayTouchFeedback(shouldDelay);
349     }
350 
351     @Override
draw(Canvas canvas)352     public void draw(Canvas canvas) {
353         if (mHasOvalBg) {
354             int d = Math.min(getWidth(), getHeight());
355             canvas.drawOval(0, 0, d, d, mOvalBgPaint);
356         }
357         super.draw(canvas);
358     }
359 
360     /**
361      * Ratio of quickstep touch slop (when system takes over the touch) to view touch slop
362      */
363     public static final float QUICKSTEP_TOUCH_SLOP_RATIO = 3;
364 
365     /**
366      * Touch slop for quickstep gesture
367      */
getQuickStepTouchSlopPx(Context context)368     private static float getQuickStepTouchSlopPx(Context context) {
369         return QUICKSTEP_TOUCH_SLOP_RATIO * ViewConfiguration.get(context).getScaledTouchSlop();
370     }
371 }
372