blob: 8f26f1847779f0f5302f26be5df4cac6360a77b4 [file] [log] [blame]
Kevin Chyn6cf54e82018-09-18 19:13:27 -07001/*
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
17package com.android.systemui.biometrics;
18
Kevin Chyne1912712019-01-04 14:22:34 -080019import android.animation.Animator;
20import android.animation.AnimatorListenerAdapter;
21import android.animation.AnimatorSet;
22import android.animation.ValueAnimator;
Kevin Chyn6cf54e82018-09-18 19:13:27 -070023import android.content.Context;
Kevin Chyne1912712019-01-04 14:22:34 -080024import android.graphics.Outline;
Kevin Chyn3b53d6f2019-05-01 11:49:05 -070025import android.graphics.drawable.Animatable2;
26import android.graphics.drawable.AnimatedVectorDrawable;
Kevin Chyn6cf54e82018-09-18 19:13:27 -070027import android.graphics.drawable.Drawable;
Kevin Chyn3b53d6f2019-05-01 11:49:05 -070028import android.hardware.biometrics.BiometricPrompt;
Kevin Chyne1912712019-01-04 14:22:34 -080029import android.os.Bundle;
30import android.text.TextUtils;
31import android.util.DisplayMetrics;
Kevin Chyn3b53d6f2019-05-01 11:49:05 -070032import android.util.Log;
Kevin Chyne1912712019-01-04 14:22:34 -080033import android.view.View;
34import android.view.ViewOutlineProvider;
Kevin Chyn6cf54e82018-09-18 19:13:27 -070035
36import com.android.systemui.R;
37
38/**
39 * This class loads the view for the system-provided dialog. The view consists of:
Kevin Chyn6bb20772018-12-27 15:14:44 -080040 * Application Icon, Title, Subtitle, Description, Biometric Icon, Error/Help message area,
Kevin Chyn6cf54e82018-09-18 19:13:27 -070041 * and positive/negative buttons.
42 */
43public class FaceDialogView extends BiometricDialogView {
Kevin Chyn6bb20772018-12-27 15:14:44 -080044
Kevin Chyne1912712019-01-04 14:22:34 -080045 private static final String TAG = "FaceDialogView";
46 private static final String KEY_DIALOG_SIZE = "key_dialog_size";
Kevin Chyn8cfd7a42019-05-07 20:22:31 -070047 private static final String KEY_DIALOG_ANIMATED_IN = "key_dialog_animated_in";
Kevin Chyne1912712019-01-04 14:22:34 -080048
Kevin Chyn6bb20772018-12-27 15:14:44 -080049 private static final int HIDE_DIALOG_DELAY = 500; // ms
Kevin Chyne1912712019-01-04 14:22:34 -080050 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();
Kevin Chyn3b53d6f2019-05-01 11:49:05 -070062 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
Kevin Chyn8cfd7a42019-05-07 20:22:31 -070082 public void showStatic(int iconRes) {
83 mBiometricIcon.setImageDrawable(mContext.getDrawable(iconRes));
84 }
85
Kevin Chyn3b53d6f2019-05-01 11:49:05 -070086 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 }
Kevin Chyne1912712019-01-04 14:22:34 -0800124
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 }
Kevin Chyn6bb20772018-12-27 15:14:44 -0800148
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700149 private final Runnable mErrorToIdleAnimationRunnable = () -> {
150 updateState(STATE_IDLE);
151 mErrorText.setVisibility(View.INVISIBLE);
152 };
153
Kevin Chyn6cf54e82018-09-18 19:13:27 -0700154 public FaceDialogView(Context context,
155 DialogViewCallback callback) {
156 super(context, callback);
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700157 mIconController = new IconController();
Kevin Chyn6cf54e82018-09-18 19:13:27 -0700158 }
159
Kevin Chyne1912712019-01-04 14:22:34 -0800160 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);
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700284 bundle.putBoolean(KEY_DIALOG_ANIMATED_IN, mDialogAnimatedIn);
Kevin Chyne1912712019-01-04 14:22:34 -0800285 }
286
Kevin Chync9744ac2019-01-11 18:49:50 -0800287
288 @Override
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700289 protected void handleResetMessage() {
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700290 mErrorText.setText(getHintStringResourceId());
Kevin Chynbb79c002019-05-14 12:53:20 -0700291 mErrorText.setContentDescription(mContext.getString(getHintStringResourceId()));
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700292 mErrorText.setTextColor(mTextColor);
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700293 if (getState() == STATE_AUTHENTICATING) {
294 mErrorText.setVisibility(View.VISIBLE);
295 } else {
296 mErrorText.setVisibility(View.INVISIBLE);
297 }
Kevin Chync9744ac2019-01-11 18:49:50 -0800298 }
299
Kevin Chyne1912712019-01-04 14:22:34 -0800300 @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);
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700305 mDialogAnimatedIn = bundle.getBoolean(KEY_DIALOG_ANIMATED_IN);
Kevin Chyne1912712019-01-04 14:22:34 -0800306 }
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
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700345 public void onErrorReceived(String error) {
346 super.onErrorReceived(error);
Kevin Chyne1912712019-01-04 14:22:34 -0800347 // 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
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700355 public void onAuthenticationFailed(String message) {
356 super.onAuthenticationFailed(message);
357 showTryAgainButton(true);
358 }
359
360 @Override
Kevin Chyne1912712019-01-04 14:22:34 -0800361 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 {
Kevin Chync9744ac2019-01-11 18:49:50 -0800367 if (show) {
368 mTryAgainButton.setVisibility(View.VISIBLE);
369 } else {
370 mTryAgainButton.setVisibility(View.GONE);
371 }
Kevin Chyne1912712019-01-04 14:22:34 -0800372 }
Kevin Chyn8d39b4e2019-04-26 11:02:13 -0700373
374 if (show) {
375 mPositiveButton.setVisibility(View.GONE);
Kevin Chyn8d39b4e2019-04-26 11:02:13 -0700376 }
Kevin Chyne1912712019-01-04 14:22:34 -0800377 }
378
Kevin Chyn6cf54e82018-09-18 19:13:27 -0700379 @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
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700399 protected void updateIcon(int oldState, int newState) {
400 mIconController.mState = newState;
401
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700402 if (newState == STATE_AUTHENTICATING) {
403 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700404 if (mDialogAnimatedIn) {
405 mIconController.startPulsing();
406 mErrorText.setVisibility(View.VISIBLE);
407 } else {
408 mIconController.showIcon(R.drawable.face_dialog_pulse_dark_to_light);
409 }
Kevin Chynbb79c002019-05-14 12:53:20 -0700410 mBiometricIcon.setContentDescription(mContext.getString(
411 R.string.biometric_dialog_face_icon_description_authenticating));
Kevin Chyn6bb20772018-12-27 15:14:44 -0800412 } else if (oldState == STATE_PENDING_CONFIRMATION && newState == STATE_AUTHENTICATED) {
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700413 mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark);
Kevin Chynbb79c002019-05-14 12:53:20 -0700414 mBiometricIcon.setContentDescription(mContext.getString(
415 R.string.biometric_dialog_face_icon_description_confirmed));
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700416 } else if (oldState == STATE_ERROR && newState == STATE_IDLE) {
417 mIconController.animateOnce(R.drawable.face_dialog_error_to_idle);
Kevin Chynbb79c002019-05-14 12:53:20 -0700418 mBiometricIcon.setContentDescription(mContext.getString(
419 R.string.biometric_dialog_face_icon_description_idle));
Kevin Chyna60118c2019-05-07 16:02:53 -0700420 } else if (oldState == STATE_ERROR && newState == STATE_AUTHENTICATED) {
421 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
422 mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark);
Kevin Chynbb79c002019-05-14 12:53:20 -0700423 mBiometricIcon.setContentDescription(mContext.getString(
424 R.string.biometric_dialog_face_icon_description_authenticated));
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700425 } 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 }
Kevin Chyn6bb20772018-12-27 15:14:44 -0800436 } else if (oldState == STATE_AUTHENTICATING && newState == STATE_AUTHENTICATED) {
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700437 mIconController.animateOnce(R.drawable.face_dialog_dark_to_checkmark);
Kevin Chynbb79c002019-05-14 12:53:20 -0700438 mBiometricIcon.setContentDescription(mContext.getString(
439 R.string.biometric_dialog_face_icon_description_authenticated));
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700440 } else if (newState == STATE_PENDING_CONFIRMATION) {
441 mHandler.removeCallbacks(mErrorToIdleAnimationRunnable);
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700442 mIconController.animateOnce(R.drawable.face_dialog_wink_from_dark);
Kevin Chynbb79c002019-05-14 12:53:20 -0700443 mBiometricIcon.setContentDescription(mContext.getString(
444 R.string.biometric_dialog_face_icon_description_authenticated));
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700445 } else if (newState == STATE_IDLE) {
446 mIconController.showStatic(R.drawable.face_dialog_idle_static);
Kevin Chynbb79c002019-05-14 12:53:20 -0700447 mBiometricIcon.setContentDescription(mContext.getString(
448 R.string.biometric_dialog_face_icon_description_idle));
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700449 } else {
450 Log.w(TAG, "Unknown animation from " + oldState + " -> " + newState);
Kevin Chyn6bb20772018-12-27 15:14:44 -0800451 }
Kevin Chyn8cfd7a42019-05-07 20:22:31 -0700452
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 }
Kevin Chyn3b53d6f2019-05-01 11:49:05 -0700461 }
462
463 @Override
464 public void onDialogAnimatedIn() {
465 mDialogAnimatedIn = true;
466 mIconController.startPulsing();
Kevin Chyn6bb20772018-12-27 15:14:44 -0800467 }
Kevin Chyn6cf54e82018-09-18 19:13:27 -0700468
Kevin Chyn6bb20772018-12-27 15:14:44 -0800469 @Override
470 protected int getDelayAfterAuthenticatedDurationMs() {
471 return HIDE_DIALOG_DELAY;
472 }
473
474 @Override
Kevin Chyne1912712019-01-04 14:22:34 -0800475 protected boolean shouldGrayAreaDismissDialog() {
476 if (mSize == SIZE_SMALL) {
477 return false;
478 }
479 return true;
480 }
481
Kevin Chyne1912712019-01-04 14:22:34 -0800482 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 }
Kevin Chyn6cf54e82018-09-18 19:13:27 -0700491}