blob: 8f26f1847779f0f5302f26be5df4cac6360a77b4 [file] [log] [blame]
/*
* 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 String KEY_DIALOG_ANIMATED_IN = "key_dialog_animated_in";
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 showStatic(int iconRes) {
mBiometricIcon.setImageDrawable(mContext.getDrawable(iconRes));
}
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);
bundle.putBoolean(KEY_DIALOG_ANIMATED_IN, mDialogAnimatedIn);
}
@Override
protected void handleResetMessage() {
mErrorText.setText(getHintStringResourceId());
mErrorText.setContentDescription(mContext.getString(getHintStringResourceId()));
mErrorText.setTextColor(mTextColor);
if (getState() == STATE_AUTHENTICATING) {
mErrorText.setVisibility(View.VISIBLE);
} else {
mErrorText.setVisibility(View.INVISIBLE);
}
}
@Override
public void restoreState(Bundle bundle) {
super.restoreState(bundle);
// Keep in mind that this happens before onAttachedToWindow()
mSize = bundle.getInt(KEY_DIALOG_SIZE);
mDialogAnimatedIn = bundle.getBoolean(KEY_DIALOG_ANIMATED_IN);
}
/**
* 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);
}
}
@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 (newState == STATE_AUTHENTICATING) {
mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
if (mDialogAnimatedIn) {
mIconController.startPulsing();
mErrorText.setVisibility(View.VISIBLE);
} else {
mIconController.showIcon(R.drawable.face_dialog_pulse_dark_to_light);
}
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_authenticating));
} else if (oldState == STATE_PENDING_CONFIRMATION && newState == STATE_AUTHENTICATED) {
mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark);
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_confirmed));
} else if (oldState == STATE_ERROR && newState == STATE_IDLE) {
mIconController.animateOnce(R.drawable.face_dialog_error_to_idle);
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_idle));
} else if (oldState == STATE_ERROR && newState == STATE_AUTHENTICATED) {
mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark);
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_authenticated));
} else if (newState == STATE_ERROR) {
// It's easier to only check newState and gate showing the animation on the
// mErrorToIdleAnimationRunnable as a proxy, than add a ton of extra state. For example,
// we may go from error -> error due to configuration change which is valid and we
// should show the animation, or we can go from error -> error by receiving repeated
// acquire messages in which case we do not want to repeatedly start the animation.
if (!mHandler.hasCallbacks(mErrorToIdleAnimationRunnable)) {
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);
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_authenticated));
} else if (newState == STATE_PENDING_CONFIRMATION) {
mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
mIconController.animateOnce(R.drawable.face_dialog_wink_from_dark);
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_authenticated));
} else if (newState == STATE_IDLE) {
mIconController.showStatic(R.drawable.face_dialog_idle_static);
mBiometricIcon.setContentDescription(mContext.getString(
R.string.biometric_dialog_face_icon_description_idle));
} else {
Log.w(TAG, "Unknown animation from " + oldState + " -> " + newState);
}
// Note that this must be after the newState == STATE_ERROR check above since this affects
// the logic.
if (oldState == STATE_ERROR && newState == STATE_ERROR) {
// Keep the error icon and text around for a while longer if we keep receiving
// STATE_ERROR
mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
mHandler.postDelayed(mErrorToIdleAnimationRunnable, BiometricPrompt.HIDE_DIALOG_DELAY);
}
}
@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);
}
}