1 /* 2 * Copyright (C) 2018 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.biometrics; 18 19 import static android.view.accessibility.AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE; 20 21 import android.app.admin.DevicePolicyManager; 22 import android.content.Context; 23 import android.graphics.PixelFormat; 24 import android.graphics.PorterDuff; 25 import android.graphics.drawable.Drawable; 26 import android.hardware.biometrics.BiometricPrompt; 27 import android.os.Binder; 28 import android.os.Bundle; 29 import android.os.Handler; 30 import android.os.IBinder; 31 import android.os.Message; 32 import android.os.UserManager; 33 import android.text.TextUtils; 34 import android.util.DisplayMetrics; 35 import android.util.Log; 36 import android.view.KeyEvent; 37 import android.view.LayoutInflater; 38 import android.view.View; 39 import android.view.ViewGroup; 40 import android.view.WindowManager; 41 import android.view.accessibility.AccessibilityEvent; 42 import android.view.accessibility.AccessibilityManager; 43 import android.view.animation.Interpolator; 44 import android.widget.Button; 45 import android.widget.ImageView; 46 import android.widget.LinearLayout; 47 import android.widget.TextView; 48 49 import com.android.systemui.Interpolators; 50 import com.android.systemui.R; 51 import com.android.systemui.util.leak.RotationUtils; 52 53 /** 54 * Abstract base class. Shows a dialog for BiometricPrompt. 55 */ 56 public abstract class BiometricDialogView extends LinearLayout { 57 58 private static final String TAG = "BiometricDialogView"; 59 60 private static final String KEY_TRY_AGAIN_VISIBILITY = "key_try_again_visibility"; 61 private static final String KEY_CONFIRM_VISIBILITY = "key_confirm_visibility"; 62 private static final String KEY_CONFIRM_ENABLED = "key_confirm_enabled"; 63 private static final String KEY_STATE = "key_state"; 64 private static final String KEY_ERROR_TEXT_VISIBILITY = "key_error_text_visibility"; 65 private static final String KEY_ERROR_TEXT_STRING = "key_error_text_string"; 66 private static final String KEY_ERROR_TEXT_IS_TEMPORARY = "key_error_text_is_temporary"; 67 private static final String KEY_ERROR_TEXT_COLOR = "key_error_text_color"; 68 69 private static final int ANIMATION_DURATION_SHOW = 250; // ms 70 private static final int ANIMATION_DURATION_AWAY = 350; // ms 71 72 protected static final int MSG_RESET_MESSAGE = 1; 73 74 protected static final int STATE_IDLE = 0; 75 protected static final int STATE_AUTHENTICATING = 1; 76 protected static final int STATE_ERROR = 2; 77 protected static final int STATE_PENDING_CONFIRMATION = 3; 78 protected static final int STATE_AUTHENTICATED = 4; 79 80 private final AccessibilityManager mAccessibilityManager; 81 private final IBinder mWindowToken = new Binder(); 82 private final Interpolator mLinearOutSlowIn; 83 private final WindowManager mWindowManager; 84 private final UserManager mUserManager; 85 private final DevicePolicyManager mDevicePolicyManager; 86 private final float mAnimationTranslationOffset; 87 private final int mErrorColor; 88 private final float mDialogWidth; 89 protected final DialogViewCallback mCallback; 90 91 protected final ViewGroup mLayout; 92 protected final LinearLayout mDialog; 93 protected final TextView mTitleText; 94 protected final TextView mSubtitleText; 95 protected final TextView mDescriptionText; 96 protected final ImageView mBiometricIcon; 97 protected final TextView mErrorText; 98 protected final Button mPositiveButton; 99 protected final Button mNegativeButton; 100 protected final Button mTryAgainButton; 101 102 protected final int mTextColor; 103 104 private Bundle mBundle; 105 private Bundle mRestoredState; 106 107 private int mState = STATE_IDLE; 108 private boolean mAnimatingAway; 109 private boolean mWasForceRemoved; 110 private boolean mSkipIntro; 111 protected boolean mRequireConfirmation; 112 private int mUserId; // used to determine if we should show work background 113 114 private boolean mCompletedAnimatingIn; 115 private boolean mPendingDismissDialog; 116 getHintStringResourceId()117 protected abstract int getHintStringResourceId(); getAuthenticatedAccessibilityResourceId()118 protected abstract int getAuthenticatedAccessibilityResourceId(); getIconDescriptionResourceId()119 protected abstract int getIconDescriptionResourceId(); getDelayAfterAuthenticatedDurationMs()120 protected abstract int getDelayAfterAuthenticatedDurationMs(); shouldGrayAreaDismissDialog()121 protected abstract boolean shouldGrayAreaDismissDialog(); handleResetMessage()122 protected abstract void handleResetMessage(); updateIcon(int oldState, int newState)123 protected abstract void updateIcon(int oldState, int newState); 124 125 private final Runnable mShowAnimationRunnable = new Runnable() { 126 @Override 127 public void run() { 128 mLayout.animate() 129 .alpha(1f) 130 .setDuration(ANIMATION_DURATION_SHOW) 131 .setInterpolator(mLinearOutSlowIn) 132 .withLayer() 133 .start(); 134 mDialog.animate() 135 .translationY(0) 136 .setDuration(ANIMATION_DURATION_SHOW) 137 .setInterpolator(mLinearOutSlowIn) 138 .withLayer() 139 .withEndAction(() -> onDialogAnimatedIn()) 140 .start(); 141 } 142 }; 143 144 protected Handler mHandler = new Handler() { 145 @Override 146 public void handleMessage(Message msg) { 147 switch(msg.what) { 148 case MSG_RESET_MESSAGE: 149 handleResetMessage(); 150 break; 151 default: 152 Log.e(TAG, "Unhandled message: " + msg.what); 153 break; 154 } 155 } 156 }; 157 BiometricDialogView(Context context, DialogViewCallback callback)158 public BiometricDialogView(Context context, DialogViewCallback callback) { 159 super(context); 160 mCallback = callback; 161 mLinearOutSlowIn = Interpolators.LINEAR_OUT_SLOW_IN; 162 mAccessibilityManager = mContext.getSystemService(AccessibilityManager.class); 163 mWindowManager = mContext.getSystemService(WindowManager.class); 164 mUserManager = mContext.getSystemService(UserManager.class); 165 mDevicePolicyManager = mContext.getSystemService(DevicePolicyManager.class); 166 mAnimationTranslationOffset = getResources() 167 .getDimension(R.dimen.biometric_dialog_animation_translation_offset); 168 mErrorColor = getResources().getColor(R.color.biometric_dialog_error); 169 mTextColor = getResources().getColor(R.color.biometric_dialog_gray); 170 171 DisplayMetrics metrics = new DisplayMetrics(); 172 mWindowManager.getDefaultDisplay().getMetrics(metrics); 173 mDialogWidth = Math.min(metrics.widthPixels, metrics.heightPixels); 174 175 // Create the dialog 176 LayoutInflater factory = LayoutInflater.from(getContext()); 177 mLayout = (ViewGroup) factory.inflate(R.layout.biometric_dialog, this, false); 178 addView(mLayout); 179 180 mLayout.setOnKeyListener(new View.OnKeyListener() { 181 boolean downPressed = false; 182 @Override 183 public boolean onKey(View v, int keyCode, KeyEvent event) { 184 if (keyCode != KeyEvent.KEYCODE_BACK) { 185 return false; 186 } 187 if (event.getAction() == KeyEvent.ACTION_DOWN && downPressed == false) { 188 downPressed = true; 189 } else if (event.getAction() == KeyEvent.ACTION_DOWN) { 190 downPressed = false; 191 } else if (event.getAction() == KeyEvent.ACTION_UP && downPressed == true) { 192 downPressed = false; 193 mCallback.onUserCanceled(); 194 } 195 return true; 196 } 197 }); 198 199 final View space = mLayout.findViewById(R.id.space); 200 final View leftSpace = mLayout.findViewById(R.id.left_space); 201 final View rightSpace = mLayout.findViewById(R.id.right_space); 202 203 mDialog = mLayout.findViewById(R.id.dialog); 204 mTitleText = mLayout.findViewById(R.id.title); 205 mSubtitleText = mLayout.findViewById(R.id.subtitle); 206 mDescriptionText = mLayout.findViewById(R.id.description); 207 mBiometricIcon = mLayout.findViewById(R.id.biometric_icon); 208 mErrorText = mLayout.findViewById(R.id.error); 209 mNegativeButton = mLayout.findViewById(R.id.button2); 210 mPositiveButton = mLayout.findViewById(R.id.button1); 211 mTryAgainButton = mLayout.findViewById(R.id.button_try_again); 212 213 mBiometricIcon.setContentDescription( 214 getResources().getString(getIconDescriptionResourceId())); 215 216 setDismissesDialog(space); 217 setDismissesDialog(leftSpace); 218 setDismissesDialog(rightSpace); 219 220 mNegativeButton.setOnClickListener((View v) -> { 221 if (mState == STATE_PENDING_CONFIRMATION || mState == STATE_AUTHENTICATED) { 222 mCallback.onUserCanceled(); 223 } else { 224 mCallback.onNegativePressed(); 225 } 226 }); 227 228 mPositiveButton.setOnClickListener((View v) -> { 229 updateState(STATE_AUTHENTICATED); 230 mHandler.postDelayed(() -> { 231 mCallback.onPositivePressed(); 232 }, getDelayAfterAuthenticatedDurationMs()); 233 }); 234 235 mTryAgainButton.setOnClickListener((View v) -> { 236 handleResetMessage(); 237 updateState(STATE_AUTHENTICATING); 238 showTryAgainButton(false /* show */); 239 240 mPositiveButton.setVisibility(View.VISIBLE); 241 mPositiveButton.setEnabled(false); 242 243 mCallback.onTryAgainPressed(); 244 }); 245 246 // Must set these in order for the back button events to be received. 247 mLayout.setFocusableInTouchMode(true); 248 mLayout.requestFocus(); 249 } 250 onSaveState(Bundle bundle)251 public void onSaveState(Bundle bundle) { 252 bundle.putInt(KEY_TRY_AGAIN_VISIBILITY, mTryAgainButton.getVisibility()); 253 bundle.putInt(KEY_CONFIRM_VISIBILITY, mPositiveButton.getVisibility()); 254 bundle.putBoolean(KEY_CONFIRM_ENABLED, mPositiveButton.isEnabled()); 255 bundle.putInt(KEY_STATE, mState); 256 bundle.putInt(KEY_ERROR_TEXT_VISIBILITY, mErrorText.getVisibility()); 257 bundle.putCharSequence(KEY_ERROR_TEXT_STRING, mErrorText.getText()); 258 bundle.putBoolean(KEY_ERROR_TEXT_IS_TEMPORARY, mHandler.hasMessages(MSG_RESET_MESSAGE)); 259 bundle.putInt(KEY_ERROR_TEXT_COLOR, mErrorText.getCurrentTextColor()); 260 } 261 262 @Override onAttachedToWindow()263 public void onAttachedToWindow() { 264 super.onAttachedToWindow(); 265 266 final ImageView backgroundView = mLayout.findViewById(R.id.background); 267 268 if (mUserManager.isManagedProfile(mUserId)) { 269 final Drawable image = getResources().getDrawable(R.drawable.work_challenge_background, 270 mContext.getTheme()); 271 image.setColorFilter(mDevicePolicyManager.getOrganizationColorForUser(mUserId), 272 PorterDuff.Mode.DARKEN); 273 backgroundView.setScaleType(ImageView.ScaleType.CENTER_CROP); 274 backgroundView.setImageDrawable(image); 275 } else { 276 backgroundView.setImageDrawable(null); 277 backgroundView.setBackgroundColor(R.color.biometric_dialog_dim_color); 278 } 279 280 mNegativeButton.setVisibility(View.VISIBLE); 281 282 if (RotationUtils.getRotation(mContext) != RotationUtils.ROTATION_NONE) { 283 mDialog.getLayoutParams().width = (int) mDialogWidth; 284 } 285 286 if (mRestoredState == null) { 287 updateState(STATE_AUTHENTICATING); 288 mNegativeButton.setText(mBundle.getCharSequence(BiometricPrompt.KEY_NEGATIVE_TEXT)); 289 final int hint = getHintStringResourceId(); 290 if (hint != 0) { 291 mErrorText.setText(hint); 292 mErrorText.setContentDescription(mContext.getString(hint)); 293 mErrorText.setVisibility(View.VISIBLE); 294 } else { 295 mErrorText.setVisibility(View.INVISIBLE); 296 } 297 announceAccessibilityEvent(); 298 } else { 299 updateState(mState); 300 } 301 302 CharSequence titleText = mBundle.getCharSequence(BiometricPrompt.KEY_TITLE); 303 304 mTitleText.setVisibility(View.VISIBLE); 305 mTitleText.setText(titleText); 306 307 final CharSequence subtitleText = mBundle.getCharSequence(BiometricPrompt.KEY_SUBTITLE); 308 if (TextUtils.isEmpty(subtitleText)) { 309 mSubtitleText.setVisibility(View.GONE); 310 announceAccessibilityEvent(); 311 } else { 312 mSubtitleText.setVisibility(View.VISIBLE); 313 mSubtitleText.setText(subtitleText); 314 } 315 316 final CharSequence descriptionText = 317 mBundle.getCharSequence(BiometricPrompt.KEY_DESCRIPTION); 318 if (TextUtils.isEmpty(descriptionText)) { 319 mDescriptionText.setVisibility(View.GONE); 320 announceAccessibilityEvent(); 321 } else { 322 mDescriptionText.setVisibility(View.VISIBLE); 323 mDescriptionText.setText(descriptionText); 324 } 325 326 if (requiresConfirmation() && mRestoredState == null) { 327 mPositiveButton.setVisibility(View.VISIBLE); 328 mPositiveButton.setEnabled(false); 329 } 330 331 if (mWasForceRemoved || mSkipIntro) { 332 // Show the dialog immediately 333 mLayout.animate().cancel(); 334 mDialog.animate().cancel(); 335 mDialog.setAlpha(1.0f); 336 mDialog.setTranslationY(0); 337 mLayout.setAlpha(1.0f); 338 mCompletedAnimatingIn = true; 339 } else { 340 // Dim the background and slide the dialog up 341 mDialog.setTranslationY(mAnimationTranslationOffset); 342 mLayout.setAlpha(0f); 343 postOnAnimation(mShowAnimationRunnable); 344 } 345 mWasForceRemoved = false; 346 mSkipIntro = false; 347 } 348 setDismissesDialog(View v)349 private void setDismissesDialog(View v) { 350 v.setClickable(true); 351 v.setOnClickListener(v1 -> { 352 if (mState != STATE_AUTHENTICATED && shouldGrayAreaDismissDialog()) { 353 mCallback.onUserCanceled(); 354 } 355 }); 356 } 357 startDismiss()358 public void startDismiss() { 359 if (!mCompletedAnimatingIn) { 360 Log.w(TAG, "startDismiss(): waiting for onDialogAnimatedIn"); 361 mPendingDismissDialog = true; 362 return; 363 } 364 365 mAnimatingAway = true; 366 367 // This is where final cleanup should occur. 368 final Runnable endActionRunnable = new Runnable() { 369 @Override 370 public void run() { 371 mWindowManager.removeView(BiometricDialogView.this); 372 mAnimatingAway = false; 373 // Set the icons / text back to normal state 374 handleResetMessage(); 375 showTryAgainButton(false /* show */); 376 updateState(STATE_IDLE); 377 } 378 }; 379 380 postOnAnimation(new Runnable() { 381 @Override 382 public void run() { 383 mLayout.animate() 384 .alpha(0f) 385 .setDuration(ANIMATION_DURATION_AWAY) 386 .setInterpolator(mLinearOutSlowIn) 387 .withLayer() 388 .start(); 389 mDialog.animate() 390 .translationY(mAnimationTranslationOffset) 391 .setDuration(ANIMATION_DURATION_AWAY) 392 .setInterpolator(mLinearOutSlowIn) 393 .withLayer() 394 .withEndAction(endActionRunnable) 395 .start(); 396 } 397 }); 398 } 399 400 /** 401 * Force remove the window, cancelling any animation that's happening. This should only be 402 * called if we want to quickly show the dialog again (e.g. on rotation). Calling this method 403 * will cause the dialog to show without an animation the next time it's attached. 404 */ forceRemove()405 public void forceRemove() { 406 mLayout.animate().cancel(); 407 mDialog.animate().cancel(); 408 mWindowManager.removeView(BiometricDialogView.this); 409 mAnimatingAway = false; 410 mWasForceRemoved = true; 411 } 412 413 /** 414 * Skip the intro animation 415 */ setSkipIntro(boolean skip)416 public void setSkipIntro(boolean skip) { 417 mSkipIntro = skip; 418 } 419 isAnimatingAway()420 public boolean isAnimatingAway() { 421 return mAnimatingAway; 422 } 423 setBundle(Bundle bundle)424 public void setBundle(Bundle bundle) { 425 mBundle = bundle; 426 } 427 setRequireConfirmation(boolean requireConfirmation)428 public void setRequireConfirmation(boolean requireConfirmation) { 429 mRequireConfirmation = requireConfirmation; 430 } 431 requiresConfirmation()432 public boolean requiresConfirmation() { 433 return mRequireConfirmation; 434 } 435 setUserId(int userId)436 public void setUserId(int userId) { 437 mUserId = userId; 438 } 439 getLayout()440 public ViewGroup getLayout() { 441 return mLayout; 442 } 443 444 // Shows an error/help message showTemporaryMessage(String message)445 protected void showTemporaryMessage(String message) { 446 mHandler.removeMessages(MSG_RESET_MESSAGE); 447 mErrorText.setText(message); 448 mErrorText.setTextColor(mErrorColor); 449 mErrorText.setContentDescription(message); 450 mErrorText.setVisibility(View.VISIBLE); 451 mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RESET_MESSAGE), 452 BiometricPrompt.HIDE_DIALOG_DELAY); 453 } 454 455 /** 456 * Transient help message (acquire) is received, dialog stays showing. Sensor stays in 457 * "authenticating" state. 458 * @param message 459 */ onHelpReceived(String message)460 public void onHelpReceived(String message) { 461 updateState(STATE_ERROR); 462 showTemporaryMessage(message); 463 } 464 onAuthenticationFailed(String message)465 public void onAuthenticationFailed(String message) { 466 updateState(STATE_ERROR); 467 showTemporaryMessage(message); 468 } 469 470 /** 471 * Hard error is received, dialog will be dismissed soon. 472 * @param error 473 */ onErrorReceived(String error)474 public void onErrorReceived(String error) { 475 updateState(STATE_ERROR); 476 showTemporaryMessage(error); 477 showTryAgainButton(false /* show */); 478 mCallback.onErrorShown(); // TODO: Split between fp and face 479 } 480 updateState(int newState)481 public void updateState(int newState) { 482 if (newState == STATE_PENDING_CONFIRMATION) { 483 mHandler.removeMessages(MSG_RESET_MESSAGE); 484 mErrorText.setTextColor(mTextColor); 485 mErrorText.setText(R.string.biometric_dialog_tap_confirm); 486 mErrorText.setContentDescription( 487 getResources().getString(R.string.biometric_dialog_tap_confirm)); 488 mErrorText.setVisibility(View.VISIBLE); 489 announceAccessibilityEvent(); 490 mPositiveButton.setVisibility(View.VISIBLE); 491 mPositiveButton.setEnabled(true); 492 } else if (newState == STATE_AUTHENTICATED) { 493 mPositiveButton.setVisibility(View.GONE); 494 mNegativeButton.setVisibility(View.GONE); 495 mErrorText.setVisibility(View.INVISIBLE); 496 announceAccessibilityEvent(); 497 } 498 499 if (newState == STATE_PENDING_CONFIRMATION || newState == STATE_AUTHENTICATED) { 500 mNegativeButton.setText(R.string.cancel); 501 mNegativeButton.setContentDescription(getResources().getString(R.string.cancel)); 502 } else { 503 mNegativeButton.setText(mBundle.getCharSequence(BiometricPrompt.KEY_NEGATIVE_TEXT)); 504 } 505 506 updateIcon(mState, newState); 507 mState = newState; 508 } 509 showTryAgainButton(boolean show)510 public void showTryAgainButton(boolean show) { 511 } 512 onDialogAnimatedIn()513 public void onDialogAnimatedIn() { 514 mCompletedAnimatingIn = true; 515 516 if (mPendingDismissDialog) { 517 Log.d(TAG, "onDialogAnimatedIn(): mPendingDismissDialog=true, dismissing now"); 518 startDismiss(); 519 mPendingDismissDialog = false; 520 } 521 } 522 restoreState(Bundle bundle)523 public void restoreState(Bundle bundle) { 524 mRestoredState = bundle; 525 final int tryAgainVisibility = bundle.getInt(KEY_TRY_AGAIN_VISIBILITY); 526 mTryAgainButton.setVisibility(tryAgainVisibility); 527 final int confirmVisibility = bundle.getInt(KEY_CONFIRM_VISIBILITY); 528 mPositiveButton.setVisibility(confirmVisibility); 529 final boolean confirmEnabled = bundle.getBoolean(KEY_CONFIRM_ENABLED); 530 mPositiveButton.setEnabled(confirmEnabled); 531 mState = bundle.getInt(KEY_STATE); 532 mErrorText.setText(bundle.getCharSequence(KEY_ERROR_TEXT_STRING)); 533 mErrorText.setContentDescription(bundle.getCharSequence(KEY_ERROR_TEXT_STRING)); 534 final int errorTextVisibility = bundle.getInt(KEY_ERROR_TEXT_VISIBILITY); 535 mErrorText.setVisibility(errorTextVisibility); 536 if (errorTextVisibility == View.INVISIBLE || tryAgainVisibility == View.INVISIBLE 537 || confirmVisibility == View.INVISIBLE) { 538 announceAccessibilityEvent(); 539 } 540 mErrorText.setTextColor(bundle.getInt(KEY_ERROR_TEXT_COLOR)); 541 if (bundle.getBoolean(KEY_ERROR_TEXT_IS_TEMPORARY)) { 542 mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RESET_MESSAGE), 543 BiometricPrompt.HIDE_DIALOG_DELAY); 544 } 545 } 546 getState()547 protected int getState() { 548 return mState; 549 } 550 getLayoutParams()551 public WindowManager.LayoutParams getLayoutParams() { 552 final WindowManager.LayoutParams lp = new WindowManager.LayoutParams( 553 ViewGroup.LayoutParams.MATCH_PARENT, 554 ViewGroup.LayoutParams.MATCH_PARENT, 555 WindowManager.LayoutParams.TYPE_STATUS_BAR_PANEL, 556 WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, 557 PixelFormat.TRANSLUCENT); 558 lp.privateFlags |= WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS; 559 lp.setTitle("BiometricDialogView"); 560 lp.token = mWindowToken; 561 return lp; 562 } 563 564 // Every time a view becomes invisible we need to announce an accessibility event. 565 // This is due to an issue in the framework, b/132298701 recommended this workaround. announceAccessibilityEvent()566 protected void announceAccessibilityEvent() { 567 if (!mAccessibilityManager.isEnabled()) { 568 return; 569 } 570 AccessibilityEvent event = AccessibilityEvent.obtain(); 571 event.setEventType(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED); 572 event.setContentChangeTypes(CONTENT_CHANGE_TYPE_SUBTREE); 573 mDialog.sendAccessibilityEventUnchecked(event); 574 mDialog.notifySubtreeAccessibilityStateChanged(mDialog, mDialog, 575 CONTENT_CHANGE_TYPE_SUBTREE); 576 } 577 } 578