1/n: Refactor BiometricPrompt UI hierarchy

The UI is split into a few components now
 1) BiometricPromptContainerView - top level, contains the work profile
    background view, the panel (rounded background)
 2) BiometricPromptBiometricView - nested within, displays contents for
    biometric auth

The panel must be one level higher (in hierarchy) than the biometric
dialog to allow future non-biometric views to be added cleanly, and to
allow separate animations for the background/foreground.

Bug: 123378871
Test: Demo app with text that requires scrolling; dialog bounds are correct,
      view elements are contained within the dialog bounds
Test: atest BiometricDialogImplTest
Test: atest BiometricDialogViewTest
Test: atest AuthBiometricViewTest
Test: atest AuthBiometricFaceViewTest

Change-Id: Ie4e5a8641a10229154a1011afefacb823aadf565
This commit is contained in:
Kevin Chyn
2019-08-20 17:17:11 -07:00
parent 4077d36bfc
commit fc46826f0a
14 changed files with 1254 additions and 21 deletions

View File

@@ -0,0 +1,119 @@
<!--
~ Copyright (C) 2019 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.
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
android:id="@+id/title"
android:fontFamily="@*android:string/config_headlineFontFamilyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingTop="24dp"
android:gravity="@integer/biometric_dialog_text_gravity"
android:textSize="20sp"
android:textColor="?android:attr/textColorPrimary"/>
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingHorizontal="24dp"
android:gravity="@integer/biometric_dialog_text_gravity"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"/>
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingBottom="48dp"
android:paddingTop="8dp"
android:gravity="@integer/biometric_dialog_text_gravity"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"/>
<ImageView
android:id="@+id/biometric_icon"
android:layout_width="@dimen/biometric_dialog_biometric_icon_size"
android:layout_height="@dimen/biometric_dialog_biometric_icon_size"
android:layout_gravity="center_horizontal"
android:scaleType="fitXY" />
<TextView
android:id="@+id/error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:paddingTop="16dp"
android:paddingBottom="24dp"
android:textSize="12sp"
android:gravity="center_horizontal"
android:accessibilityLiveRegion="polite"
android:textColor="@color/biometric_dialog_gray"
android:text="ERROR"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="72dip"
android:paddingTop="24dp"
android:layout_gravity="center_vertical"
style="?android:attr/buttonBarStyle"
android:orientation="horizontal">
<Space android:id="@+id/leftSpacer"
android:layout_width="12dp"
android:layout_height="match_parent"
android:visibility="visible" />
<!-- Negative Button -->
<Button android:id="@+id/button_negative"
android:layout_width="wrap_content"
android:layout_height="match_parent"
style="@*android:style/Widget.DeviceDefault.Button.Borderless.Colored"
android:gravity="center"
android:maxLines="2"
android:text="NEGATIVE"/>
<Space android:id="@+id/middleSpacer"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:visibility="visible" />
<!-- Positive Button -->
<Button android:id="@+id/button_positive"
android:layout_width="wrap_content"
android:layout_height="match_parent"
style="@*android:style/Widget.DeviceDefault.Button.Colored"
android:gravity="center"
android:maxLines="2"
android:text="@string/biometric_dialog_confirm"
android:visibility="gone"/>
<!-- Try Again Button -->
<Button android:id="@+id/button_try_again"
android:layout_width="wrap_content"
android:layout_height="match_parent"
style="@*android:style/Widget.DeviceDefault.Button.Colored"
android:gravity="center"
android:maxLines="2"
android:text="@string/biometric_dialog_try_again"
android:visibility="gone"/>
<Space android:id="@+id/rightSpacer"
android:layout_width="12dip"
android:layout_height="match_parent"
android:visibility="visible" />
</LinearLayout>
</merge>

View File

@@ -0,0 +1,26 @@
<!--
~ Copyright (C) 2019 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.
-->
<com.android.systemui.biometrics.ui.AuthBiometricFaceView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/contents"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<include layout="@layout/auth_biometric_contents"/>
</com.android.systemui.biometrics.ui.AuthBiometricFaceView>

View File

@@ -0,0 +1,43 @@
<!--
~ Copyright (C) 2019 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
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/biometric_dialog_dim_color"/>
<View
android:id="@+id/panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/colorBackgroundFloating"
android:elevation="@dimen/biometric_dialog_elevation"/>
<ScrollView
android:id="@+id/scrollview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:layout_margin="@dimen/biometric_dialog_border_padding"
android:elevation="@dimen/biometric_dialog_elevation"/>
</FrameLayout>

View File

