Home | History | Annotate | Download | only in biometrics
      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