| /* |
| * Copyright (C) 2018 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License |
| */ |
| |
| package com.android.systemui.biometrics; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.AnimatorSet; |
| import android.animation.ValueAnimator; |
| import android.content.Context; |
| import android.graphics.Outline; |
| import android.graphics.drawable.Animatable2; |
| import android.graphics.drawable.AnimatedVectorDrawable; |
| import android.graphics.drawable.Drawable; |
| import android.hardware.biometrics.BiometricPrompt; |
| import android.os.Bundle; |
| import android.text.TextUtils; |
| import android.util.DisplayMetrics; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewOutlineProvider; |
| |
| import com.android.systemui.R; |
| |
| /** |
| * This class loads the view for the system-provided dialog. The view consists of: |
| * Application Icon, Title, Subtitle, Description, Biometric Icon, Error/Help message area, |
| * and positive/negative buttons. |
| */ |
| public class FaceDialogView extends BiometricDialogView { |
| |
| private static final String TAG = "FaceDialogView"; |
| private static final String KEY_DIALOG_SIZE = "key_dialog_size"; |
| |
| private static final int HIDE_DIALOG_DELAY = 500; // ms |
| private static final int IMPLICIT_Y_PADDING = 16; // dp |
| private static final int GROW_DURATION = 150; // ms |
| private static final int TEXT_ANIMATE_DISTANCE = 32; // dp |
| |
| private static final int SIZE_UNKNOWN = 0; |
| private static final int SIZE_SMALL = 1; |
| private static final int SIZE_GROWING = 2; |
| private static final int SIZE_BIG = 3; |
| |
| private int mSize; |
| private float mIconOriginalY; |
| private DialogOutlineProvider mOutlineProvider = new DialogOutlineProvider(); |
| private IconController mIconController; |
| private boolean mDialogAnimatedIn; |
| |
| /** |
| * Class that handles the biometric icon animations. |
| */ |
| private final class IconController extends Animatable2.AnimationCallback { |
| |
| private boolean mLastPulseDirection; // false = dark to light, true = light to dark |
| |
| int mState; |
| |
| IconController() { |
| mState = STATE_IDLE; |
| } |
| |
| public void animateOnce(int iconRes) { |
| animateIcon(iconRes, false); |
| } |
| |
| public void startPulsing() { |
| mLastPulseDirection = false; |
| animateIcon(R.drawable.face_dialog_pulse_dark_to_light, true); |
| } |
| |
| public void showIcon(int iconRes) { |
| final Drawable drawable = mContext.getDrawable(iconRes); |
| mBiometricIcon.setImageDrawable(drawable); |
| } |
| |
| private void animateIcon(int iconRes, boolean repeat) { |
| final AnimatedVectorDrawable icon = |
| (AnimatedVectorDrawable) mContext.getDrawable(iconRes); |
| mBiometricIcon.setImageDrawable(icon); |
| icon.forceAnimationOnUI(); |
| if (repeat) { |
| icon.registerAnimationCallback(this); |
| } |
| icon.start(); |
| } |
| |
| private void pulseInNextDirection() { |
| int iconRes = mLastPulseDirection ? R.drawable.face_dialog_pulse_dark_to_light |
| : R.drawable.face_dialog_pulse_light_to_dark; |
| animateIcon(iconRes, true /* repeat */); |
| mLastPulseDirection = !mLastPulseDirection; |
| } |
| |
| @Override |
| public void onAnimationEnd(Drawable drawable) { |
| super.onAnimationEnd(drawable); |
| |
| if (mState == STATE_AUTHENTICATING) { |
| // Still authenticating, pulse the icon |
| pulseInNextDirection(); |
| } |
| } |
| } |
| |
| private final class DialogOutlineProvider extends ViewOutlineProvider { |
| |
| float mY; |
| |
| @Override |
| public void getOutline(View view, Outline outline) { |
| outline.setRoundRect( |
| 0 /* left */, |
| (int) mY, /* top */ |
| mDialog.getWidth() /* right */, |
| mDialog.getBottom(), /* bottom */ |
| getResources().getDimension(R.dimen.biometric_dialog_corner_size)); |
| } |
| |
| int calculateSmall() { |
| final float padding = dpToPixels(IMPLICIT_Y_PADDING); |
| return mDialog.getHeight() - mBiometricIcon.getHeight() - 2 * (int) padding; |
| } |
| |
| void setOutlineY(float y) { |
| mY = y; |
| } |
| } |
| |
| private final Runnable mErrorToIdleAnimationRunnable = () -> { |
| updateState(STATE_IDLE); |
| mErrorText.setVisibility(View.INVISIBLE); |
| }; |
| |
| public FaceDialogView(Context context, |
| DialogViewCallback callback) { |
| super(context, callback); |
| mIconController = new IconController(); |
| } |
| |
| private void updateSize(int newSize) { |
| final float padding = dpToPixels(IMPLICIT_Y_PADDING); |
| final float iconSmallPositionY = mDialog.getHeight() - mBiometricIcon.getHeight() - padding; |
| |
| if (newSize == SIZE_SMALL) { |
| // These fields are required and/or always hold a spot on the UI, so should be set to |
| // INVISIBLE so they keep their position |
| mTitleText.setVisibility(View.INVISIBLE); |
| mErrorText.setVisibility(View.INVISIBLE); |
| mNegativeButton.setVisibility(View.INVISIBLE); |
| |
| // These fields are optional, so set them to gone or invisible depending on their |
| // usage. If they're empty, they're already set to GONE in BiometricDialogView. |
| if (!TextUtils.isEmpty(mSubtitleText.getText())) { |
| mSubtitleText.setVisibility(View.INVISIBLE); |
| } |
| if (!TextUtils.isEmpty(mDescriptionText.getText())) { |
| mDescriptionText.setVisibility(View.INVISIBLE); |
| } |
| |
| // Move the biometric icon to the small spot |
| mBiometricIcon.setY(iconSmallPositionY); |
| |
| // Clip the dialog to the small size |
| mDialog.setOutlineProvider(mOutlineProvider); |
| mOutlineProvider.setOutlineY(mOutlineProvider.calculateSmall()); |
| |
| mDialog.setClipToOutline(true); |
| mDialog.invalidateOutline(); |
| |
| mSize = newSize; |
| } else if (mSize == SIZE_SMALL && newSize == SIZE_BIG) { |
| mSize = SIZE_GROWING; |
| |
| // Animate the outline |
| final ValueAnimator outlineAnimator = |
| ValueAnimator.ofFloat(mOutlineProvider.calculateSmall(), 0); |
| outlineAnimator.addUpdateListener((animation) -> { |
| final float y = (float) animation.getAnimatedValue(); |
| mOutlineProvider.setOutlineY(y); |
| mDialog.invalidateOutline(); |
| }); |
| |
| // Animate the icon back to original big position |
| final ValueAnimator iconAnimator = |
| ValueAnimator.ofFloat(iconSmallPositionY, mIconOriginalY); |
| iconAnimator.addUpdateListener((animation) -> { |
| final float y = (float) animation.getAnimatedValue(); |
| mBiometricIcon.setY(y); |
| }); |
| |
| // Animate the error text so it slides up with the icon |
| final ValueAnimator textSlideAnimator = |
| ValueAnimator.ofFloat(dpToPixels(TEXT_ANIMATE_DISTANCE), 0); |
| textSlideAnimator.addUpdateListener((animation) -> { |
| final float y = (float) animation.getAnimatedValue(); |
| mErrorText.setTranslationY(y); |
| }); |
| |
| // Opacity animator for things that should fade in (title, subtitle, details, negative |
| // button) |
| final ValueAnimator opacityAnimator = ValueAnimator.ofFloat(0, 1); |
| opacityAnimator.addUpdateListener((animation) -> { |
| final float opacity = (float) animation.getAnimatedValue(); |
| |
| // These fields are required and/or always hold a spot on the UI |
| mTitleText.setAlpha(opacity); |
| mErrorText.setAlpha(opacity); |
| mNegativeButton.setAlpha(opacity); |
| mTryAgainButton.setAlpha(opacity); |
| |
| // These fields are optional, so only animate them if they're supposed to be showing |
| if (!TextUtils.isEmpty(mSubtitleText.getText())) { |
| mSubtitleText.setAlpha(opacity); |
| } |
| if (!TextUtils.isEmpty(mDescriptionText.getText())) { |
| mDescriptionText.setAlpha(opacity); |
| } |
| }); |
| |
| // Choreograph together |
| final AnimatorSet as = new AnimatorSet(); |
| as.setDuration(GROW_DURATION); |
| as.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationStart(Animator animation) { |
| super.onAnimationStart(animation); |
| // Set the visibility of opacity-animating views back to VISIBLE |
| mTitleText.setVisibility(View.VISIBLE); |
| mErrorText.setVisibility(View.VISIBLE); |
| mNegativeButton.setVisibility(View.VISIBLE); |
| mTryAgainButton.setVisibility(View.VISIBLE); |
| |
| if (!TextUtils.isEmpty(mSubtitleText.getText())) { |
| mSubtitleText.setVisibility(View.VISIBLE); |
| } |
| if (!TextUtils.isEmpty(mDescriptionText.getText())) { |
| mDescriptionText.setVisibility(View.VISIBLE); |
| } |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| super.onAnimationEnd(animation); |
| mSize = SIZE_BIG; |
| } |
| }); |
| as.play(outlineAnimator).with(iconAnimator).with(opacityAnimator) |
| .with(textSlideAnimator); |
| as.start(); |
| } else if (mSize == SIZE_BIG) { |
| mDialog.setClipToOutline(false); |
| mDialog.invalidateOutline(); |
| |
| mBiometricIcon.setY(mIconOriginalY); |
| |
| mSize = newSize; |
| } |
| } |
| |
| @Override |
| public void onSaveState(Bundle bundle) { |
| super.onSaveState(bundle); |
| bundle.putInt(KEY_DIALOG_SIZE, mSize); |
| } |
| |
| |
| @Override |
| protected void handleClearMessage() { |
| mErrorText.setText(getHintStringResourceId()); |
| mErrorText.setTextColor(mTextColor); |
| } |
| |
| @Override |
| public void restoreState(Bundle bundle) { |
| super.restoreState(bundle); |
| // Keep in mind that this happens before onAttachedToWindow() |
| mSize = bundle.getInt(KEY_DIALOG_SIZE); |
| } |
| |
| /** |
| * Do small/big layout here instead of onAttachedToWindow, since: |
| * 1) We need the big layout to be measured, etc for small -> big animation |
| * 2) We need the dialog measurements to know where to move the biometric icon to |
| * |
| * BiometricDialogView already sets the views to their default big state, so here we only |
| * need to hide the ones that are unnecessary. |
| */ |
| @Override |
| public void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| super.onLayout(changed, left, top, right, bottom); |
| |
| if (mIconOriginalY == 0) { |
| mIconOriginalY = mBiometricIcon.getY(); |
| } |
| |
| // UNKNOWN means size hasn't been set yet. First time we create the dialog. |
| // onLayout can happen when visibility of views change (during animation, etc). |
| if (mSize != SIZE_UNKNOWN) { |
| // Probably not the cleanest way to do this, but since dialog is big by default, |
| // and small dialogs can persist across orientation changes, we need to set it to |
| // small size here again. |
| if (mSize == SIZE_SMALL) { |
| updateSize(SIZE_SMALL); |
| } |
| return; |
| } |
| |
| // If we don't require confirmation, show the small dialog first (until errors occur). |
| if (!requiresConfirmation()) { |
| updateSize(SIZE_SMALL); |
| } else { |
| updateSize(SIZE_BIG); |
| } |
| } |
| |
| @Override |
| public void onErrorReceived(String error) { |
| super.onErrorReceived(error); |
| // All error messages will cause the dialog to go from small -> big. Error messages |
| // are messages such as lockout, auth failed, etc. |
| if (mSize == SIZE_SMALL) { |
| updateSize(SIZE_BIG); |
| } |
| } |
| |
| @Override |
| public void onAuthenticationFailed(String message) { |
| super.onAuthenticationFailed(message); |
| showTryAgainButton(true); |
| } |
| |
| @Override |
| public void showTryAgainButton(boolean show) { |
| if (show && mSize == SIZE_SMALL) { |
| // Do not call super, we will nicely animate the alpha together with the rest |
| // of the elements in here. |
| updateSize(SIZE_BIG); |
| } else { |
| if (show) { |
| mTryAgainButton.setVisibility(View.VISIBLE); |
| } else { |
| mTryAgainButton.setVisibility(View.GONE); |
| } |
| } |
| |
| if (show) { |
| mPositiveButton.setVisibility(View.GONE); |
| } else if (!show && requiresConfirmation()) { |
| mPositiveButton.setVisibility(View.VISIBLE); |
| } |
| } |
| |
| @Override |
| protected int getHintStringResourceId() { |
| return R.string.face_dialog_looking_for_face; |
| } |
| |
| @Override |
| protected int getAuthenticatedAccessibilityResourceId() { |
| if (mRequireConfirmation) { |
| return com.android.internal.R.string.face_authenticated_confirmation_required; |
| } else { |
| return com.android.internal.R.string.face_authenticated_no_confirmation_required; |
| } |
| } |
| |
| @Override |
| protected int getIconDescriptionResourceId() { |
| return R.string.accessibility_face_dialog_face_icon; |
| } |
| |
| @Override |
| protected void updateIcon(int oldState, int newState) { |
| mIconController.mState = newState; |
| |
| if (oldState == STATE_IDLE && newState == STATE_AUTHENTICATING) { |
| if (mDialogAnimatedIn) { |
| mIconController.startPulsing(); |
| mErrorText.setVisibility(View.VISIBLE); |
| } else { |
| mIconController.showIcon(R.drawable.face_dialog_pulse_dark_to_light); |
| } |
| } else if (oldState == STATE_PENDING_CONFIRMATION && newState == STATE_AUTHENTICATED) { |
| mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark); |
| } else if (oldState == STATE_ERROR && newState == STATE_IDLE) { |
| mIconController.animateOnce(R.drawable.face_dialog_error_to_idle); |
| } else if (oldState == STATE_ERROR && newState == STATE_AUTHENTICATING) { |
| mHandler.removeCallbacks(mErrorToIdleAnimationRunnable); |
| mIconController.startPulsing(); |
| } else if (oldState == STATE_AUTHENTICATING && newState == STATE_ERROR) { |
| mIconController.animateOnce(R.drawable.face_dialog_dark_to_error); |
| mHandler.postDelayed(mErrorToIdleAnimationRunnable, BiometricPrompt.HIDE_DIALOG_DELAY); |
| } else if (oldState == STATE_AUTHENTICATING && newState == STATE_AUTHENTICATED) { |
| mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark); |
| } else if (oldState == STATE_AUTHENTICATING && newState == STATE_PENDING_CONFIRMATION) { |
| mIconController.animateOnce(R.drawable.face_dialog_wink_from_dark); |
| } else { |
| Log.w(TAG, "Unknown animation from " + oldState + " -> " + newState); |
| } |
| } |
| |
| @Override |
| public void onDialogAnimatedIn() { |
| mDialogAnimatedIn = true; |
| mIconController.startPulsing(); |
| } |
| |
| @Override |
| protected int getDelayAfterAuthenticatedDurationMs() { |
| return HIDE_DIALOG_DELAY; |
| } |
| |
| @Override |
| protected boolean shouldGrayAreaDismissDialog() { |
| if (mSize == SIZE_SMALL) { |
| return false; |
| } |
| return true; |
| } |
| |
| private float dpToPixels(float dp) { |
| return dp * ((float) mContext.getResources().getDisplayMetrics().densityDpi |
| / DisplayMetrics.DENSITY_DEFAULT); |
| } |
| |
| private float pixelsToDp(float pixels) { |
| return pixels / ((float) mContext.getResources().getDisplayMetrics().densityDpi |
| / DisplayMetrics.DENSITY_DEFAULT); |
| } |
| } |