@@ -1010,6 +1010,8 @@
<dimen name="biometric_dialog_corner_size">4dp</dimen>
<dimen name="biometric_dialog_animation_translation_offset">350dp</dimen>
<dimen name="biometric_dialog_border_padding">4dp</dimen>
<dimen name="biometric_dialog_elevation">1dp</dimen>
<dimen name="biometric_dialog_icon_padding">16dp</dimen>
<!-- Wireless Charging Animation values -->
<dimen name="wireless_charging_dots_radius_start">0dp</dimen>

View File

@@ -16,12 +16,16 @@
package com.android.systemui.biometrics;
import android.annotation.IntDef;
import android.hardware.biometrics.BiometricPrompt;
import android.os.Bundle;
import android.view.WindowManager;
import com.android.systemui.biometrics.ui.BiometricDialogView;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Interface for the biometric dialog UI.
*/
@@ -49,12 +53,19 @@ public interface BiometricDialog {
BiometricDialogView.KEY_ERROR_TEXT_COLOR,
};
int SIZE_UNKNOWN = 0;
int SIZE_SMALL = 1;
int SIZE_MEDIUM = 2;
int SIZE_LARGE = 3;
@Retention(RetentionPolicy.SOURCE)
@IntDef({SIZE_UNKNOWN, SIZE_SMALL, SIZE_MEDIUM, SIZE_LARGE})
@interface DialogSize {}
/**
* Show the dialog.
* @param wm
* @param skipIntroAnimation
*/
void show(WindowManager wm, boolean skipIntroAnimation);
void show(WindowManager wm);
/**
* Dismiss the dialog without sending a callback.

View File

@@ -29,6 +29,7 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.provider.Settings;
import android.util.Log;
import android.view.WindowManager;
@@ -36,6 +37,7 @@ import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.os.SomeArgs;
import com.android.systemui.SystemUI;
import com.android.systemui.biometrics.ui.BiometricDialogView;
import com.android.systemui.biometrics.ui.AuthContainerView;
import com.android.systemui.statusbar.CommandQueue;
import java.util.List;
@@ -46,6 +48,9 @@ import java.util.List;
*/
public class BiometricDialogImpl extends SystemUI implements CommandQueue.Callbacks,
DialogViewCallback {
private static final String USE_NEW_DIALOG =
"com.android.systemui.biometrics.BiometricDialogImpl.USE_NEW_DIALOG";
private static final String TAG = "BiometricDialogImpl";
private static final boolean DEBUG = true;
@@ -253,7 +258,8 @@ public class BiometricDialogImpl extends SystemUI implements CommandQueue.Callba
requireConfirmation,
userId,
type,
opPackageName);
opPackageName,
skipAnimation);
if (newDialog == null) {
Log.e(TAG, "Unsupported type: " + type);
@@ -282,7 +288,7 @@ public class BiometricDialogImpl extends SystemUI implements CommandQueue.Callba
mReceiver = (IBiometricServiceReceiverInternal) args.arg2;
mCurrentDialog = newDialog;
mCurrentDialog.show(mWindowManager, skipAnimation);
mCurrentDialog.show(mWindowManager);
}
private void onDialogDismissed(@DismissedReason int reason) {
@@ -309,14 +315,27 @@ public class BiometricDialogImpl extends SystemUI implements CommandQueue.Callba
}
}
protected BiometricDialog buildDialog(Bundle biometricPromptBundle,
boolean requireConfirmation, int userId, int type, String opPackageName) {
return new BiometricDialogView.Builder(mContext)
.setCallback(this)
.setBiometricPromptBundle(biometricPromptBundle)
.setRequireConfirmation(requireConfirmation)
.setUserId(userId)
.setOpPackageName(opPackageName)
.build(type);
protected BiometricDialog buildDialog(Bundle biometricPromptBundle, boolean requireConfirmation,
int userId, int type, String opPackageName, boolean skipIntro) {
if (Settings.Secure.getIntForUser(
mContext.getContentResolver(), USE_NEW_DIALOG, userId, 0) != 0) {
return new AuthContainerView.Builder(mContext)
.setCallback(this)
.setBiometricPromptBundle(biometricPromptBundle)
.setRequireConfirmation(requireConfirmation)
.setUserId(userId)
.setOpPackageName(opPackageName)
.setSkipIntro(skipIntro)
.build(type);
} else {
return new BiometricDialogView.Builder(mContext)
.setCallback(this)
.setBiometricPromptBundle(biometricPromptBundle)
.setRequireConfirmation(requireConfirmation)
.setUserId(userId)
.setOpPackageName(opPackageName)
.setSkipIntro(skipIntro)
.build(type);
}
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (C) 2019 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.ui;
import android.content.Context;
import android.graphics.drawable.Animatable2;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.widget.ImageView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.R;
public class AuthBiometricFaceView extends AuthBiometricView {
// Delay before dismissing after being authenticated/confirmed.
private static final int HIDE_DELAY_MS = 500;
public static class IconController extends Animatable2.AnimationCallback {
Context mContext;
ImageView mIconView;
Handler mHandler;
boolean mLastPulseLightToDark; // false = dark to light, true = light to dark
@State int mState;
IconController(Context context, ImageView iconView) {
mContext = context;
mIconView = iconView;
mHandler = new Handler(Looper.getMainLooper());
showIcon(R.drawable.face_dialog_pulse_dark_to_light);
}
void showIcon(int iconRes) {
final Drawable drawable = mContext.getDrawable(iconRes);
mIconView.setImageDrawable(drawable);
}
void animateIcon(int iconRes, boolean repeat) {
final AnimatedVectorDrawable icon =
(AnimatedVectorDrawable) mContext.getDrawable(iconRes);
mIconView.setImageDrawable(icon);
icon.forceAnimationOnUI();
if (repeat) {
icon.registerAnimationCallback(this);
}
icon.start();
}
void startPulsing() {
mLastPulseLightToDark = false;
animateIcon(R.drawable.face_dialog_pulse_dark_to_light, true);
}
void pulseInNextDirection() {
int iconRes = mLastPulseLightToDark ? R.drawable.face_dialog_pulse_dark_to_light
: R.drawable.face_dialog_pulse_light_to_dark;
animateIcon(iconRes, true /* repeat */);
mLastPulseLightToDark = !mLastPulseLightToDark;
}
@Override
public void onAnimationEnd(Drawable drawable) {
super.onAnimationEnd(drawable);
if (mState == STATE_AUTHENTICATING) {
pulseInNextDirection();
}
}
public void updateState(int newState) {
if (newState == STATE_AUTHENTICATING) {
startPulsing();
}
mState = newState;
}
}
@VisibleForTesting IconController mIconController;
public AuthBiometricFaceView(Context context) {
this(context, null);
}
public AuthBiometricFaceView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected int getDelayAfterAuthenticatedDurationMs() {
return HIDE_DELAY_MS;
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mIconController = new IconController(mContext, mIconView);
}
@Override
public void updateState(@State int newState) {
super.updateState(newState);
mIconController.updateState(newState);
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright (C) 2019 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.ui;
import android.annotation.IntDef;
import android.content.Context;
import android.hardware.biometrics.BiometricPrompt;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.android.systemui.R;
import com.android.systemui.biometrics.BiometricDialog;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Contains the Biometric views (title, subtitle, icon, buttons, etc) and its controllers.
*/
public abstract class AuthBiometricView extends LinearLayout {
private static final String TAG = "BiometricPrompt/AuthBiometricView";
/**
* Authentication hardware idle.
*/
protected static final int STATE_IDLE = 0;
/**
* UI animating in, authentication hardware active.
*/
protected static final int STATE_AUTHENTICATING_ANIMATING_IN = 1;
/**
* UI animated in, authentication hardware active.
*/
protected static final int STATE_AUTHENTICATING = 2;
/**
* Hard error, e.g. ERROR_TIMEOUT. Authentication hardware idle.
*/
protected static final int STATE_ERROR = 3;
/**
* Authenticated, waiting for user confirmation. Authentication hardware idle.
*/
protected static final int STATE_PENDING_CONFIRMATION = 4;
/**
* Authenticated, dialog animating away soon.
*/
protected static final int STATE_AUTHENTICATED = 5;
@Retention(RetentionPolicy.SOURCE)
@IntDef({STATE_IDLE, STATE_AUTHENTICATING_ANIMATING_IN, STATE_AUTHENTICATING, STATE_ERROR,
STATE_PENDING_CONFIRMATION, STATE_AUTHENTICATED})
@interface State {}
/**
* Callback to the parent when a user action has occurred.
*/
interface Callback {
int ACTION_AUTHENTICATED = 1;
/**
* When an action has occurred. The caller will only invoke this when the callback should
* be propagated. e.g. the caller will handle any necessary delay.
* @param action
*/
void onAction(int action);
}
private final Handler mHandler;
private AuthPanelController mPanelController;
private Bundle mBundle;
private boolean mRequireConfirmation;
private @BiometricDialog.DialogSize int mSize = BiometricDialog.SIZE_UNKNOWN;
private TextView mTitleView;
private TextView mSubtitleView;
private TextView mDescriptionView;
protected ImageView mIconView;
private TextView mErrorView;
private Button mNegativeButton;
private Button mPositiveButton;
private Button mTryAgainButton;
private int mCurrentHeight;
private int mCurrentWidth;
private Callback mCallback;
protected @State int mState;
protected abstract int getDelayAfterAuthenticatedDurationMs();
public AuthBiometricView(Context context) {
this(context, null);
}
public AuthBiometricView(Context context, AttributeSet attrs) {
super(context, attrs);
mHandler = new Handler(Looper.getMainLooper());
addOnLayoutChangeListener(new OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
updateSize(mRequireConfirmation ? BiometricDialog.SIZE_MEDIUM
: BiometricDialog.SIZE_SMALL);
mPanelController.updateForContentDimensions(mCurrentWidth, mCurrentHeight);
}
});
}
public void setPanelController(AuthPanelController panelController) {
mPanelController = panelController;
}
public void setBiometricPromptBundle(Bundle bundle) {
mBundle = bundle;
}
public void setCallback(Callback callback) {
mCallback = callback;
}
public void setRequireConfirmation(boolean requireConfirmation) {
mRequireConfirmation = requireConfirmation;
}
public void updateSize(@BiometricDialog.DialogSize int newSize) {
if (mSize == newSize) {
Log.w(TAG, "Skipping updating size: " + mSize);
return;
}
if (newSize == BiometricDialog.SIZE_SMALL) {
mTitleView.setVisibility(View.GONE);
mSubtitleView.setVisibility(View.GONE);
mDescriptionView.setVisibility(View.GONE);
mErrorView.setVisibility(View.GONE);
mNegativeButton.setVisibility(View.GONE);
final float iconPadding = getResources()
.getDimension(R.dimen.biometric_dialog_icon_padding);
mIconView.setY(getHeight() - mIconView.getHeight() - iconPadding);
mCurrentHeight = mIconView.getHeight() + 2 * (int) iconPadding;
}
mSize = newSize;
}
public void updateState(@State int newState) {
Log.v(TAG, "newState: " + newState);
if (newState == STATE_AUTHENTICATED) {
if (mRequireConfirmation) {
} else {
mHandler.postDelayed(() -> {
mCallback.onAction(Callback.ACTION_AUTHENTICATED);
}, getDelayAfterAuthenticatedDurationMs());
}
}
mState = newState;
}
public void onDialogAnimatedIn() {
updateState(STATE_AUTHENTICATING);
}
public void onAuthenticationSucceeded() {
if (mRequireConfirmation) {
updateState(STATE_PENDING_CONFIRMATION);
} else {
updateState(STATE_AUTHENTICATED);
}
}
private void setTextOrHide(TextView view, String string) {
if (TextUtils.isEmpty(string)) {
view.setVisibility(View.GONE);
} else {
view.setText(string);
}
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mTitleView = findViewById(R.id.title);
mSubtitleView = findViewById(R.id.subtitle);
mDescriptionView = findViewById(R.id.description);
mIconView = findViewById(R.id.biometric_icon);
mErrorView = findViewById(R.id.error);
mNegativeButton = findViewById(R.id.button_negative);
mPositiveButton = findViewById(R.id.button_positive);
mTryAgainButton = findViewById(R.id.button_try_again);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
setTextOrHide(mTitleView, mBundle.getString(BiometricPrompt.KEY_TITLE));
setTextOrHide(mSubtitleView, mBundle.getString(BiometricPrompt.KEY_SUBTITLE));
setTextOrHide(mDescriptionView, mBundle.getString(BiometricPrompt.KEY_DESCRIPTION));
updateState(STATE_AUTHENTICATING_ANIMATING_IN);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int width = MeasureSpec.getSize(widthMeasureSpec);
final int height = MeasureSpec.getSize(heightMeasureSpec);
final int newWidth = Math.min(width, height);
int totalHeight = 0;
final int numChildren = getChildCount();
for (int i = 0; i < numChildren; i++) {
final View child = getChildAt(i);
if (child.getId() == R.id.biometric_icon) {
child.measure(
MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST));
} else {
child.measure(
MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST));
}
totalHeight += child.getMeasuredHeight();
}
// Use the new width so it's centered horizontally
setMeasuredDimension(newWidth, totalHeight);
mCurrentHeight = getMeasuredHeight();
mCurrentWidth = getMeasuredWidth();
}
}

