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