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 android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.AnimatorSet; 22 import android.animation.ValueAnimator; 23 import android.content.Context; 24 import android.graphics.Outline; 25 import android.graphics.drawable.Animatable2; 26 import android.graphics.drawable.AnimatedVectorDrawable; 27 import android.graphics.drawable.Drawable; 28 import android.hardware.biometrics.BiometricPrompt; 29 import android.os.Bundle; 30 import android.text.TextUtils; 31 import android.util.DisplayMetrics; 32 import android.util.Log; 33 import android.view.View; 34 import android.view.ViewOutlineProvider; 35 36 import com.android.systemui.R; 37 38 /** 39 * This class loads the view for the system-provided dialog. The view consists of: 40 * Application Icon, Title, Subtitle, Description, Biometric Icon, Error/Help message area, 41 * and positive/negative buttons. 42 */ 43 public class FaceDialogView extends BiometricDialogView { 44 45 private static final String TAG = "FaceDialogView"; 46 private static final String KEY_DIALOG_SIZE = "key_dialog_size"; 47 private static final String KEY_DIALOG_ANIMATED_IN = "key_dialog_animated_in"; 48 49 private static final int HIDE_DIALOG_DELAY = 500; // ms 50 private static final int IMPLICIT_Y_PADDING = 16; // dp 51 private static final int GROW_DURATION = 150; // ms 52 private static final int TEXT_ANIMATE_DISTANCE = 32; // dp 53 54 private static final int SIZE_UNKNOWN = 0; 55 private static final int SIZE_SMALL = 1; 56 private static final int SIZE_GROWING = 2; 57 private static final int SIZE_BIG = 3; 58 59 private int mSize; 60 private float mIconOriginalY; 61 private DialogOutlineProvider mOutlineProvider = new DialogOutlineProvider(); 62 private IconController mIconController; 63 private boolean mDialogAnimatedIn; 64 65 /** 66 * Class that handles the biometric icon animations. 67 */ 68 private final class IconController extends Animatable2.AnimationCallback { 69 70 private boolean mLastPulseDirection; // false = dark to light, true = light to dark 71 72 int mState; 73 74 IconController() { 75 mState = STATE_IDLE; 76 } 77 78 public void animateOnce(int iconRes) { 79 animateIcon(iconRes, false); 80 } 81 82 public void showStatic(int iconRes) { 83 mBiometricIcon.setImageDrawable(mContext.getDrawable(iconRes)); 84 } 85 86 public void startPulsing() { 87 mLastPulseDirection = false; 88 animateIcon(R.drawable.face_dialog_pulse_dark_to_light, true); 89 } 90 91 public void showIcon(int iconRes) { 92 final Drawable drawable = mContext.getDrawable(iconRes); 93 mBiometricIcon.setImageDrawable(drawable); 94 } 95 96 private void animateIcon(int iconRes, boolean repeat) { 97 final AnimatedVectorDrawable icon = 98 (AnimatedVectorDrawable) mContext.getDrawable(iconRes); 99 mBiometricIcon.setImageDrawable(icon); 100 icon.forceAnimationOnUI(); 101 if (repeat) { 102 icon.registerAnimationCallback(this); 103 } 104 icon.start(); 105 } 106 107 private void pulseInNextDirection() { 108 int iconRes = mLastPulseDirection ? R.drawable.face_dialog_pulse_dark_to_light 109 : R.drawable.face_dialog_pulse_light_to_dark; 110 animateIcon(iconRes, true /* repeat */); 111 mLastPulseDirection = !mLastPulseDirection; 112 } 113 114 @Override 115 public void onAnimationEnd(Drawable drawable) { 116 super.onAnimationEnd(drawable); 117 118 if (mState == STATE_AUTHENTICATING) { 119 // Still authenticating, pulse the icon 120 pulseInNextDirection(); 121 } 122 } 123 } 124 125 private final class DialogOutlineProvider extends ViewOutlineProvider { 126 127 float mY; 128 129 @Override 130 public void getOutline(View view, Outline outline) { 131 outline.setRoundRect( 132 0 /* left */, 133 (int) mY, /* top */ 134 mDialog.getWidth() /* right */, 135 mDialog.getBottom(), /* bottom */ 136 getResources().getDimension(R.dimen.biometric_dialog_corner_size)); 137 } 138 139 int calculateSmall() { 140 final float padding = dpToPixels(IMPLICIT_Y_PADDING); 141 return mDialog.getHeight() - mBiometricIcon.getHeight() - 2 * (int) padding; 142 } 143 144 void setOutlineY(float y) { 145 mY = y; 146 } 147 } 148 149 private final Runnable mErrorToIdleAnimationRunnable = () -> { 150 updateState(STATE_IDLE); 151 mErrorText.setVisibility(View.INVISIBLE); 152 }; 153 154 public FaceDialogView(Context context, 155 DialogViewCallback callback) { 156 super(context, callback); 157 mIconController = new IconController(); 158 } 159 160 private void updateSize(int newSize) { 161 final float padding = dpToPixels(IMPLICIT_Y_PADDING); 162 final float iconSmallPositionY = mDialog.getHeight() - mBiometricIcon.getHeight() - padding; 163 164 if (newSize == SIZE_SMALL) { 165 // These fields are required and/or always hold a spot on the UI, so should be set to 166 // INVISIBLE so they keep their position 167 mTitleText.setVisibility(View.INVISIBLE); 168 mErrorText.setVisibility(View.INVISIBLE); 169 mNegativeButton.setVisibility(View.INVISIBLE); 170 171 // These fields are optional, so set them to gone or invisible depending on their 172 // usage. If they're empty, they're already set to GONE in BiometricDialogView. 173 if (!TextUtils.isEmpty(mSubtitleText.getText())) { 174 mSubtitleText.setVisibility(View.INVISIBLE); 175 } 176 if (!TextUtils.isEmpty(mDescriptionText.getText())) { 177 mDescriptionText.setVisibility(View.INVISIBLE); 178 } 179 180 // Move the biometric icon to the small spot 181 mBiometricIcon.setY(iconSmallPositionY); 182 183 // Clip the dialog to the small size 184 mDialog.setOutlineProvider(mOutlineProvider); 185 mOutlineProvider.setOutlineY(mOutlineProvider.calculateSmall()); 186 187 mDialog.setClipToOutline(true); 188 mDialog.invalidateOutline(); 189 190 mSize = newSize; 191 } else if (mSize == SIZE_SMALL && newSize == SIZE_BIG) { 192 mSize = SIZE_GROWING; 193 194 // Animate the outline 195 final ValueAnimator outlineAnimator = 196 ValueAnimator.ofFloat(mOutlineProvider.calculateSmall(), 0); 197 outlineAnimator.addUpdateListener((animation) -> { 198 final float y = (float) animation.getAnimatedValue(); 199 mOutlineProvider.setOutlineY(y); 200 mDialog.invalidateOutline(); 201 }); 202 203 // Animate the icon back to original big position 204 final ValueAnimator iconAnimator = 205 ValueAnimator.ofFloat(iconSmallPositionY, mIconOriginalY); 206 iconAnimator.addUpdateListener((animation) -> { 207 final float y = (float) animation.getAnimatedValue(); 208 mBiometricIcon.setY(y); 209 }); 210 211 // Animate the error text so it slides up with the icon 212 final ValueAnimator textSlideAnimator = 213 ValueAnimator.ofFloat(dpToPixels(TEXT_ANIMATE_DISTANCE), 0); 214 textSlideAnimator.addUpdateListener((animation) -> { 215 final float y = (float) animation.getAnimatedValue(); 216 mErrorText.setTranslationY(y); 217 }); 218 219 // Opacity animator for things that should fade in (title, subtitle, details, negative 220 // button) 221 final ValueAnimator opacityAnimator = ValueAnimator.ofFloat(0, 1); 222 opacityAnimator.addUpdateListener((animation) -> { 223 final float opacity = (float) animation.getAnimatedValue(); 224 225 // These fields are required and/or always hold a spot on the UI 226 mTitleText.setAlpha(opacity); 227 mErrorText.setAlpha(opacity); 228 mNegativeButton.setAlpha(opacity); 229 mTryAgainButton.setAlpha(opacity); 230 231 // These fields are optional, so only animate them if they're supposed to be showing 232 if (!TextUtils.isEmpty(mSubtitleText.getText())) { 233 mSubtitleText.setAlpha(opacity); 234 } 235 if (!TextUtils.isEmpty(mDescriptionText.getText())) { 236 mDescriptionText.setAlpha(opacity); 237 } 238 }); 239 240 // Choreograph together 241 final AnimatorSet as = new AnimatorSet(); 242 as.setDuration(GROW_DURATION); 243 as.addListener(new AnimatorListenerAdapter() { 244 @Override 245 public void onAnimationStart(Animator animation) { 246 super.onAnimationStart(animation); 247 // Set the visibility of opacity-animating views back to VISIBLE 248 mTitleText.setVisibility(View.VISIBLE); 249 mErrorText.setVisibility(View.VISIBLE); 250 mNegativeButton.setVisibility(View.VISIBLE); 251 mTryAgainButton.setVisibility(View.VISIBLE); 252 253 if (!TextUtils.isEmpty(mSubtitleText.getText())) { 254 mSubtitleText.setVisibility(View.VISIBLE); 255 } 256 if (!TextUtils.isEmpty(mDescriptionText.getText())) { 257 mDescriptionText.setVisibility(View.VISIBLE); 258 } 259 } 260 261 @Override 262 public void onAnimationEnd(Animator animation) { 263 super.onAnimationEnd(animation); 264 mSize = SIZE_BIG; 265 } 266 }); 267 as.play(outlineAnimator).with(iconAnimator).with(opacityAnimator) 268 .with(textSlideAnimator); 269 as.start(); 270 } else if (mSize == SIZE_BIG) { 271 mDialog.setClipToOutline(false); 272 mDialog.invalidateOutline(); 273 274 mBiometricIcon.setY(mIconOriginalY); 275 276 mSize = newSize; 277 } 278 } 279 280 @Override 281 public void onSaveState(Bundle bundle) { 282 super.onSaveState(bundle); 283 bundle.putInt(KEY_DIALOG_SIZE, mSize); 284 bundle.putBoolean(KEY_DIALOG_ANIMATED_IN, mDialogAnimatedIn); 285 } 286 287 288 @Override 289 protected void handleResetMessage() { 290 mErrorText.setText(getHintStringResourceId()); 291 mErrorText.setContentDescription(mContext.getString(getHintStringResourceId())); 292 mErrorText.setTextColor(mTextColor); 293 if (getState() == STATE_AUTHENTICATING) { 294 mErrorText.setVisibility(View.VISIBLE); 295 } else { 296 mErrorText.setVisibility(View.INVISIBLE); 297 } 298 } 299 300 @Override 301 public void restoreState(Bundle bundle) { 302 super.restoreState(bundle); 303 // Keep in mind that this happens before onAttachedToWindow() 304 mSize = bundle.getInt(KEY_DIALOG_SIZE); 305 mDialogAnimatedIn = bundle.getBoolean(KEY_DIALOG_ANIMATED_IN); 306 } 307 308 /** 309 * Do small/big layout here instead of onAttachedToWindow, since: 310 * 1) We need the big layout to be measured, etc for small -> big animation 311 * 2) We need the dialog measurements to know where to move the biometric icon to 312 * 313 * BiometricDialogView already sets the views to their default big state, so here we only 314 * need to hide the ones that are unnecessary. 315 */ 316 @Override 317 public void onLayout(boolean changed, int left, int top, int right, int bottom) { 318 super.onLayout(changed, left, top, right, bottom); 319 320 if (mIconOriginalY == 0) { 321 mIconOriginalY = mBiometricIcon.getY(); 322 } 323 324 // UNKNOWN means size hasn't been set yet. First time we create the dialog. 325 // onLayout can happen when visibility of views change (during animation, etc). 326 if (mSize != SIZE_UNKNOWN) { 327 // Probably not the cleanest way to do this, but since dialog is big by default, 328 // and small dialogs can persist across orientation changes, we need to set it to 329 // small size here again. 330 if (mSize == SIZE_SMALL) { 331 updateSize(SIZE_SMALL); 332 } 333 return; 334 } 335 336 // If we don't require confirmation, show the small dialog first (until errors occur). 337 if (!requiresConfirmation()) { 338 updateSize(SIZE_SMALL); 339 } else { 340 updateSize(SIZE_BIG); 341 } 342 } 343 344 @Override 345 public void onErrorReceived(String error) { 346 super.onErrorReceived(error); 347 // All error messages will cause the dialog to go from small -> big. Error messages 348 // are messages such as lockout, auth failed, etc. 349 if (mSize == SIZE_SMALL) { 350 updateSize(SIZE_BIG); 351 } 352 } 353 354 @Override 355 public void onAuthenticationFailed(String message) { 356 super.onAuthenticationFailed(message); 357 showTryAgainButton(true); 358 } 359 360 @Override 361 public void showTryAgainButton(boolean show) { 362 if (show && mSize == SIZE_SMALL) { 363 // Do not call super, we will nicely animate the alpha together with the rest 364 // of the elements in here. 365 updateSize(SIZE_BIG); 366 } else { 367 if (show) { 368 mTryAgainButton.setVisibility(View.VISIBLE); 369 } else { 370 mTryAgainButton.setVisibility(View.GONE); 371 } 372 } 373 374 if (show) { 375 mPositiveButton.setVisibility(View.GONE); 376 } 377 } 378 379 @Override 380 protected int getHintStringResourceId() { 381 return R.string.face_dialog_looking_for_face; 382 } 383 384 @Override 385 protected int getAuthenticatedAccessibilityResourceId() { 386 if (mRequireConfirmation) { 387 return com.android.internal.R.string.face_authenticated_confirmation_required; 388 } else { 389 return com.android.internal.R.string.face_authenticated_no_confirmation_required; 390 } 391 } 392 393 @Override 394 protected int getIconDescriptionResourceId() { 395 return R.string.accessibility_face_dialog_face_icon; 396 } 397 398 @Override 399 protected void updateIcon(int oldState, int newState) { 400 mIconController.mState = newState; 401 402 if (newState == STATE_AUTHENTICATING) { 403 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable); 404 if (mDialogAnimatedIn) { 405 mIconController.startPulsing(); 406 mErrorText.setVisibility(View.VISIBLE); 407 } else { 408 mIconController.showIcon(R.drawable.face_dialog_pulse_dark_to_light); 409 } 410 mBiometricIcon.setContentDescription(mContext.getString( 411 R.string.biometric_dialog_face_icon_description_authenticating)); 412 } else if (oldState == STATE_PENDING_CONFIRMATION && newState == STATE_AUTHENTICATED) { 413 mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark); 414 mBiometricIcon.setContentDescription(mContext.getString( 415 R.string.biometric_dialog_face_icon_description_confirmed)); 416 } else if (oldState == STATE_ERROR && newState == STATE_IDLE) { 417 mIconController.animateOnce(R.drawable.face_dialog_error_to_idle); 418 mBiometricIcon.setContentDescription(mContext.getString( 419 R.string.biometric_dialog_face_icon_description_idle)); 420 } else if (oldState == STATE_ERROR && newState == STATE_AUTHENTICATED) { 421 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable); 422 mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark); 423 mBiometricIcon.setContentDescription(mContext.getString( 424 R.string.biometric_dialog_face_icon_description_authenticated)); 425 } else if (newState == STATE_ERROR) { 426 // It's easier to only check newState and gate showing the animation on the 427 // mErrorToIdleAnimationRunnable as a proxy, than add a ton of extra state. For example, 428 // we may go from error -> error due to configuration change which is valid and we 429 // should show the animation, or we can go from error -> error by receiving repeated 430 // acquire messages in which case we do not want to repeatedly start the animation. 431 if (!mHandler.hasCallbacks(mErrorToIdleAnimationRunnable)) { 432 mIconController.animateOnce(R.drawable.face_dialog_dark_to_error); 433 mHandler.postDelayed(mErrorToIdleAnimationRunnable, 434 BiometricPrompt.HIDE_DIALOG_DELAY); 435 } 436 } else if (oldState == STATE_AUTHENTICATING && newState == STATE_AUTHENTICATED) { 437 mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark); 438 mBiometricIcon.setContentDescription(mContext.getString( 439 R.string.biometric_dialog_face_icon_description_authenticated)); 440 } else if (newState == STATE_PENDING_CONFIRMATION) { 441 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable); 442 mIconController.animateOnce(R.drawable.face_dialog_wink_from_dark); 443 mBiometricIcon.setContentDescription(mContext.getString( 444 R.string.biometric_dialog_face_icon_description_authenticated)); 445 } else if (newState == STATE_IDLE) { 446 mIconController.showStatic(R.drawable.face_dialog_idle_static); 447 mBiometricIcon.setContentDescription(mContext.getString( 448 R.string.biometric_dialog_face_icon_description_idle)); 449 } else { 450 Log.w(TAG, "Unknown animation from " + oldState + " -> " + newState); 451 } 452 453 // Note that this must be after the newState == STATE_ERROR check above since this affects 454 // the logic. 455 if (oldState == STATE_ERROR && newState == STATE_ERROR) { 456 // Keep the error icon and text around for a while longer if we keep receiving 457 // STATE_ERROR 458 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable); 459 mHandler.postDelayed(mErrorToIdleAnimationRunnable, BiometricPrompt.HIDE_DIALOG_DELAY); 460 } 461 } 462 463 @Override 464 public void onDialogAnimatedIn() { 465 mDialogAnimatedIn = true; 466 mIconController.startPulsing(); 467 } 468 469 @Override 470 protected int getDelayAfterAuthenticatedDurationMs() { 471 return HIDE_DIALOG_DELAY; 472 } 473 474 @Override 475 protected boolean shouldGrayAreaDismissDialog() { 476 if (mSize == SIZE_SMALL) { 477 return false; 478 } 479 return true; 480 } 481 482 private float dpToPixels(float dp) { 483 return dp * ((float) mContext.getResources().getDisplayMetrics().densityDpi 484 / DisplayMetrics.DENSITY_DEFAULT); 485 } 486 487 private float pixelsToDp(float pixels) { 488 return pixels / ((float) mContext.getResources().getDisplayMetrics().densityDpi 489 / DisplayMetrics.DENSITY_DEFAULT); 490 } 491 } 492