View File

@@ -0,0 +1,363 @@
/*
* Copyright (C) 2019 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.ui;
import android.content.Context;
import android.graphics.PixelFormat;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Interpolator;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.Dependency;
import com.android.systemui.Interpolators;
import com.android.systemui.R;
import com.android.systemui.biometrics.BiometricDialog;
import com.android.systemui.biometrics.DialogViewCallback;
import com.android.systemui.keyguard.WakefulnessLifecycle;
/**
* Top level container/controller for the BiometricPrompt UI.
*/
public class AuthContainerView extends LinearLayout
implements BiometricDialog, WakefulnessLifecycle.Observer {
private static final String TAG = "BiometricPrompt/AuthContainerView";
private static final int ANIMATION_DURATION_SHOW_MS = 250;
private static final int ANIMATION_DURATION_AWAY_MS = 350; // ms
private final Config mConfig;
private final Handler mHandler = new Handler();
private final IBinder mWindowToken = new Binder();
private final WindowManager mWindowManager;
private final AuthPanelController mPanelController;
private final Interpolator mLinearOutSlowIn;
private final ViewGroup mContainerView;
private final AuthBiometricView mBiometricView;
private final ImageView mBackgroundView;
private final ScrollView mScrollView;
private final View mPanelView;
private final float mTranslationY;
@VisibleForTesting final WakefulnessLifecycle mWakefulnessLifecycle;
private boolean mCompletedAnimatingIn;
private boolean mPendingDismissDialog;
private static class Config {
Context mContext;
DialogViewCallback mCallback;
Bundle mBiometricPromptBundle;
boolean mRequireConfirmation;
int mUserId;
String mOpPackageName;
int mModalityMask;
boolean mSkipIntro;
}
public static class Builder {
Config mConfig;
public Builder(Context context) {
mConfig = new Config();
mConfig.mContext = context;
}
public Builder setCallback(DialogViewCallback callback) {
mConfig.mCallback = callback;
return this;
}
public Builder setBiometricPromptBundle(Bundle bundle) {
mConfig.mBiometricPromptBundle = bundle;
return this;
}
public Builder setRequireConfirmation(boolean requireConfirmation) {
mConfig.mRequireConfirmation = requireConfirmation;
return this;
}
public Builder setUserId(int userId) {
mConfig.mUserId = userId;
return this;
}
public Builder setOpPackageName(String opPackageName) {
mConfig.mOpPackageName = opPackageName;
return this;
}
public Builder setSkipIntro(boolean skip) {
mConfig.mSkipIntro = skip;
return this;
}
public AuthContainerView build(int modalityMask) { // TODO
return new AuthContainerView(mConfig);
}
}
private final AuthBiometricView.Callback mBiometricCallback = action -> {
switch (action) {
case AuthBiometricView.Callback.ACTION_AUTHENTICATED:
animateAway(DialogViewCallback.DISMISSED_AUTHENTICATED);
break;
default:
Log.e(TAG, "Unhandled action: " + action);
}
};
private AuthContainerView(Config config) {
super(config.mContext);
mConfig = config;
mWindowManager = mContext.getSystemService(WindowManager.class);
mWakefulnessLifecycle = Dependency.get(WakefulnessLifecycle.class);
mTranslationY = getResources()
.getDimension(R.dimen.biometric_dialog_animation_translation_offset);
mLinearOutSlowIn = Interpolators.LINEAR_OUT_SLOW_IN;
final LayoutInflater factory = LayoutInflater.from(mContext);
mContainerView = (ViewGroup) factory.inflate(
R.layout.auth_container_view, this, false /* attachToRoot */);
// TODO: Depends on modality
mBiometricView = (AuthBiometricFaceView)
factory.inflate(R.layout.auth_biometric_face_view, null, false);
mBackgroundView = mContainerView.findViewById(R.id.background);
mPanelView = mContainerView.findViewById(R.id.panel);
mPanelController = new AuthPanelController(mContext, mPanelView);
mBiometricView.setRequireConfirmation(mConfig.mRequireConfirmation);
mBiometricView.setPanelController(mPanelController);
mBiometricView.setBiometricPromptBundle(config.mBiometricPromptBundle);
mBiometricView.setCallback(mBiometricCallback);
mScrollView = mContainerView.findViewById(R.id.scrollview);
mScrollView.addView(mBiometricView);
addView(mContainerView);
setOnKeyListener((v, keyCode, event) -> {
if (keyCode != KeyEvent.KEYCODE_BACK) {
return false;
}
if (event.getAction() == KeyEvent.ACTION_UP) {
animateAway(DialogViewCallback.DISMISSED_USER_CANCELED);
}
return true;
});
setFocusableInTouchMode(true);
requestFocus();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mPanelController.setContainerDimensions(getMeasuredWidth(), getMeasuredHeight());
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
mWakefulnessLifecycle.addObserver(this);
if (mConfig.mSkipIntro) {
mCompletedAnimatingIn = true;
} else {
// The background panel and content are different views since we need to be able to
// animate them separately in other places.
mPanelView.setY(mTranslationY);
mScrollView.setY(mTranslationY);
setAlpha(0f);
postOnAnimation(() -> {
mPanelView.animate()
.translationY(0)
.setDuration(ANIMATION_DURATION_SHOW_MS)
.setInterpolator(mLinearOutSlowIn)
.withLayer()
.withEndAction(this::onDialogAnimatedIn)
.start();
mScrollView.animate()
.translationY(0)
.setDuration(ANIMATION_DURATION_SHOW_MS)
.setInterpolator(mLinearOutSlowIn)
.withLayer()
.start();
animate()
.alpha(1f)
.setDuration(ANIMATION_DURATION_SHOW_MS)
.setInterpolator(mLinearOutSlowIn)
.withLayer()
.start();
});
}
}
@Override
public void onDetachedFromWindow() {
super.onDetachedFromWindow();
mWakefulnessLifecycle.removeObserver(this);
}
@Override
public void onStartedGoingToSleep() {
animateAway(DialogViewCallback.DISMISSED_USER_CANCELED);
}
@Override
public void show(WindowManager wm) {
wm.addView(this, getLayoutParams(mWindowToken));
}
@Override
public void dismissWithoutCallback(boolean animate) {
if (animate) {
animateAway(false /* sendReason */, 0 /* reason */);
} else {
mWindowManager.removeView(this);
}
}
@Override
public void dismissFromSystemServer() {
mWindowManager.removeView(this);
}
@Override
public void onAuthenticationSucceeded() {
mBiometricView.onAuthenticationSucceeded();
}
@Override
public void onAuthenticationFailed(String failureReason) {
}
@Override
public void onHelp(String help) {
}
@Override
public void onError(String error) {
}
@Override
public void onSaveState(Bundle outState) {
}
@Override
public void restoreState(Bundle savedState) {
}
@Override
public String getOpPackageName() {
return mConfig.mOpPackageName;
}
private void animateAway(int reason) {
animateAway(true /* sendReason */, reason);
}
private void animateAway(boolean sendReason, @DialogViewCallback.DismissedReason int reason) {
if (!mCompletedAnimatingIn) {
Log.w(TAG, "startDismiss(): waiting for onDialogAnimatedIn");
mPendingDismissDialog = true;
return;
}
final Runnable endActionRunnable = () -> {
setVisibility(View.INVISIBLE);
mWindowManager.removeView(this);
if (sendReason) {
mConfig.mCallback.onDismissed(reason);
}
};
postOnAnimation(() -> {
mPanelView.animate()
.translationY(mTranslationY)
.setDuration(ANIMATION_DURATION_AWAY_MS)
.setInterpolator(mLinearOutSlowIn)
.withLayer()
.withEndAction(endActionRunnable)
.start();
mScrollView.animate()
.translationY(mTranslationY)
.setDuration(ANIMATION_DURATION_AWAY_MS)
.setInterpolator(mLinearOutSlowIn)
.withLayer()
.start();
animate()
.alpha(0f)
.setDuration(ANIMATION_DURATION_AWAY_MS)
.setInterpolator(mLinearOutSlowIn)
.withLayer()
.start();
});
}
private void onDialogAnimatedIn() {
mCompletedAnimatingIn = true;
if (mPendingDismissDialog) {
Log.d(TAG, "onDialogAnimatedIn(): mPendingDismissDialog=true, dismissing now");
animateAway(false /* sendReason */, 0);
mPendingDismissDialog = false;
return;
}
mBiometricView.onDialogAnimatedIn();
}
/**
* @param windowToken token for the window
* @return
*/
public static WindowManager.LayoutParams getLayoutParams(IBinder windowToken) {
final WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_STATUS_BAR_PANEL,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
PixelFormat.TRANSLUCENT);
lp.privateFlags |= WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS;
lp.setTitle("BiometricPrompt");
lp.token = windowToken;
return lp;
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright (C) 2019 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.ui;
import android.content.Context;
import android.graphics.Outline;
import android.util.Log;
import android.view.View;
import android.view.ViewOutlineProvider;
import com.android.systemui.R;
/**
* Controls the back panel and its animations for the BiometricPrompt UI.
*/
public class AuthPanelController extends ViewOutlineProvider {
private static final String TAG = "BiometricPrompt/AuthPanelController";
private static final boolean DEBUG = false;
private final Context mContext;
private final View mPanelView;
private final float mCornerRadius;
private final int mBiometricMargin;
private int mContainerWidth;
private int mContainerHeight;
private int mContentWidth;
private int mContentHeight;
@Override
public void getOutline(View view, Outline outline) {
final int left = (mContainerWidth - mContentWidth) / 2;
final int right = mContainerWidth - left;
final int top = mContentHeight < mContainerHeight
? mContainerHeight - mContentHeight - mBiometricMargin
: mBiometricMargin;
final int bottom = mContainerHeight - mBiometricMargin;
outline.setRoundRect(left, top, right, bottom, mCornerRadius);
}
public void setContainerDimensions(int containerWidth, int containerHeight) {
if (DEBUG) {
Log.v(TAG, "Container Width: " + containerWidth + " Height: " + containerHeight);
}
mContainerWidth = containerWidth;
mContainerHeight = containerHeight;
}
public void updateForContentDimensions(int contentWidth, int contentHeight) {
if (DEBUG) {
Log.v(TAG, "Content Width: " + contentWidth + " Height: " + contentHeight);
}
mContentWidth = contentWidth;
mContentHeight = contentHeight;
if (mContainerWidth == 0 || mContainerHeight == 0) {
Log.w(TAG, "Not done measuring yet");
return;
}
mPanelView.invalidateOutline();
}
AuthPanelController(Context context, View panelView) {
mContext = context;
mPanelView = panelView;
mCornerRadius = context.getResources()
.getDimension(R.dimen.biometric_dialog_corner_size);
mBiometricMargin = (int) context.getResources()
.getDimension(R.dimen.biometric_dialog_border_padding);
mPanelView.setOutlineProvider(this);
mPanelView.setClipToOutline(true);
}
}

View File

@@ -231,6 +231,7 @@ public abstract class BiometricDialogView extends LinearLayout implements Biomet
private boolean mRequireConfirmation;
private int mUserId;
private String mOpPackageName;
private boolean mSkipIntro;
public Builder(Context context) {
mContext = context;
@@ -261,6 +262,11 @@ public abstract class BiometricDialogView extends LinearLayout implements Biomet
return this;
}
public Builder setSkipIntro(boolean skipIntro) {
mSkipIntro = skipIntro;
return this;
}
public BiometricDialogView build(int type) {
return build(type, new Injector());
}
@@ -278,6 +284,7 @@ public abstract class BiometricDialogView extends LinearLayout implements Biomet
dialog.setRequireConfirmation(mRequireConfirmation);
dialog.setUserId(mUserId);
dialog.setOpPackageName(mOpPackageName);
dialog.setSkipIntro(mSkipIntro);
return dialog;
}
}
@@ -508,6 +515,7 @@ public abstract class BiometricDialogView extends LinearLayout implements Biomet
mWakefulnessLifecycle.removeObserver(mWakefulnessObserver);
}
@VisibleForTesting
void updateSize(@DialogSize int newSize) {
final float padding = Utils.dpToPixels(mContext, IMPLICIT_Y_PADDING);
@@ -733,8 +741,7 @@ public abstract class BiometricDialogView extends LinearLayout implements Biomet
}
@Override
public void show(WindowManager wm, boolean skipIntroAnimation) {
setSkipIntro(skipIntroAnimation);
public void show(WindowManager wm) {
wm.addView(this, getLayoutParams(mWindowToken));
}

View File

@@ -147,7 +147,7 @@ public class BiometricDialogImplTest extends SysuiTestCase {
public void testShowInvoked_whenSystemRequested()
throws Exception {
showDialog(BiometricPrompt.TYPE_FACE);
verify(mDialog1).show(any(), eq(false) /* skipIntro */);
verify(mDialog1).show(any());
}
@Test
@@ -215,7 +215,7 @@ public class BiometricDialogImplTest extends SysuiTestCase {
@Test
public void testShowNewDialog_beforeOldDialogDismissed_SkipsAnimations() throws Exception {
showDialog(BiometricPrompt.TYPE_FACE);
verify(mDialog1).show(any(), eq(false) /* skipIntro */);
verify(mDialog1).show(any());
showDialog(BiometricPrompt.TYPE_FACE);
@@ -223,13 +223,13 @@ public class BiometricDialogImplTest extends SysuiTestCase {
verify(mDialog1).dismissWithoutCallback(eq(false) /* animate */);
// Second dialog should be shown without animation
verify(mDialog2).show(any(), eq(true)) /* skipIntro */;
verify(mDialog2).show(any());
}
@Test
public void testConfigurationPersists_whenOnConfigurationChanged() throws Exception {
showDialog(BiometricPrompt.TYPE_FACE);
verify(mDialog1).show(any(), eq(false) /* skipIntro */);
verify(mDialog1).show(any());
mBiometricDialogImpl.onConfigurationChanged(new Configuration());
@@ -244,7 +244,7 @@ public class BiometricDialogImplTest extends SysuiTestCase {
verify(mDialog2).restoreState(captor2.capture());
// Dialog for new configuration skips intro
verify(mDialog2).show(any(), eq(true) /* skipIntro */);
verify(mDialog2).show(any());
// TODO: This should check all values we want to save/restore
assertEquals(captor.getValue(), captor2.getValue());
@@ -305,7 +305,8 @@ public class BiometricDialogImplTest extends SysuiTestCase {
@Override
protected BiometricDialog buildDialog(Bundle biometricPromptBundle,
boolean requireConfirmation, int userId, int type, String opPackageName) {
boolean requireConfirmation, int userId, int type, String opPackageName,
boolean skipIntro) {
BiometricDialog dialog;
if (mBuildCount == 0) {
dialog = mDialog1;

View File

@@ -0,0 +1,97 @@
/*
* Copyright (C) 2019 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.ui;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import android.content.Context;
import android.test.suitebuilder.annotation.SmallTest;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper.RunWithLooper;
import android.widget.ImageView;
import com.android.systemui.SysuiTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import com.android.systemui.R;
@RunWith(AndroidTestingRunner.class)
@RunWithLooper
@SmallTest
public class AuthBiometricFaceViewTest extends SysuiTestCase {
@Mock
AuthBiometricView.Callback mCallback;
private TestableFaceView mFaceView;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
mFaceView = new TestableFaceView(mContext);
mFaceView.mIconController = mock(TestableFaceView.TestableIconController.class);
mFaceView.setCallback(mCallback);
}
@Test
public void testStateUpdated_whenDialogAnimatedIn() {
mFaceView.onDialogAnimatedIn();
verify(mFaceView.mIconController)
.updateState(eq(AuthBiometricFaceView.STATE_AUTHENTICATING));
}
@Test
public void testIconUpdatesState_whenDialogStateUpdated() {
mFaceView.updateState(AuthBiometricFaceView.STATE_AUTHENTICATING);
verify(mFaceView.mIconController)
.updateState(eq(AuthBiometricFaceView.STATE_AUTHENTICATING));
mFaceView.updateState(AuthBiometricFaceView.STATE_AUTHENTICATED);
verify(mFaceView.mIconController)
.updateState(eq(AuthBiometricFaceView.STATE_AUTHENTICATED));
}
public class TestableFaceView extends AuthBiometricFaceView {
public class TestableIconController extends IconController {
TestableIconController(Context context, ImageView iconView) {
super(context, iconView);
}
public void startPulsing() {
// Stub for testing
}
}
@Override
protected int getDelayAfterAuthenticatedDurationMs() {
return 0; // Keep this at 0 for tests to invoke callback immediately.
}
public TestableFaceView(Context context) {
super(context);
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (C) 2019 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.ui;
import static org.mockito.Mockito.verify;
import android.content.Context;
import android.test.suitebuilder.annotation.SmallTest;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper.RunWithLooper;
import com.android.systemui.SysuiTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@RunWith(AndroidTestingRunner.class)
@RunWithLooper
@SmallTest
public class AuthBiometricViewTest extends SysuiTestCase {
@Mock
AuthBiometricView.Callback mCallback;
TestableBiometricView mBiometricView;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
mBiometricView = new TestableBiometricView(mContext);
mBiometricView.setCallback(mCallback);
}
@Test
public void testOnAuthenticationSucceeded_noConfirmationRequired() {
// The onAuthenticated runnable is posted when authentication succeeds.
mBiometricView.onAuthenticationSucceeded();
waitForIdleSync();
verify(mCallback).onAction(AuthBiometricView.Callback.ACTION_AUTHENTICATED);
}
@Test
public void testOnAuthenticationSucceeded_confirmationRequired() {
mBiometricView.setRequireConfirmation(true);
// TODO: Update when code path is complete
}
public class TestableBiometricView extends AuthBiometricView {
public TestableBiometricView(Context context) {
super(context);
}
@Override
protected int getDelayAfterAuthenticatedDurationMs() {
return 0; // Keep this at 0 for tests to invoke callback immediately.
}
}
}