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 static android.view.accessibility.AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE;
20 
21 import android.app.admin.DevicePolicyManager;
22 import android.content.Context;
23 import android.graphics.PixelFormat;
24 import android.graphics.PorterDuff;
25 import android.graphics.drawable.Drawable;
26 import android.hardware.biometrics.BiometricPrompt;
27 import android.os.Binder;
28 import android.os.Bundle;
29 import android.os.Handler;
30 import android.os.IBinder;
31 import android.os.Message;
32 import android.os.UserManager;
33 import android.text.TextUtils;
34 import android.util.DisplayMetrics;
35 import android.util.Log;
36 import android.view.KeyEvent;
37 import android.view.LayoutInflater;
38 import android.view.View;
39 import android.view.ViewGroup;
40 import android.view.WindowManager;
41 import android.view.accessibility.AccessibilityEvent;
42 import android.view.accessibility.AccessibilityManager;
43 import android.view.animation.Interpolator;
44 import android.widget.Button;
45 import android.widget.ImageView;
46 import android.widget.LinearLayout;
47 import android.widget.TextView;
48 
49 import com.android.systemui.Interpolators;
50 import com.android.systemui.R;
51 import com.android.systemui.util.leak.RotationUtils;
52 
53 /**
54  * Abstract base class. Shows a dialog for BiometricPrompt.
55  */
56 public abstract class BiometricDialogView extends LinearLayout {
57 
58     private static final String TAG = "BiometricDialogView";
59 
60     private static final String KEY_TRY_AGAIN_VISIBILITY = "key_try_again_visibility";
61     private static final String KEY_CONFIRM_VISIBILITY = "key_confirm_visibility";
62     private static final String KEY_CONFIRM_ENABLED = "key_confirm_enabled";
63     private static final String KEY_STATE = "key_state";
64     private static final String KEY_ERROR_TEXT_VISIBILITY = "key_error_text_visibility";
65     private static final String KEY_ERROR_TEXT_STRING = "key_error_text_string";
66     private static final String KEY_ERROR_TEXT_IS_TEMPORARY = "key_error_text_is_temporary";
67     private static final String KEY_ERROR_TEXT_COLOR = "key_error_text_color";
68 
69     private static final int ANIMATION_DURATION_SHOW = 250; // ms
70     private static final int ANIMATION_DURATION_AWAY = 350; // ms
71 
72     protected static final int MSG_RESET_MESSAGE = 1;
73 
74     protected static final int STATE_IDLE = 0;
75     protected static final int STATE_AUTHENTICATING = 1;
76     protected static final int STATE_ERROR = 2;
77     protected static final int STATE_PENDING_CONFIRMATION = 3;
78     protected static final int STATE_AUTHENTICATED = 4;
79 
80     private final AccessibilityManager mAccessibilityManager;
81     private final IBinder mWindowToken = new Binder();
82     private final Interpolator mLinearOutSlowIn;
83     private final WindowManager mWindowManager;
84     private final UserManager mUserManager;
85     private final DevicePolicyManager mDevicePolicyManager;
86     private final float mAnimationTranslationOffset;
87     private final int mErrorColor;
88     private final float mDialogWidth;
89     protected final DialogViewCallback mCallback;
90 
91     protected final ViewGroup mLayout;
92     protected final LinearLayout mDialog;
93     protected final TextView mTitleText;
94     protected final TextView mSubtitleText;
95     protected final TextView mDescriptionText;
96     protected final ImageView mBiometricIcon;
97     protected final TextView mErrorText;
98     protected final Button mPositiveButton;
99     protected final Button mNegativeButton;
100     protected final Button mTryAgainButton;
101 
102     protected final int mTextColor;
103 
104     private Bundle mBundle;
105     private Bundle mRestoredState;
106 
107     private int mState = STATE_IDLE;
108     private boolean mAnimatingAway;
109     private boolean mWasForceRemoved;
110     private boolean mSkipIntro;
111     protected boolean mRequireConfirmation;
112     private int mUserId; // used to determine if we should show work background
113 
114     private boolean mCompletedAnimatingIn;
115     private boolean mPendingDismissDialog;
116 
getHintStringResourceId()117     protected abstract int getHintStringResourceId();
getAuthenticatedAccessibilityResourceId()118     protected abstract int getAuthenticatedAccessibilityResourceId();
getIconDescriptionResourceId()119     protected abstract int getIconDescriptionResourceId();
getDelayAfterAuthenticatedDurationMs()120     protected abstract int getDelayAfterAuthenticatedDurationMs();
shouldGrayAreaDismissDialog()121     protected abstract boolean shouldGrayAreaDismissDialog();
handleResetMessage()122     protected abstract void handleResetMessage();
updateIcon(int oldState, int newState)123     protected abstract void updateIcon(int oldState, int newState);
124 
125     private final Runnable mShowAnimationRunnable = new Runnable() {
126         @Override
127         public void run() {
128             mLayout.animate()
129                     .alpha(1f)
130                     .setDuration(ANIMATION_DURATION_SHOW)
131                     .setInterpolator(mLinearOutSlowIn)
132                     .withLayer()
133                     .start();
134             mDialog.animate()
135                     .translationY(0)
136                     .setDuration(ANIMATION_DURATION_SHOW)
137                     .setInterpolator(mLinearOutSlowIn)
138                     .withLayer()
139                     .withEndAction(() -> onDialogAnimatedIn())
140                     .start();
141         }
142     };
143 
144     protected Handler mHandler = new Handler() {
145         @Override
146         public void handleMessage(Message msg) {
147             switch(msg.what) {
148                 case MSG_RESET_MESSAGE:
149                     handleResetMessage();
150                     break;
151                 default:
152                     Log.e(TAG, "Unhandled message: " + msg.what);
153                     break;
154             }
155         }
156     };
157 
BiometricDialogView(Context context, DialogViewCallback callback)158     public BiometricDialogView(Context context, DialogViewCallback callback) {
159         super(context);
160         mCallback = callback;
161         mLinearOutSlowIn = Interpolators.LINEAR_OUT_SLOW_IN;
162         mAccessibilityManager = mContext.getSystemService(AccessibilityManager.class);
163         mWindowManager = mContext.getSystemService(WindowManager.class);
164         mUserManager = mContext.getSystemService(UserManager.class);
165         mDevicePolicyManager = mContext.getSystemService(DevicePolicyManager.class);
166         mAnimationTranslationOffset = getResources()
167                 .getDimension(R.dimen.biometric_dialog_animation_translation_offset);
168         mErrorColor = getResources().getColor(R.color.biometric_dialog_error);
169         mTextColor = getResources().getColor(R.color.biometric_dialog_gray);
170 
171         DisplayMetrics metrics = new DisplayMetrics();
172         mWindowManager.getDefaultDisplay().getMetrics(metrics);
173         mDialogWidth = Math.min(metrics.widthPixels, metrics.heightPixels);
174 
175         // Create the dialog
176         LayoutInflater factory = LayoutInflater.from(getContext());
177         mLayout = (ViewGroup) factory.inflate(R.layout.biometric_dialog, this, false);
178         addView(mLayout);
179 
180         mLayout.setOnKeyListener(new View.OnKeyListener() {
181             boolean downPressed = false;
182             @Override
183             public boolean onKey(View v, int keyCode, KeyEvent event) {
184                 if (keyCode != KeyEvent.KEYCODE_BACK) {
185                     return false;
186                 }
187                 if (event.getAction() == KeyEvent.ACTION_DOWN && downPressed == false) {
188                     downPressed = true;
189                 } else if (event.getAction() == KeyEvent.ACTION_DOWN) {
190                     downPressed = false;
191                 } else if (event.getAction() == KeyEvent.ACTION_UP && downPressed == true) {
192                     downPressed = false;
193                     mCallback.onUserCanceled();
194                 }
195                 return true;
196             }
197         });
198 
199         final View space = mLayout.findViewById(R.id.space);
200         final View leftSpace = mLayout.findViewById(R.id.left_space);
201         final View rightSpace = mLayout.findViewById(R.id.right_space);
202 
203         mDialog = mLayout.findViewById(R.id.dialog);
204         mTitleText = mLayout.findViewById(R.id.title);
205         mSubtitleText = mLayout.findViewById(R.id.subtitle);
206         mDescriptionText = mLayout.findViewById(R.id.description);
207         mBiometricIcon = mLayout.findViewById(R.id.biometric_icon);
208         mErrorText = mLayout.findViewById(R.id.error);
209         mNegativeButton = mLayout.findViewById(R.id.button2);
210         mPositiveButton = mLayout.findViewById(R.id.button1);
211         mTryAgainButton = mLayout.findViewById(R.id.button_try_again);
212 
213         mBiometricIcon.setContentDescription(
214                 getResources().getString(getIconDescriptionResourceId()));
215 
216         setDismissesDialog(space);
217         setDismissesDialog(leftSpace);
218         setDismissesDialog(rightSpace);
219 
220         mNegativeButton.setOnClickListener((View v) -> {
221             if (mState == STATE_PENDING_CONFIRMATION || mState == STATE_AUTHENTICATED) {
222                 mCallback.onUserCanceled();
223             } else {
224                 mCallback.onNegativePressed();
225             }
226         });
227 
228         mPositiveButton.setOnClickListener((View v) -> {
229             updateState(STATE_AUTHENTICATED);
230             mHandler.postDelayed(() -> {
231                 mCallback.onPositivePressed();
232             }, getDelayAfterAuthenticatedDurationMs());
233         });
234 
235         mTryAgainButton.setOnClickListener((View v) -> {
236             handleResetMessage();
237             updateState(STATE_AUTHENTICATING);
238             showTryAgainButton(false /* show */);
239 
240             mPositiveButton.setVisibility(View.VISIBLE);
241             mPositiveButton.setEnabled(false);
242 
243             mCallback.onTryAgainPressed();
244         });
245 
246         // Must set these in order for the back button events to be received.
247         mLayout.setFocusableInTouchMode(true);
248         mLayout.requestFocus();
249     }
250 
onSaveState(Bundle bundle)251     public void onSaveState(Bundle bundle) {
252         bundle.putInt(KEY_TRY_AGAIN_VISIBILITY, mTryAgainButton.getVisibility());
253         bundle.putInt(KEY_CONFIRM_VISIBILITY, mPositiveButton.getVisibility());
254         bundle.putBoolean(KEY_CONFIRM_ENABLED, mPositiveButton.isEnabled());
255         bundle.putInt(KEY_STATE, mState);
256         bundle.putInt(KEY_ERROR_TEXT_VISIBILITY, mErrorText.getVisibility());
257         bundle.putCharSequence(KEY_ERROR_TEXT_STRING, mErrorText.getText());
258         bundle.putBoolean(KEY_ERROR_TEXT_IS_TEMPORARY, mHandler.hasMessages(MSG_RESET_MESSAGE));
259         bundle.putInt(KEY_ERROR_TEXT_COLOR, mErrorText.getCurrentTextColor());
260     }
261 
262     @Override
onAttachedToWindow()263     public void onAttachedToWindow() {
264         super.onAttachedToWindow();
265 
266         final ImageView backgroundView = mLayout.findViewById(R.id.background);
267 
268         if (mUserManager.isManagedProfile(mUserId)) {
269             final Drawable image = getResources().getDrawable(R.drawable.work_challenge_background,
270                     mContext.getTheme());
271             image.setColorFilter(mDevicePolicyManager.getOrganizationColorForUser(mUserId),
272                     PorterDuff.Mode.DARKEN);
273             backgroundView.setScaleType(ImageView.ScaleType.CENTER_CROP);
274             backgroundView.setImageDrawable(image);
275         } else {
276             backgroundView.setImageDrawable(null);
277             backgroundView.setBackgroundColor(R.color.biometric_dialog_dim_color);
278         }
279 
280         mNegativeButton.setVisibility(View.VISIBLE);
281 
282         if (RotationUtils.getRotation(mContext) != RotationUtils.ROTATION_NONE) {
283             mDialog.getLayoutParams().width = (int) mDialogWidth;
284         }
285 
286         if (mRestoredState == null) {
287             updateState(STATE_AUTHENTICATING);
288             mNegativeButton.setText(mBundle.getCharSequence(BiometricPrompt.KEY_NEGATIVE_TEXT));
289             final int hint = getHintStringResourceId();
290             if (hint != 0) {
291                 mErrorText.setText(hint);
292                 mErrorText.setContentDescription(mContext.getString(hint));
293                 mErrorText.setVisibility(View.VISIBLE);
294             } else {
295                 mErrorText.setVisibility(View.INVISIBLE);
296             }
297             announceAccessibilityEvent();
298         } else {
299             updateState(mState);
300         }
301 
302         CharSequence titleText = mBundle.getCharSequence(BiometricPrompt.KEY_TITLE);
303 
304         mTitleText.setVisibility(View.VISIBLE);
305         mTitleText.setText(titleText);
306 
307         final CharSequence subtitleText = mBundle.getCharSequence(BiometricPrompt.KEY_SUBTITLE);
308         if (TextUtils.isEmpty(subtitleText)) {
309             mSubtitleText.setVisibility(View.GONE);
310             announceAccessibilityEvent();
311         } else {
312             mSubtitleText.setVisibility(View.VISIBLE);
313             mSubtitleText.setText(subtitleText);
314         }
315 
316         final CharSequence descriptionText =
317                 mBundle.getCharSequence(BiometricPrompt.KEY_DESCRIPTION);
318         if (TextUtils.isEmpty(descriptionText)) {
319             mDescriptionText.setVisibility(View.GONE);
320             announceAccessibilityEvent();
321         } else {
322             mDescriptionText.setVisibility(View.VISIBLE);
323             mDescriptionText.setText(descriptionText);
324         }
325 
326         if (requiresConfirmation() && mRestoredState == null) {
327             mPositiveButton.setVisibility(View.VISIBLE);
328             mPositiveButton.setEnabled(false);
329         }
330 
331         if (mWasForceRemoved || mSkipIntro) {
332             // Show the dialog immediately
333             mLayout.animate().cancel();
334             mDialog.animate().cancel();
335             mDialog.setAlpha(1.0f);
336             mDialog.setTranslationY(0);
337             mLayout.setAlpha(1.0f);
338             mCompletedAnimatingIn = true;
339         } else {
340             // Dim the background and slide the dialog up
341             mDialog.setTranslationY(mAnimationTranslationOffset);
342             mLayout.setAlpha(0f);
343             postOnAnimation(mShowAnimationRunnable);
344         }
345         mWasForceRemoved = false;
346         mSkipIntro = false;
347     }
348 
setDismissesDialog(View v)349     private void setDismissesDialog(View v) {
350         v.setClickable(true);
351         v.setOnClickListener(v1 -> {
352             if (mState != STATE_AUTHENTICATED && shouldGrayAreaDismissDialog()) {
353                 mCallback.onUserCanceled();
354             }
355         });
356     }
357 
startDismiss()358     public void startDismiss() {
359         if (!mCompletedAnimatingIn) {
360             Log.w(TAG, "startDismiss(): waiting for onDialogAnimatedIn");
361             mPendingDismissDialog = true;
362             return;
363         }
364 
365         mAnimatingAway = true;
366 
367         // This is where final cleanup should occur.
368         final Runnable endActionRunnable = new Runnable() {
369             @Override
370             public void run() {
371                 mWindowManager.removeView(BiometricDialogView.this);
372                 mAnimatingAway = false;
373                 // Set the icons / text back to normal state
374                 handleResetMessage();
375                 showTryAgainButton(false /* show */);
376                 updateState(STATE_IDLE);
377             }
378         };
379 
380         postOnAnimation(new Runnable() {
381             @Override
382             public void run() {
383                 mLayout.animate()
384                         .alpha(0f)
385                         .setDuration(ANIMATION_DURATION_AWAY)
386                         .setInterpolator(mLinearOutSlowIn)
387                         .withLayer()
388                         .start();
389                 mDialog.animate()
390                         .translationY(mAnimationTranslationOffset)
391                         .setDuration(ANIMATION_DURATION_AWAY)
392                         .setInterpolator(mLinearOutSlowIn)
393                         .withLayer()
394                         .withEndAction(endActionRunnable)
395                         .start();
396             }
397         });
398     }
399 
400     /**
401      * Force remove the window, cancelling any animation that's happening. This should only be
402      * called if we want to quickly show the dialog again (e.g. on rotation). Calling this method
403      * will cause the dialog to show without an animation the next time it's attached.
404      */
forceRemove()405     public void forceRemove() {
406         mLayout.animate().cancel();
407         mDialog.animate().cancel();
408         mWindowManager.removeView(BiometricDialogView.this);
409         mAnimatingAway = false;
410         mWasForceRemoved = true;
411     }
412 
413     /**
414      * Skip the intro animation
415      */
setSkipIntro(boolean skip)416     public void setSkipIntro(boolean skip) {
417         mSkipIntro = skip;
418     }
419 
isAnimatingAway()420     public boolean isAnimatingAway() {
421         return mAnimatingAway;
422     }
423 
setBundle(Bundle bundle)424     public void setBundle(Bundle bundle) {
425         mBundle = bundle;
426     }
427 
setRequireConfirmation(boolean requireConfirmation)428     public void setRequireConfirmation(boolean requireConfirmation) {
429         mRequireConfirmation = requireConfirmation;
430     }
431 
requiresConfirmation()432     public boolean requiresConfirmation() {
433         return mRequireConfirmation;
434     }
435 
setUserId(int userId)436     public void setUserId(int userId) {
437         mUserId = userId;
438     }
439 
getLayout()440     public ViewGroup getLayout() {
441         return mLayout;
442     }
443 
444     // Shows an error/help message
showTemporaryMessage(String message)445     protected void showTemporaryMessage(String message) {
446         mHandler.removeMessages(MSG_RESET_MESSAGE);
447         mErrorText.setText(message);
448         mErrorText.setTextColor(mErrorColor);
449         mErrorText.setContentDescription(message);
450         mErrorText.setVisibility(View.VISIBLE);
451         mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RESET_MESSAGE),
452                 BiometricPrompt.HIDE_DIALOG_DELAY);
453     }
454 
455     /**
456      * Transient help message (acquire) is received, dialog stays showing. Sensor stays in
457      * "authenticating" state.
458      * @param message
459      */
onHelpReceived(String message)460     public void onHelpReceived(String message) {
461         updateState(STATE_ERROR);
462         showTemporaryMessage(message);
463     }
464 
onAuthenticationFailed(String message)465     public void onAuthenticationFailed(String message) {
466         updateState(STATE_ERROR);
467         showTemporaryMessage(message);
468     }
469 
470     /**
471      * Hard error is received, dialog will be dismissed soon.
472      * @param error
473      */
onErrorReceived(String error)474     public void onErrorReceived(String error) {
475         updateState(STATE_ERROR);
476         showTemporaryMessage(error);
477         showTryAgainButton(false /* show */);
478         mCallback.onErrorShown(); // TODO: Split between fp and face
479     }
480 
updateState(int newState)481     public void updateState(int newState) {
482         if (newState == STATE_PENDING_CONFIRMATION) {
483             mHandler.removeMessages(MSG_RESET_MESSAGE);
484             mErrorText.setTextColor(mTextColor);
485             mErrorText.setText(R.string.biometric_dialog_tap_confirm);
486             mErrorText.setContentDescription(
487                     getResources().getString(R.string.biometric_dialog_tap_confirm));
488             mErrorText.setVisibility(View.VISIBLE);
489             announceAccessibilityEvent();
490             mPositiveButton.setVisibility(View.VISIBLE);
491             mPositiveButton.setEnabled(true);
492         } else if (newState == STATE_AUTHENTICATED) {
493             mPositiveButton.setVisibility(View.GONE);
494             mNegativeButton.setVisibility(View.GONE);
495             mErrorText.setVisibility(View.INVISIBLE);
496             announceAccessibilityEvent();
497         }
498 
499         if (newState == STATE_PENDING_CONFIRMATION || newState == STATE_AUTHENTICATED) {
500             mNegativeButton.setText(R.string.cancel);
501             mNegativeButton.setContentDescription(getResources().getString(R.string.cancel));
502         } else {
503             mNegativeButton.setText(mBundle.getCharSequence(BiometricPrompt.KEY_NEGATIVE_TEXT));
504         }
505 
506         updateIcon(mState, newState);
507         mState = newState;
508     }
509 
showTryAgainButton(boolean show)510     public void showTryAgainButton(boolean show) {
511     }
512 
onDialogAnimatedIn()513     public void onDialogAnimatedIn() {
514         mCompletedAnimatingIn = true;
515 
516         if (mPendingDismissDialog) {
517             Log.d(TAG, "onDialogAnimatedIn(): mPendingDismissDialog=true, dismissing now");
518             startDismiss();
519             mPendingDismissDialog = false;
520         }
521     }
522 
restoreState(Bundle bundle)523     public void restoreState(Bundle bundle) {
524         mRestoredState = bundle;
525         final int tryAgainVisibility = bundle.getInt(KEY_TRY_AGAIN_VISIBILITY);
526         mTryAgainButton.setVisibility(tryAgainVisibility);
527         final int confirmVisibility = bundle.getInt(KEY_CONFIRM_VISIBILITY);
528         mPositiveButton.setVisibility(confirmVisibility);
529         final boolean confirmEnabled = bundle.getBoolean(KEY_CONFIRM_ENABLED);
530         mPositiveButton.setEnabled(confirmEnabled);
531         mState = bundle.getInt(KEY_STATE);
532         mErrorText.setText(bundle.getCharSequence(KEY_ERROR_TEXT_STRING));
533         mErrorText.setContentDescription(bundle.getCharSequence(KEY_ERROR_TEXT_STRING));
534         final int errorTextVisibility = bundle.getInt(KEY_ERROR_TEXT_VISIBILITY);
535         mErrorText.setVisibility(errorTextVisibility);
536         if (errorTextVisibility == View.INVISIBLE || tryAgainVisibility == View.INVISIBLE
537                 || confirmVisibility == View.INVISIBLE) {
538             announceAccessibilityEvent();
539         }
540         mErrorText.setTextColor(bundle.getInt(KEY_ERROR_TEXT_COLOR));
541         if (bundle.getBoolean(KEY_ERROR_TEXT_IS_TEMPORARY)) {
542             mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_RESET_MESSAGE),
543                     BiometricPrompt.HIDE_DIALOG_DELAY);
544         }
545     }
546 
getState()547     protected int getState() {
548         return mState;
549     }
550 
getLayoutParams()551     public WindowManager.LayoutParams getLayoutParams() {
552         final WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
553                 ViewGroup.LayoutParams.MATCH_PARENT,
554                 ViewGroup.LayoutParams.MATCH_PARENT,
555                 WindowManager.LayoutParams.TYPE_STATUS_BAR_PANEL,
556                 WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
557                 PixelFormat.TRANSLUCENT);
558         lp.privateFlags |= WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS;
559         lp.setTitle("BiometricDialogView");
560         lp.token = mWindowToken;
561         return lp;
562     }
563 
564     // Every time a view becomes invisible we need to announce an accessibility event.
565     // This is due to an issue in the framework, b/132298701 recommended this workaround.
announceAccessibilityEvent()566     protected void announceAccessibilityEvent() {
567         if (!mAccessibilityManager.isEnabled()) {
568             return;
569         }
570         AccessibilityEvent event = AccessibilityEvent.obtain();
571         event.setEventType(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
572         event.setContentChangeTypes(CONTENT_CHANGE_TYPE_SUBTREE);
573         mDialog.sendAccessibilityEventUnchecked(event);
574         mDialog.notifySubtreeAccessibilityStateChanged(mDialog, mDialog,
575                 CONTENT_CHANGE_TYPE_SUBTREE);
576     }
577 }
578