Refactor biometric prompt credential screens - ui layer.

Step 3/3, replace existing credential views & break down code into seperate view, view model, & view binder sub-layers.

Prep for scuba.

Bug: 251476085
Test: atest AuthContainerViewTest AuthControllerTest CredentialViewModelTest
Test: manual (use test app and verify credential screens)
Change-Id: Ibcf7f30ee3f32dc736a71ae87513b9601862d20a
This commit is contained in:
Joe Bolinger
2022-10-17 17:57:41 +00:00
parent 7dd4037f4d
commit 700d9bb7bc
22 changed files with 997 additions and 951 deletions

View File

@@ -14,7 +14,7 @@
~ limitations under the License.
-->
<com.android.systemui.biometrics.AuthCredentialPasswordView
<com.android.systemui.biometrics.ui.CredentialPasswordView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@@ -86,4 +86,4 @@
</LinearLayout>
</com.android.systemui.biometrics.AuthCredentialPasswordView>
</com.android.systemui.biometrics.ui.CredentialPasswordView>

View File

@@ -14,7 +14,7 @@
~ limitations under the License.
-->
<com.android.systemui.biometrics.AuthCredentialPatternView
<com.android.systemui.biometrics.ui.CredentialPatternView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@@ -83,4 +83,4 @@
</FrameLayout>
</com.android.systemui.biometrics.AuthCredentialPatternView>
</com.android.systemui.biometrics.ui.CredentialPatternView>

View File

@@ -14,7 +14,7 @@
~ limitations under the License.
-->
<com.android.systemui.biometrics.AuthCredentialPasswordView
<com.android.systemui.biometrics.ui.CredentialPasswordView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@@ -83,4 +83,4 @@
</LinearLayout>
</com.android.systemui.biometrics.AuthCredentialPasswordView>
</com.android.systemui.biometrics.ui.CredentialPasswordView>

View File

@@ -14,7 +14,7 @@
~ limitations under the License.
-->
<com.android.systemui.biometrics.AuthCredentialPatternView
<com.android.systemui.biometrics.ui.CredentialPatternView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@@ -78,4 +78,4 @@
android:layout_gravity="center_horizontal|bottom"/>
</FrameLayout>
</com.android.systemui.biometrics.AuthCredentialPatternView>
</com.android.systemui.biometrics.ui.CredentialPatternView>

View File

@@ -26,6 +26,7 @@ import android.annotation.DurationMillisLong;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.AlertDialog;
import android.content.Context;
import android.graphics.PixelFormat;
import android.hardware.biometrics.BiometricAuthenticator.Modality;
@@ -63,6 +64,9 @@ import com.android.internal.widget.LockPatternUtils;
import com.android.systemui.R;
import com.android.systemui.animation.Interpolators;
import com.android.systemui.biometrics.AuthController.ScaleFactorProvider;
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor;
import com.android.systemui.biometrics.ui.CredentialView;
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.keyguard.WakefulnessLifecycle;
import com.android.systemui.util.concurrency.DelayableExecutor;
@@ -74,11 +78,13 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Provider;
/**
* Top level container/controller for the BiometricPrompt UI.
*/
public class AuthContainerView extends LinearLayout
implements AuthDialog, WakefulnessLifecycle.Observer {
implements AuthDialog, WakefulnessLifecycle.Observer, CredentialView.Host {
private static final String TAG = "AuthContainerView";
@@ -112,15 +118,18 @@ public class AuthContainerView extends LinearLayout
private final IBinder mWindowToken = new Binder();
private final WindowManager mWindowManager;
private final Interpolator mLinearOutSlowIn;
private final CredentialCallback mCredentialCallback;
private final LockPatternUtils mLockPatternUtils;
private final WakefulnessLifecycle mWakefulnessLifecycle;
private final InteractionJankMonitor mInteractionJankMonitor;
// TODO: these should be migrated out once ready
private final Provider<BiometricPromptCredentialInteractor> mBiometricPromptInteractor;
private final Provider<CredentialViewModel> mCredentialViewModelProvider;
@VisibleForTesting final BiometricCallback mBiometricCallback;
@Nullable private AuthBiometricView mBiometricView;
@Nullable private AuthCredentialView mCredentialView;
@Nullable private View mCredentialView;
private final AuthPanelController mPanelController;
private final FrameLayout mFrameLayout;
private final ImageView mBackgroundView;
@@ -229,11 +238,13 @@ public class AuthContainerView extends LinearLayout
@NonNull WakefulnessLifecycle wakefulnessLifecycle,
@NonNull UserManager userManager,
@NonNull LockPatternUtils lockPatternUtils,
@NonNull InteractionJankMonitor jankMonitor) {
@NonNull InteractionJankMonitor jankMonitor,
@NonNull Provider<BiometricPromptCredentialInteractor> biometricPromptInteractor,
@NonNull Provider<CredentialViewModel> credentialViewModelProvider) {
mConfig.mSensorIds = sensorIds;
return new AuthContainerView(mConfig, fpProps, faceProps, wakefulnessLifecycle,
userManager, lockPatternUtils, jankMonitor, new Handler(Looper.getMainLooper()),
bgExecutor);
userManager, lockPatternUtils, jankMonitor, biometricPromptInteractor,
credentialViewModelProvider, new Handler(Looper.getMainLooper()), bgExecutor);
}
}
@@ -271,14 +282,51 @@ public class AuthContainerView extends LinearLayout
}
}
final class CredentialCallback implements AuthCredentialView.Callback {
@Override
public void onCredentialMatched(byte[] attestation) {
mCredentialAttestation = attestation;
animateAway(AuthDialogCallback.DISMISSED_CREDENTIAL_AUTHENTICATED);
@Override
public void onCredentialMatched(@NonNull byte[] attestation) {
mCredentialAttestation = attestation;
animateAway(AuthDialogCallback.DISMISSED_CREDENTIAL_AUTHENTICATED);
}
@Override
public void onCredentialAborted() {
sendEarlyUserCanceled();
animateAway(AuthDialogCallback.DISMISSED_USER_CANCELED);
}
@Override
public void onCredentialAttemptsRemaining(int remaining, @NonNull String messageBody) {
// Only show dialog if <=1 attempts are left before wiping.
if (remaining == 1) {
showLastAttemptBeforeWipeDialog(messageBody);
} else if (remaining <= 0) {
showNowWipingDialog(messageBody);
}
}
private void showLastAttemptBeforeWipeDialog(@NonNull String messageBody) {
final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
.setTitle(R.string.biometric_dialog_last_attempt_before_wipe_dialog_title)
.setMessage(messageBody)
.setPositiveButton(android.R.string.ok, null)
.create();
alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL);
alertDialog.show();
}
private void showNowWipingDialog(@NonNull String messageBody) {
final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
.setMessage(messageBody)
.setPositiveButton(
com.android.settingslib.R.string.failed_attempts_now_wiping_dialog_dismiss,
null /* OnClickListener */)
.setOnDismissListener(
dialog -> animateAway(AuthDialogCallback.DISMISSED_ERROR))
.create();
alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL);
alertDialog.show();
}
@VisibleForTesting
AuthContainerView(Config config,
@Nullable List<FingerprintSensorPropertiesInternal> fpProps,
@@ -287,6 +335,8 @@ public class AuthContainerView extends LinearLayout
@NonNull UserManager userManager,
@NonNull LockPatternUtils lockPatternUtils,
@NonNull InteractionJankMonitor jankMonitor,
@NonNull Provider<BiometricPromptCredentialInteractor> biometricPromptInteractor,
@NonNull Provider<CredentialViewModel> credentialViewModelProvider,
@NonNull Handler mainHandler,
@NonNull @Background DelayableExecutor bgExecutor) {
super(config.mContext);
@@ -302,7 +352,6 @@ public class AuthContainerView extends LinearLayout
.getDimension(R.dimen.biometric_dialog_animation_translation_offset);
mLinearOutSlowIn = Interpolators.LINEAR_OUT_SLOW_IN;
mBiometricCallback = new BiometricCallback();
mCredentialCallback = new CredentialCallback();
final LayoutInflater layoutInflater = LayoutInflater.from(mContext);
mFrameLayout = (FrameLayout) layoutInflater.inflate(
@@ -314,6 +363,8 @@ public class AuthContainerView extends LinearLayout
mPanelController = new AuthPanelController(mContext, mPanelView);
mBackgroundExecutor = bgExecutor;
mInteractionJankMonitor = jankMonitor;
mBiometricPromptInteractor = biometricPromptInteractor;
mCredentialViewModelProvider = credentialViewModelProvider;
// Inflate biometric view only if necessary.
if (Utils.isBiometricAllowed(mConfig.mPromptInfo)) {
@@ -404,12 +455,12 @@ public class AuthContainerView extends LinearLayout
switch (credentialType) {
case Utils.CREDENTIAL_PATTERN:
mCredentialView = (AuthCredentialView) factory.inflate(
mCredentialView = factory.inflate(
R.layout.auth_credential_pattern_view, null, false);
break;
case Utils.CREDENTIAL_PIN:
case Utils.CREDENTIAL_PASSWORD:
mCredentialView = (AuthCredentialView) factory.inflate(
mCredentialView = factory.inflate(
R.layout.auth_credential_password_view, null, false);
break;
default:
@@ -422,16 +473,12 @@ public class AuthContainerView extends LinearLayout
mBackgroundView.setOnClickListener(null);
mBackgroundView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
mCredentialView.setContainerView(this);
mCredentialView.setUserId(mConfig.mUserId);
mCredentialView.setOperationId(mConfig.mOperationId);
mCredentialView.setEffectiveUserId(mEffectiveUserId);
mCredentialView.setCredentialType(credentialType);
mCredentialView.setCallback(mCredentialCallback);
mCredentialView.setPromptInfo(mConfig.mPromptInfo);
mCredentialView.setPanelController(mPanelController, animatePanel);
mCredentialView.setShouldAnimateContents(animateContents);
mCredentialView.setBackgroundExecutor(mBackgroundExecutor);
mBiometricPromptInteractor.get().useCredentialsForAuthentication(
mConfig.mPromptInfo, credentialType, mConfig.mUserId, mConfig.mOperationId);
final CredentialViewModel vm = mCredentialViewModelProvider.get();
vm.setAnimateContents(animateContents);
((CredentialView) mCredentialView).init(vm, this, mPanelController, animatePanel);
mFrameLayout.addView(mCredentialView);
}

View File

@@ -72,6 +72,8 @@ import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.os.SomeArgs;
import com.android.internal.widget.LockPatternUtils;
import com.android.systemui.CoreStartable;
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor;
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
@@ -122,6 +124,10 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
private final Provider<UdfpsController> mUdfpsControllerFactory;
private final Provider<SidefpsController> mSidefpsControllerFactory;
// TODO: these should be migrated out once ready
@NonNull private final Provider<BiometricPromptCredentialInteractor> mBiometricPromptInteractor;
@NonNull private final Provider<CredentialViewModel> mCredentialViewModelProvider;
private final Display mDisplay;
private float mScaleFactor = 1f;
// sensor locations without any resolution scaling nor rotation adjustments:
@@ -683,6 +689,8 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
@NonNull LockPatternUtils lockPatternUtils,
@NonNull UdfpsLogger udfpsLogger,
@NonNull StatusBarStateController statusBarStateController,
@NonNull Provider<BiometricPromptCredentialInteractor> biometricPromptInteractor,
@NonNull Provider<CredentialViewModel> credentialViewModelProvider,
@NonNull InteractionJankMonitor jankMonitor,
@Main Handler handler,
@Background DelayableExecutor bgExecutor,
@@ -706,6 +714,9 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
mUdfpsEnrolledForUser = new SparseBooleanArray();
mVibratorHelper = vibrator;
mBiometricPromptInteractor = biometricPromptInteractor;
mCredentialViewModelProvider = credentialViewModelProvider;
mOrientationListener = new BiometricDisplayListener(
context,
mDisplayManager,
@@ -1204,7 +1215,8 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
.setMultiSensorConfig(multiSensorConfig)
.setScaleFactorProvider(() -> getScaleFactor())
.build(bgExecutor, sensorIds, mFpProps, mFaceProps, wakefulnessLifecycle,
userManager, lockPatternUtils, mInteractionJankMonitor);
userManager, lockPatternUtils, mInteractionJankMonitor,
mBiometricPromptInteractor, mCredentialViewModelProvider);
}
@Override

View File

@@ -1,238 +0,0 @@
/*
* 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;
import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
import static android.view.WindowInsets.Type.ime;
import android.annotation.NonNull;
import android.content.Context;
import android.graphics.Insets;
import android.os.UserHandle;
import android.text.InputType;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.view.View;
import android.view.View.OnApplyWindowInsetsListener;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.ImeAwareEditText;
import android.widget.TextView;
import com.android.internal.widget.LockPatternChecker;
import com.android.internal.widget.LockPatternUtils;
import com.android.internal.widget.LockscreenCredential;
import com.android.internal.widget.VerifyCredentialResponse;
import com.android.systemui.Dumpable;
import com.android.systemui.R;
import java.io.PrintWriter;
/**
* Pin and Password UI
*/
public class AuthCredentialPasswordView extends AuthCredentialView
implements TextView.OnEditorActionListener, OnApplyWindowInsetsListener, Dumpable {
private static final String TAG = "BiometricPrompt/AuthCredentialPasswordView";
private final InputMethodManager mImm;
private ImeAwareEditText mPasswordField;
private ViewGroup mAuthCredentialHeader;
private ViewGroup mAuthCredentialInput;
private int mBottomInset = 0;
public AuthCredentialPasswordView(Context context,
AttributeSet attrs) {
super(context, attrs);
mImm = mContext.getSystemService(InputMethodManager.class);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mAuthCredentialHeader = findViewById(R.id.auth_credential_header);
mAuthCredentialInput = findViewById(R.id.auth_credential_input);
mPasswordField = findViewById(R.id.lockPassword);
mPasswordField.setOnEditorActionListener(this);
// TODO: De-dupe the logic with AuthContainerView
mPasswordField.setOnKeyListener((v, keyCode, event) -> {
if (keyCode != KeyEvent.KEYCODE_BACK) {
return false;
}
if (event.getAction() == KeyEvent.ACTION_UP) {
mContainerView.sendEarlyUserCanceled();
mContainerView.animateAway(AuthDialogCallback.DISMISSED_USER_CANCELED);
}
return true;
});
setOnApplyWindowInsetsListener(this);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mPasswordField.setTextOperationUser(UserHandle.of(mUserId));
if (mCredentialType == Utils.CREDENTIAL_PIN) {
mPasswordField.setInputType(
InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
}
mPasswordField.requestFocus();
mPasswordField.scheduleShowSoftInput();
}
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
// Check if this was the result of hitting the enter key
final boolean isSoftImeEvent = event == null
&& (actionId == EditorInfo.IME_NULL
|| actionId == EditorInfo.IME_ACTION_DONE
|| actionId == EditorInfo.IME_ACTION_NEXT);
final boolean isKeyboardEnterKey = event != null
&& KeyEvent.isConfirmKey(event.getKeyCode())
&& event.getAction() == KeyEvent.ACTION_DOWN;
if (isSoftImeEvent || isKeyboardEnterKey) {
checkPasswordAndUnlock();
return true;
}
return false;
}
private void checkPasswordAndUnlock() {
try (LockscreenCredential password = mCredentialType == Utils.CREDENTIAL_PIN
? LockscreenCredential.createPinOrNone(mPasswordField.getText())
: LockscreenCredential.createPasswordOrNone(mPasswordField.getText())) {
if (password.isNone()) {
return;
}
// Request LockSettingsService to return the Gatekeeper Password in the
// VerifyCredentialResponse so that we can request a Gatekeeper HAT with the
// Gatekeeper Password and operationId.
mPendingLockCheck = LockPatternChecker.verifyCredential(mLockPatternUtils,
password, mEffectiveUserId, LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE,
this::onCredentialVerified);
}
}
@Override
protected void onCredentialVerified(@NonNull VerifyCredentialResponse response,
int timeoutMs) {
super.onCredentialVerified(response, timeoutMs);
if (response.isMatched()) {
mImm.hideSoftInputFromWindow(getWindowToken(), 0 /* flags */);
} else {
mPasswordField.setText("");
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (mAuthCredentialInput == null || mAuthCredentialHeader == null || mSubtitleView == null
|| mDescriptionView == null || mPasswordField == null || mErrorView == null) {
return;
}
int inputLeftBound;
int inputTopBound;
int headerRightBound = right;
int headerTopBounds = top;
final int subTitleBottom = (mSubtitleView.getVisibility() == GONE) ? mTitleView.getBottom()
: mSubtitleView.getBottom();
final int descBottom = (mDescriptionView.getVisibility() == GONE) ? subTitleBottom
: mDescriptionView.getBottom();
if (getResources().getConfiguration().orientation == ORIENTATION_LANDSCAPE) {
inputTopBound = (bottom - mAuthCredentialInput.getHeight()) / 2;
inputLeftBound = (right - left) / 2;
headerRightBound = inputLeftBound;
headerTopBounds -= Math.min(mIconView.getBottom(), mBottomInset);
} else {
inputTopBound =
descBottom + (bottom - descBottom - mAuthCredentialInput.getHeight()) / 2;
inputLeftBound = (right - left - mAuthCredentialInput.getWidth()) / 2;
}
if (mDescriptionView.getBottom() > mBottomInset) {
mAuthCredentialHeader.layout(left, headerTopBounds, headerRightBound, bottom);
}
mAuthCredentialInput.layout(inputLeftBound, inputTopBound, right, bottom);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
final int newWidth = MeasureSpec.getSize(widthMeasureSpec);
final int newHeight = MeasureSpec.getSize(heightMeasureSpec) - mBottomInset;
setMeasuredDimension(newWidth, newHeight);
final int halfWidthSpec = MeasureSpec.makeMeasureSpec(getWidth() / 2,
MeasureSpec.AT_MOST);
final int fullHeightSpec = MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.UNSPECIFIED);
if (getResources().getConfiguration().orientation == ORIENTATION_LANDSCAPE) {
measureChildren(halfWidthSpec, fullHeightSpec);
} else {
measureChildren(widthMeasureSpec, fullHeightSpec);
}
}
@NonNull
@Override
public WindowInsets onApplyWindowInsets(@NonNull View v, WindowInsets insets) {
final Insets bottomInset = insets.getInsets(ime());
if (v instanceof AuthCredentialPasswordView && mBottomInset != bottomInset.bottom) {
mBottomInset = bottomInset.bottom;
if (mBottomInset > 0
&& getResources().getConfiguration().orientation == ORIENTATION_LANDSCAPE) {
mTitleView.setSingleLine(true);
mTitleView.setEllipsize(TextUtils.TruncateAt.MARQUEE);
mTitleView.setMarqueeRepeatLimit(-1);
// select to enable marquee unless a screen reader is enabled
mTitleView.setSelected(!mAccessibilityManager.isEnabled()
|| !mAccessibilityManager.isTouchExplorationEnabled());
} else {
mTitleView.setSingleLine(false);
mTitleView.setEllipsize(null);
// select to enable marquee unless a screen reader is enabled
mTitleView.setSelected(false);
}
requestLayout();
}
return insets;
}
@Override
public void dump(@NonNull PrintWriter pw, @NonNull String[] args) {
pw.println(TAG + "State:");
pw.println(" mBottomInset=" + mBottomInset);
pw.println(" mAuthCredentialHeader size=(" + mAuthCredentialHeader.getWidth() + ","
+ mAuthCredentialHeader.getHeight());
pw.println(" mAuthCredentialInput size=(" + mAuthCredentialInput.getWidth() + ","
+ mAuthCredentialInput.getHeight());
}
}

View File

@@ -1,113 +0,0 @@
/*
* 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;
import android.annotation.NonNull;
import android.content.Context;
import android.util.AttributeSet;
import com.android.internal.widget.LockPatternChecker;
import com.android.internal.widget.LockPatternUtils;
import com.android.internal.widget.LockPatternView;
import com.android.internal.widget.LockscreenCredential;
import com.android.internal.widget.VerifyCredentialResponse;
import com.android.systemui.R;
import java.util.List;
/**
* Pattern UI
*/
public class AuthCredentialPatternView extends AuthCredentialView {
private LockPatternView mLockPatternView;
private class UnlockPatternListener implements LockPatternView.OnPatternListener {
@Override
public void onPatternStart() {
}
@Override
public void onPatternCleared() {
}
@Override
public void onPatternCellAdded(List<LockPatternView.Cell> pattern) {
}
@Override
public void onPatternDetected(List<LockPatternView.Cell> pattern) {
if (mPendingLockCheck != null) {
mPendingLockCheck.cancel(false);
}
mLockPatternView.setEnabled(false);
if (pattern.size() < LockPatternUtils.MIN_PATTERN_REGISTER_FAIL) {
// Pattern size is less than the minimum, do not count it as a failed attempt.
onPatternVerified(VerifyCredentialResponse.ERROR, 0 /* timeoutMs */);
return;
}
try (LockscreenCredential credential = LockscreenCredential.createPattern(pattern)) {
// Request LockSettingsService to return the Gatekeeper Password in the
// VerifyCredentialResponse so that we can request a Gatekeeper HAT with the
// Gatekeeper Password and operationId.
mPendingLockCheck = LockPatternChecker.verifyCredential(
mLockPatternUtils,
credential,
mEffectiveUserId,
LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE,
this::onPatternVerified);
}
}
private void onPatternVerified(@NonNull VerifyCredentialResponse response, int timeoutMs) {
AuthCredentialPatternView.this.onCredentialVerified(response, timeoutMs);
if (timeoutMs > 0) {
mLockPatternView.setEnabled(false);
} else {
mLockPatternView.setEnabled(true);
}
}
}
@Override
protected void onErrorTimeoutFinish() {
super.onErrorTimeoutFinish();
// select to enable marquee unless a screen reader is enabled
mLockPatternView.setEnabled(!mAccessibilityManager.isEnabled()
|| !mAccessibilityManager.isTouchExplorationEnabled());
}
public AuthCredentialPatternView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
mLockPatternView = findViewById(R.id.lockPattern);
mLockPatternView.setOnPatternListener(new UnlockPatternListener());
mLockPatternView.setInStealthMode(
!mLockPatternUtils.isVisiblePatternEnabled(mUserId));
}
}

View File

@@ -1,565 +0,0 @@
/*
* 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;
import static android.app.admin.DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_LOCK_FAILED_ATTEMPTS;
import static android.app.admin.DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PASSWORD_LAST_ATTEMPT;
import static android.app.admin.DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PATTERN_LAST_ATTEMPT;
import static android.app.admin.DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PIN_LAST_ATTEMPT;
import static android.app.admin.DevicePolicyResources.UNDEFINED;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.AlertDialog;
import android.app.admin.DevicePolicyManager;
import android.content.Context;
import android.content.pm.UserInfo;
import android.graphics.drawable.Drawable;
import android.hardware.biometrics.BiometricPrompt;
import android.hardware.biometrics.PromptInfo;
import android.os.AsyncTask;
import android.os.CountDownTimer;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.os.UserManager;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.StringRes;
import com.android.internal.widget.LockPatternUtils;
import com.android.internal.widget.VerifyCredentialResponse;
import com.android.systemui.R;
import com.android.systemui.animation.Interpolators;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.util.concurrency.DelayableExecutor;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Abstract base class for Pin, Pattern, or Password authentication, for
* {@link BiometricPrompt.Builder#setAllowedAuthenticators(int)}}
*/
public abstract class AuthCredentialView extends LinearLayout {
private static final String TAG = "BiometricPrompt/AuthCredentialView";
private static final int ERROR_DURATION_MS = 3000;
static final int USER_TYPE_PRIMARY = 1;
static final int USER_TYPE_MANAGED_PROFILE = 2;
static final int USER_TYPE_SECONDARY = 3;
@Retention(RetentionPolicy.SOURCE)
@IntDef({USER_TYPE_PRIMARY, USER_TYPE_MANAGED_PROFILE, USER_TYPE_SECONDARY})
private @interface UserType {}
protected final Handler mHandler;
protected final LockPatternUtils mLockPatternUtils;
protected final AccessibilityManager mAccessibilityManager;
private final UserManager mUserManager;
private final DevicePolicyManager mDevicePolicyManager;
private PromptInfo mPromptInfo;
private AuthPanelController mPanelController;
private boolean mShouldAnimatePanel;
private boolean mShouldAnimateContents;
protected TextView mTitleView;
protected TextView mSubtitleView;
protected TextView mDescriptionView;
protected ImageView mIconView;
protected TextView mErrorView;
protected @Utils.CredentialType int mCredentialType;
protected AuthContainerView mContainerView;
protected Callback mCallback;
protected AsyncTask<?, ?, ?> mPendingLockCheck;
protected int mUserId;
protected long mOperationId;
protected int mEffectiveUserId;
protected ErrorTimer mErrorTimer;
protected @Background DelayableExecutor mBackgroundExecutor;
interface Callback {
void onCredentialMatched(byte[] attestation);
}
protected static class ErrorTimer extends CountDownTimer {
private final TextView mErrorView;
private final Context mContext;
/**
* @param millisInFuture The number of millis in the future from the call
* to {@link #start()} until the countdown is done and {@link
* #onFinish()}
* is called.
* @param countDownInterval The interval along the way to receive
* {@link #onTick(long)} callbacks.
*/
public ErrorTimer(Context context, long millisInFuture, long countDownInterval,
TextView errorView) {
super(millisInFuture, countDownInterval);
mErrorView = errorView;
mContext = context;
}
@Override
public void onTick(long millisUntilFinished) {
final int secondsCountdown = (int) (millisUntilFinished / 1000);
mErrorView.setText(mContext.getString(
R.string.biometric_dialog_credential_too_many_attempts, secondsCountdown));
}
@Override
public void onFinish() {
if (mErrorView != null) {
mErrorView.setText("");
}
}
}
protected final Runnable mClearErrorRunnable = new Runnable() {
@Override
public void run() {
if (mErrorView != null) {
mErrorView.setText("");
}
}
};
public AuthCredentialView(Context context, AttributeSet attrs) {
super(context, attrs);
mLockPatternUtils = new LockPatternUtils(mContext);
mHandler = new Handler(Looper.getMainLooper());
mAccessibilityManager = mContext.getSystemService(AccessibilityManager.class);
mUserManager = mContext.getSystemService(UserManager.class);
mDevicePolicyManager = mContext.getSystemService(DevicePolicyManager.class);
}
protected void showError(String error) {
if (mHandler != null) {
mHandler.removeCallbacks(mClearErrorRunnable);
mHandler.postDelayed(mClearErrorRunnable, ERROR_DURATION_MS);
}
if (mErrorView != null) {
mErrorView.setText(error);
}
}
private void setTextOrHide(TextView view, CharSequence text) {
if (TextUtils.isEmpty(text)) {
view.setVisibility(View.GONE);
} else {
view.setText(text);
}
Utils.notifyAccessibilityContentChanged(mAccessibilityManager, this);
}
private void setText(TextView view, CharSequence text) {
view.setText(text);
}
void setUserId(int userId) {
mUserId = userId;
}
void setOperationId(long operationId) {
mOperationId = operationId;
}
void setEffectiveUserId(int effectiveUserId) {
mEffectiveUserId = effectiveUserId;
}
void setCredentialType(@Utils.CredentialType int credentialType) {
mCredentialType = credentialType;
}
void setCallback(Callback callback) {
mCallback = callback;
}
void setPromptInfo(PromptInfo promptInfo) {
mPromptInfo = promptInfo;
}
void setPanelController(AuthPanelController panelController, boolean animatePanel) {
mPanelController = panelController;
mShouldAnimatePanel = animatePanel;
}
void setShouldAnimateContents(boolean animateContents) {
mShouldAnimateContents = animateContents;
}
void setContainerView(AuthContainerView containerView) {
mContainerView = containerView;
}
void setBackgroundExecutor(@Background DelayableExecutor bgExecutor) {
mBackgroundExecutor = bgExecutor;
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
final CharSequence title = getTitle(mPromptInfo);
setText(mTitleView, title);
setTextOrHide(mSubtitleView, getSubtitle(mPromptInfo));
setTextOrHide(mDescriptionView, getDescription(mPromptInfo));
announceForAccessibility(title);
if (mIconView != null) {
final boolean isManagedProfile = Utils.isManagedProfile(mContext, mEffectiveUserId);
final Drawable image;
if (isManagedProfile) {
image = getResources().getDrawable(R.drawable.auth_dialog_enterprise,
mContext.getTheme());
} else {
image = getResources().getDrawable(R.drawable.auth_dialog_lock,
mContext.getTheme());
}
mIconView.setImageDrawable(image);
}
// Only animate this if we're transitioning from a biometric view.
if (mShouldAnimateContents) {
setTranslationY(getResources()
.getDimension(R.dimen.biometric_dialog_credential_translation_offset));
setAlpha(0);
postOnAnimation(() -> {
animate().translationY(0)
.setDuration(AuthDialog.ANIMATE_CREDENTIAL_INITIAL_DURATION_MS)
.alpha(1.f)
.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN)
.withLayer()
.start();
});
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mErrorTimer != null) {
mErrorTimer.cancel();
}
}
@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.icon);
mErrorView = findViewById(R.id.error);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (mShouldAnimatePanel) {
// Credential view is always full screen.
mPanelController.setUseFullScreen(true);
mPanelController.updateForContentDimensions(mPanelController.getContainerWidth(),
mPanelController.getContainerHeight(), 0 /* animateDurationMs */);
mShouldAnimatePanel = false;
}
}
protected void onErrorTimeoutFinish() {}
protected void onCredentialVerified(@NonNull VerifyCredentialResponse response, int timeoutMs) {
if (response.isMatched()) {
mClearErrorRunnable.run();
mLockPatternUtils.userPresent(mEffectiveUserId);
// The response passed into this method contains the Gatekeeper Password. We still
// have to request Gatekeeper to create a Hardware Auth Token with the
// Gatekeeper Password and Challenge (keystore operationId in this case)
final long pwHandle = response.getGatekeeperPasswordHandle();
final VerifyCredentialResponse gkResponse = mLockPatternUtils
.verifyGatekeeperPasswordHandle(pwHandle, mOperationId, mEffectiveUserId);
mCallback.onCredentialMatched(gkResponse.getGatekeeperHAT());
mLockPatternUtils.removeGatekeeperPasswordHandle(pwHandle);
} else {
if (timeoutMs > 0) {
mHandler.removeCallbacks(mClearErrorRunnable);
long deadline = mLockPatternUtils.setLockoutAttemptDeadline(
mEffectiveUserId, timeoutMs);
mErrorTimer = new ErrorTimer(mContext,
deadline - SystemClock.elapsedRealtime(),
LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS,
mErrorView) {
@Override
public void onFinish() {
onErrorTimeoutFinish();
mClearErrorRunnable.run();
}
};
mErrorTimer.start();
} else {
final boolean didUpdateErrorText = reportFailedAttempt();
if (!didUpdateErrorText) {
final @StringRes int errorRes;
switch (mCredentialType) {
case Utils.CREDENTIAL_PIN:
errorRes = R.string.biometric_dialog_wrong_pin;
break;
case Utils.CREDENTIAL_PATTERN:
errorRes = R.string.biometric_dialog_wrong_pattern;
break;
case Utils.CREDENTIAL_PASSWORD:
default:
errorRes = R.string.biometric_dialog_wrong_password;
break;
}
showError(getResources().getString(errorRes));
}
}
}
}
private boolean reportFailedAttempt() {
boolean result = updateErrorMessage(
mLockPatternUtils.getCurrentFailedPasswordAttempts(mEffectiveUserId) + 1);
mLockPatternUtils.reportFailedPasswordAttempt(mEffectiveUserId);
return result;
}
private boolean updateErrorMessage(int numAttempts) {
// Don't show any message if there's no maximum number of attempts.
final int maxAttempts = mLockPatternUtils.getMaximumFailedPasswordsForWipe(
mEffectiveUserId);
if (maxAttempts <= 0 || numAttempts <= 0) {
return false;
}
// Update the on-screen error string.
if (mErrorView != null) {
final String message = getResources().getString(
R.string.biometric_dialog_credential_attempts_before_wipe,
numAttempts,
maxAttempts);
showError(message);
}
// Only show dialog if <=1 attempts are left before wiping.
final int remainingAttempts = maxAttempts - numAttempts;
if (remainingAttempts == 1) {
showLastAttemptBeforeWipeDialog();
} else if (remainingAttempts <= 0) {
showNowWipingDialog();
}
return true;
}
private void showLastAttemptBeforeWipeDialog() {
mBackgroundExecutor.execute(() -> {
final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
.setTitle(R.string.biometric_dialog_last_attempt_before_wipe_dialog_title)
.setMessage(
getLastAttemptBeforeWipeMessage(getUserTypeForWipe(), mCredentialType))
.setPositiveButton(android.R.string.ok, null)
.create();
alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL);
mHandler.post(alertDialog::show);
});
}
private void showNowWipingDialog() {
mBackgroundExecutor.execute(() -> {
String nowWipingMessage = getNowWipingMessage(getUserTypeForWipe());
final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
.setMessage(nowWipingMessage)
.setPositiveButton(
com.android.settingslib.R.string.failed_attempts_now_wiping_dialog_dismiss,
null /* OnClickListener */)
.setOnDismissListener(
dialog -> mContainerView.animateAway(
AuthDialogCallback.DISMISSED_ERROR))
.create();
alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL);
mHandler.post(alertDialog::show);
});
}
private @UserType int getUserTypeForWipe() {
final UserInfo userToBeWiped = mUserManager.getUserInfo(
mDevicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(mEffectiveUserId));
if (userToBeWiped == null || userToBeWiped.isPrimary()) {
return USER_TYPE_PRIMARY;
} else if (userToBeWiped.isManagedProfile()) {
return USER_TYPE_MANAGED_PROFILE;
} else {
return USER_TYPE_SECONDARY;
}
}
// This should not be called on the main thread to avoid making an IPC.
private String getLastAttemptBeforeWipeMessage(
@UserType int userType, @Utils.CredentialType int credentialType) {
switch (userType) {
case USER_TYPE_PRIMARY:
return getLastAttemptBeforeWipeDeviceMessage(credentialType);
case USER_TYPE_MANAGED_PROFILE:
return getLastAttemptBeforeWipeProfileMessage(credentialType);
case USER_TYPE_SECONDARY:
return getLastAttemptBeforeWipeUserMessage(credentialType);
default:
throw new IllegalArgumentException("Unrecognized user type:" + userType);
}
}
private String getLastAttemptBeforeWipeDeviceMessage(
@Utils.CredentialType int credentialType) {
switch (credentialType) {
case Utils.CREDENTIAL_PIN:
return mContext.getString(
R.string.biometric_dialog_last_pin_attempt_before_wipe_device);
case Utils.CREDENTIAL_PATTERN:
return mContext.getString(
R.string.biometric_dialog_last_pattern_attempt_before_wipe_device);
case Utils.CREDENTIAL_PASSWORD:
default:
return mContext.getString(
R.string.biometric_dialog_last_password_attempt_before_wipe_device);
}
}
// This should not be called on the main thread to avoid making an IPC.
private String getLastAttemptBeforeWipeProfileMessage(
@Utils.CredentialType int credentialType) {
return mDevicePolicyManager.getResources().getString(
getLastAttemptBeforeWipeProfileUpdatableStringId(credentialType),
() -> getLastAttemptBeforeWipeProfileDefaultMessage(credentialType));
}
private static String getLastAttemptBeforeWipeProfileUpdatableStringId(
@Utils.CredentialType int credentialType) {
switch (credentialType) {
case Utils.CREDENTIAL_PIN:
return BIOMETRIC_DIALOG_WORK_PIN_LAST_ATTEMPT;
case Utils.CREDENTIAL_PATTERN:
return BIOMETRIC_DIALOG_WORK_PATTERN_LAST_ATTEMPT;
case Utils.CREDENTIAL_PASSWORD:
default:
return BIOMETRIC_DIALOG_WORK_PASSWORD_LAST_ATTEMPT;
}
}
private String getLastAttemptBeforeWipeProfileDefaultMessage(
@Utils.CredentialType int credentialType) {
int resId;
switch (credentialType) {
case Utils.CREDENTIAL_PIN:
resId = R.string.biometric_dialog_last_pin_attempt_before_wipe_profile;
break;
case Utils.CREDENTIAL_PATTERN:
resId = R.string.biometric_dialog_last_pattern_attempt_before_wipe_profile;
break;
case Utils.CREDENTIAL_PASSWORD:
default:
resId = R.string.biometric_dialog_last_password_attempt_before_wipe_profile;
}
return mContext.getString(resId);
}
private String getLastAttemptBeforeWipeUserMessage(
@Utils.CredentialType int credentialType) {
int resId;
switch (credentialType) {
case Utils.CREDENTIAL_PIN:
resId = R.string.biometric_dialog_last_pin_attempt_before_wipe_user;
break;
case Utils.CREDENTIAL_PATTERN:
resId = R.string.biometric_dialog_last_pattern_attempt_before_wipe_user;
break;
case Utils.CREDENTIAL_PASSWORD:
default:
resId = R.string.biometric_dialog_last_password_attempt_before_wipe_user;
}
return mContext.getString(resId);
}
private String getNowWipingMessage(@UserType int userType) {
return mDevicePolicyManager.getResources().getString(
getNowWipingUpdatableStringId(userType),
() -> getNowWipingDefaultMessage(userType));
}
private String getNowWipingUpdatableStringId(@UserType int userType) {
switch (userType) {
case USER_TYPE_MANAGED_PROFILE:
return BIOMETRIC_DIALOG_WORK_LOCK_FAILED_ATTEMPTS;
default:
return UNDEFINED;
}
}
private String getNowWipingDefaultMessage(@UserType int userType) {
int resId;
switch (userType) {
case USER_TYPE_PRIMARY:
resId = com.android.settingslib.R.string.failed_attempts_now_wiping_device;
break;
case USER_TYPE_MANAGED_PROFILE:
resId = com.android.settingslib.R.string.failed_attempts_now_wiping_profile;
break;
case USER_TYPE_SECONDARY:
resId = com.android.settingslib.R.string.failed_attempts_now_wiping_user;
break;
default:
throw new IllegalArgumentException("Unrecognized user type:" + userType);
}
return mContext.getString(resId);
}
@Nullable
private static CharSequence getTitle(@NonNull PromptInfo promptInfo) {
final CharSequence credentialTitle = promptInfo.getDeviceCredentialTitle();
return credentialTitle != null ? credentialTitle : promptInfo.getTitle();
}
@Nullable
private static CharSequence getSubtitle(@NonNull PromptInfo promptInfo) {
final CharSequence credentialSubtitle = promptInfo.getDeviceCredentialSubtitle();
return credentialSubtitle != null ? credentialSubtitle : promptInfo.getSubtitle();
}
@Nullable
private static CharSequence getDescription(@NonNull PromptInfo promptInfo) {
final CharSequence credentialDescription = promptInfo.getDeviceCredentialDescription();
return credentialDescription != null ? credentialDescription : promptInfo.getDescription();
}
}

View File

@@ -177,11 +177,11 @@ public class AuthPanelController extends ViewOutlineProvider {
}
}
int getContainerWidth() {
public int getContainerWidth() {
return mContainerWidth;
}
int getContainerHeight() {
public int getContainerHeight() {
return mContainerHeight;
}

View File

@@ -0,0 +1,130 @@
package com.android.systemui.biometrics.ui
import android.content.Context
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.text.TextUtils
import android.util.AttributeSet
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsets.Type.ime
import android.view.accessibility.AccessibilityManager
import android.widget.ImageView
import android.widget.ImeAwareEditText
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isGone
import com.android.systemui.R
import com.android.systemui.biometrics.AuthPanelController
import com.android.systemui.biometrics.ui.binder.CredentialViewBinder
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
/** PIN or password credential view for BiometricPrompt. */
class CredentialPasswordView(context: Context, attrs: AttributeSet?) :
LinearLayout(context, attrs), CredentialView, View.OnApplyWindowInsetsListener {
private lateinit var titleView: TextView
private lateinit var subtitleView: TextView
private lateinit var descriptionView: TextView
private lateinit var iconView: ImageView
private lateinit var passwordField: ImeAwareEditText
private lateinit var credentialHeader: View
private lateinit var credentialInput: View
private var bottomInset: Int = 0
private val accessibilityManager by lazy {
context.getSystemService(AccessibilityManager::class.java)
}
/** Initializes the view. */
override fun init(
viewModel: CredentialViewModel,
host: CredentialView.Host,
panelViewController: AuthPanelController,
animatePanel: Boolean,
) {
CredentialViewBinder.bind(this, host, viewModel, panelViewController, animatePanel)
}
override fun onFinishInflate() {
super.onFinishInflate()
titleView = requireViewById(R.id.title)
subtitleView = requireViewById(R.id.subtitle)
descriptionView = requireViewById(R.id.description)
iconView = requireViewById(R.id.icon)
subtitleView = requireViewById(R.id.subtitle)
passwordField = requireViewById(R.id.lockPassword)
credentialHeader = requireViewById(R.id.auth_credential_header)
credentialInput = requireViewById(R.id.auth_credential_input)
setOnApplyWindowInsetsListener(this)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, left, top, right, bottom)
val inputLeftBound: Int
val inputTopBound: Int
var headerRightBound = right
var headerTopBounds = top
val subTitleBottom: Int = if (subtitleView.isGone) titleView.bottom else subtitleView.bottom
val descBottom = if (descriptionView.isGone) subTitleBottom else descriptionView.bottom
if (resources.configuration.orientation == ORIENTATION_LANDSCAPE) {
inputTopBound = (bottom - credentialInput.height) / 2
inputLeftBound = (right - left) / 2
headerRightBound = inputLeftBound
headerTopBounds -= iconView.bottom.coerceAtMost(bottomInset)
} else {
inputTopBound = descBottom + (bottom - descBottom - credentialInput.height) / 2
inputLeftBound = (right - left - credentialInput.width) / 2
}
if (descriptionView.bottom > bottomInset) {
credentialHeader.layout(left, headerTopBounds, headerRightBound, bottom)
}
credentialInput.layout(inputLeftBound, inputTopBound, right, bottom)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val newWidth = MeasureSpec.getSize(widthMeasureSpec)
val newHeight = MeasureSpec.getSize(heightMeasureSpec) - bottomInset
setMeasuredDimension(newWidth, newHeight)
val halfWidthSpec = MeasureSpec.makeMeasureSpec(width / 2, MeasureSpec.AT_MOST)
val fullHeightSpec = MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.UNSPECIFIED)
if (resources.configuration.orientation == ORIENTATION_LANDSCAPE) {
measureChildren(halfWidthSpec, fullHeightSpec)
} else {
measureChildren(widthMeasureSpec, fullHeightSpec)
}
}
override fun onApplyWindowInsets(v: View, insets: WindowInsets): WindowInsets {
val bottomInsets = insets.getInsets(ime())
if (bottomInset != bottomInsets.bottom) {
bottomInset = bottomInsets.bottom
if (bottomInset > 0 && resources.configuration.orientation == ORIENTATION_LANDSCAPE) {
titleView.isSingleLine = true
titleView.ellipsize = TextUtils.TruncateAt.MARQUEE
titleView.marqueeRepeatLimit = -1
// select to enable marquee unless a screen reader is enabled
titleView.isSelected = accessibilityManager.shouldMarquee()
} else {
titleView.isSingleLine = false
titleView.ellipsize = null
// select to enable marquee unless a screen reader is enabled
titleView.isSelected = false
}
requestLayout()
}
return insets
}
}
private fun AccessibilityManager.shouldMarquee(): Boolean = !isEnabled || !isTouchExplorationEnabled

View File

@@ -0,0 +1,23 @@
package com.android.systemui.biometrics.ui
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import com.android.systemui.biometrics.AuthPanelController
import com.android.systemui.biometrics.ui.binder.CredentialViewBinder
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
/** Pattern credential view for BiometricPrompt. */
class CredentialPatternView(context: Context, attrs: AttributeSet?) :
LinearLayout(context, attrs), CredentialView {
/** Initializes the view. */
override fun init(
viewModel: CredentialViewModel,
host: CredentialView.Host,
panelViewController: AuthPanelController,
animatePanel: Boolean,
) {
CredentialViewBinder.bind(this, host, viewModel, panelViewController, animatePanel)
}
}

View File

@@ -0,0 +1,31 @@
package com.android.systemui.biometrics.ui
import com.android.systemui.biometrics.AuthPanelController
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
/** A credential variant of BiometricPrompt. */
sealed interface CredentialView {
/**
* Callbacks for the "host" container view that contains this credential view.
*
* TODO(b/251476085): Removed when the host view is converted to use a parent view model.
*/
interface Host {
/** When the user's credential has been verified. */
fun onCredentialMatched(attestation: ByteArray)
/** When the user abandons credential verification. */
fun onCredentialAborted()
/** Warn the user is warned about excessive attempts. */
fun onCredentialAttemptsRemaining(remaining: Int, messageBody: String)
}
// TODO(251476085): remove AuthPanelController
fun init(
viewModel: CredentialViewModel,
host: Host,
panelViewController: AuthPanelController,
animatePanel: Boolean,
)
}

View File

@@ -0,0 +1,104 @@
package com.android.systemui.biometrics.ui.binder
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.ImeAwareEditText
import android.widget.TextView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.systemui.R
import com.android.systemui.biometrics.ui.CredentialPasswordView
import com.android.systemui.biometrics.ui.CredentialView
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
import com.android.systemui.lifecycle.repeatWhenAttached
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/** Sub-binder for the [CredentialPasswordView]. */
object CredentialPasswordViewBinder {
/** Bind the view. */
fun bind(
view: CredentialPasswordView,
host: CredentialView.Host,
viewModel: CredentialViewModel,
) {
val imeManager = view.context.getSystemService(InputMethodManager::class.java)!!
val passwordField: ImeAwareEditText = view.requireViewById(R.id.lockPassword)
view.repeatWhenAttached {
passwordField.requestFocus()
passwordField.scheduleShowSoftInput()
repeatOnLifecycle(Lifecycle.State.STARTED) {
// observe credential validation attempts and submit/cancel buttons
launch {
viewModel.header.collect { header ->
passwordField.setTextOperationUser(header.user)
passwordField.setOnEditorActionListener(
OnImeSubmitListener { text ->
launch { viewModel.checkCredential(text, header) }
}
)
passwordField.setOnKeyListener(
OnBackButtonListener { host.onCredentialAborted() }
)
}
}
launch {
viewModel.inputFlags.collect { flags ->
flags?.let { passwordField.inputType = it }
}
}
// dismiss on a valid credential check
launch {
viewModel.validatedAttestation.collect { attestation ->
if (attestation != null) {
imeManager.hideSoftInputFromWindow(view.windowToken, 0 /* flags */)
host.onCredentialMatched(attestation)
} else {
passwordField.setText("")
}
}
}
}
}
}
}
private class OnBackButtonListener(private val onBack: () -> Unit) : View.OnKeyListener {
override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean {
if (keyCode != KeyEvent.KEYCODE_BACK) {
return false
}
if (event.action == KeyEvent.ACTION_UP) {
onBack()
}
return true
}
}
private class OnImeSubmitListener(private val onSubmit: (text: CharSequence) -> Unit) :
TextView.OnEditorActionListener {
override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean {
val isSoftImeEvent =
event == null &&
(actionId == EditorInfo.IME_NULL ||
actionId == EditorInfo.IME_ACTION_DONE ||
actionId == EditorInfo.IME_ACTION_NEXT)
val isKeyboardEnterKey =
event != null &&
KeyEvent.isConfirmKey(event.keyCode) &&
event.action == KeyEvent.ACTION_DOWN
if (isSoftImeEvent || isKeyboardEnterKey) {
onSubmit(v.text)
return true
}
return false
}
}

View File

@@ -0,0 +1,75 @@
package com.android.systemui.biometrics.ui.binder
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.internal.widget.LockPatternUtils
import com.android.internal.widget.LockPatternView
import com.android.systemui.R
import com.android.systemui.biometrics.ui.CredentialPatternView
import com.android.systemui.biometrics.ui.CredentialView
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
import com.android.systemui.lifecycle.repeatWhenAttached
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/** Sub-binder for the [CredentialPatternView]. */
object CredentialPatternViewBinder {
/** Bind the view. */
fun bind(
view: CredentialPatternView,
host: CredentialView.Host,
viewModel: CredentialViewModel,
) {
val lockPatternView: LockPatternView = view.requireViewById(R.id.lockPattern)
view.repeatWhenAttached {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// observe credential validation attempts and submit/cancel buttons
launch {
viewModel.header.collect { header ->
lockPatternView.setOnPatternListener(
OnPatternDetectedListener { pattern ->
if (pattern.isPatternLongEnough()) {
// Pattern size is less than the minimum
// do not count it as a failed attempt
viewModel.showPatternTooShortError()
} else {
lockPatternView.isEnabled = false
launch { viewModel.checkCredential(pattern, header) }
}
}
)
}
}
launch { viewModel.stealthMode.collect { lockPatternView.isInStealthMode = it } }
// dismiss on a valid credential check
launch {
viewModel.validatedAttestation.collect { attestation ->
val matched = attestation != null
lockPatternView.isEnabled = !matched
if (matched) {
host.onCredentialMatched(attestation!!)
}
}
}
}
}
}
}
private class OnPatternDetectedListener(
private val onDetected: (pattern: List<LockPatternView.Cell>) -> Unit
) : LockPatternView.OnPatternListener {
override fun onPatternCellAdded(pattern: List<LockPatternView.Cell>) {}
override fun onPatternCleared() {}
override fun onPatternStart() {}
override fun onPatternDetected(pattern: List<LockPatternView.Cell>) {
onDetected(pattern)
}
}
private fun List<LockPatternView.Cell>.isPatternLongEnough(): Boolean =
size < LockPatternUtils.MIN_PATTERN_REGISTER_FAIL

View File

@@ -0,0 +1,140 @@
package com.android.systemui.biometrics.ui.binder
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.systemui.R
import com.android.systemui.animation.Interpolators
import com.android.systemui.biometrics.AuthDialog
import com.android.systemui.biometrics.AuthPanelController
import com.android.systemui.biometrics.ui.CredentialPasswordView
import com.android.systemui.biometrics.ui.CredentialPatternView
import com.android.systemui.biometrics.ui.CredentialView
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
import com.android.systemui.lifecycle.repeatWhenAttached
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
/**
* View binder for all credential variants of BiometricPrompt, including [CredentialPatternView] and
* [CredentialPasswordView].
*
* This binder delegates to sub-binders for each variant, such as the [CredentialPasswordViewBinder]
* and [CredentialPatternViewBinder].
*/
object CredentialViewBinder {
/** Binds a [CredentialPasswordView] or [CredentialPatternView] to a [CredentialViewModel]. */
@JvmStatic
fun bind(
view: ViewGroup,
host: CredentialView.Host,
viewModel: CredentialViewModel,
panelViewController: AuthPanelController,
animatePanel: Boolean,
maxErrorDuration: Long = 3_000L,
) {
val titleView: TextView = view.requireViewById(R.id.title)
val subtitleView: TextView = view.requireViewById(R.id.subtitle)
val descriptionView: TextView = view.requireViewById(R.id.description)
val iconView: ImageView? = view.findViewById(R.id.icon)
val errorView: TextView = view.requireViewById(R.id.error)
var errorTimer: Job? = null
// bind common elements
view.repeatWhenAttached {
if (animatePanel) {
with(panelViewController) {
// Credential view is always full screen.
setUseFullScreen(true)
updateForContentDimensions(
containerWidth,
containerHeight,
0 /* animateDurationMs */
)
}
}
repeatOnLifecycle(Lifecycle.State.STARTED) {
// show prompt metadata
launch {
viewModel.header.collect { header ->
titleView.text = header.title
view.announceForAccessibility(header.title)
subtitleView.textOrHide = header.subtitle
descriptionView.textOrHide = header.description
iconView?.setImageDrawable(header.icon)
// Only animate this if we're transitioning from a biometric view.
if (viewModel.animateContents.value) {
view.animateCredentialViewIn()
}
}
}
// show transient error messages
launch {
viewModel.errorMessage
.onEach { msg ->
errorTimer?.cancel()
if (msg.isNotBlank()) {
errorTimer = launch {
delay(maxErrorDuration)
viewModel.resetErrorMessage()
}
}
}
.collect { errorView.textOrHide = it }
}
// show an extra dialog if the remaining attempts becomes low
launch {
viewModel.remainingAttempts
.filter { it.remaining != null }
.collect { info ->
host.onCredentialAttemptsRemaining(info.remaining!!, info.message)
}
}
}
}
// bind the auth widget
when (view) {
is CredentialPasswordView -> CredentialPasswordViewBinder.bind(view, host, viewModel)
is CredentialPatternView -> CredentialPatternViewBinder.bind(view, host, viewModel)
else -> throw IllegalStateException("unexpected view type: ${view.javaClass.name}")
}
}
}
private fun View.animateCredentialViewIn() {
translationY = resources.getDimension(R.dimen.biometric_dialog_credential_translation_offset)
alpha = 0f
postOnAnimation {
animate()
.translationY(0f)
.setDuration(AuthDialog.ANIMATE_CREDENTIAL_INITIAL_DURATION_MS.toLong())
.alpha(1f)
.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN)
.withLayer()
.start()
}
}
private var TextView.textOrHide: String?
set(value) {
val gone = value.isNullOrBlank()
visibility = if (gone) View.GONE else View.VISIBLE
text = if (gone) "" else value
}
get() = text?.toString()

View File

@@ -0,0 +1,178 @@
package com.android.systemui.biometrics.ui.viewmodel
import android.content.Context
import android.graphics.drawable.Drawable
import android.os.UserHandle
import android.text.InputType
import com.android.internal.widget.LockPatternView
import com.android.systemui.R
import com.android.systemui.biometrics.Utils
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor
import com.android.systemui.biometrics.domain.interactor.CredentialStatus
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.dagger.qualifiers.Application
import javax.inject.Inject
import kotlin.reflect.KClass
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
/** View-model for all CredentialViews within BiometricPrompt. */
class CredentialViewModel
@Inject
constructor(
@Application private val applicationContext: Context,
private val credentialInteractor: BiometricPromptCredentialInteractor,
) {
/** Top level information about the prompt. */
val header: Flow<HeaderViewModel> =
credentialInteractor.prompt.filterIsInstance<BiometricPromptRequest.Credential>().map {
request ->
BiometricPromptHeaderViewModelImpl(
request,
user = UserHandle.of(request.userInfo.userId),
title = request.title,
subtitle = request.subtitle,
description = request.description,
icon = applicationContext.asLockIcon(request.userInfo.deviceCredentialOwnerId),
)
}
/** Input flags for text based credential views */
val inputFlags: Flow<Int?> =
credentialInteractor.prompt.map {
when (it) {
is BiometricPromptRequest.Credential.Pin ->
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
else -> null
}
}
/** If stealth mode is active (hide user credential input). */
val stealthMode: Flow<Boolean> =
credentialInteractor.prompt.map {
when (it) {
is BiometricPromptRequest.Credential.Pattern -> it.stealthMode
else -> false
}
}
private val _animateContents: MutableStateFlow<Boolean> = MutableStateFlow(true)
/** If this view should be animated on transitions. */
val animateContents = _animateContents.asStateFlow()
/** Error messages to show the user. */
val errorMessage: Flow<String> =
combine(credentialInteractor.verificationError, credentialInteractor.prompt) { error, p ->
when (error) {
is CredentialStatus.Fail.Error -> error.error
?: applicationContext.asBadCredentialErrorMessage(p)
is CredentialStatus.Fail.Throttled -> error.error
null -> ""
}
}
private val _validatedAttestation: MutableSharedFlow<ByteArray?> = MutableSharedFlow()
/** Results of [checkPatternCredential]. A non-null attestation is supplied on success. */
val validatedAttestation: Flow<ByteArray?> = _validatedAttestation.asSharedFlow()
private val _remainingAttempts: MutableStateFlow<RemainingAttempts> =
MutableStateFlow(RemainingAttempts())
/** If set, the number of remaining attempts before the user must stop. */
val remainingAttempts: Flow<RemainingAttempts> = _remainingAttempts.asStateFlow()
/** Enable transition animations. */
fun setAnimateContents(animate: Boolean) {
_animateContents.value = animate
}
/** Show an error message to inform the user the pattern is too short to attempt validation. */
fun showPatternTooShortError() {
credentialInteractor.setVerificationError(
CredentialStatus.Fail.Error(
applicationContext.asBadCredentialErrorMessage(
BiometricPromptRequest.Credential.Pattern::class
)
)
)
}
/** Reset the error message to an empty string. */
fun resetErrorMessage() {
credentialInteractor.resetVerificationError()
}
/** Check a PIN or password and update [validatedAttestation] or [remainingAttempts]. */
suspend fun checkCredential(text: CharSequence, header: HeaderViewModel) =
checkCredential(credentialInteractor.checkCredential(header.asRequest(), text = text))
/** Check a pattern and update [validatedAttestation] or [remainingAttempts]. */
suspend fun checkCredential(pattern: List<LockPatternView.Cell>, header: HeaderViewModel) =
checkCredential(credentialInteractor.checkCredential(header.asRequest(), pattern = pattern))
private suspend fun checkCredential(result: CredentialStatus) {
when (result) {
is CredentialStatus.Success.Verified -> {
_validatedAttestation.emit(result.hat)
_remainingAttempts.value = RemainingAttempts()
}
is CredentialStatus.Fail.Error -> {
_validatedAttestation.emit(null)
_remainingAttempts.value =
RemainingAttempts(result.remainingAttempts, result.urgentMessage ?: "")
}
is CredentialStatus.Fail.Throttled -> {
// required for completeness, but a throttled error cannot be the final result
_validatedAttestation.emit(null)
_remainingAttempts.value = RemainingAttempts()
}
}
}
}
private fun Context.asBadCredentialErrorMessage(prompt: BiometricPromptRequest?): String =
asBadCredentialErrorMessage(
if (prompt != null) prompt::class else BiometricPromptRequest.Credential.Password::class
)
private fun <T : BiometricPromptRequest> Context.asBadCredentialErrorMessage(
clazz: KClass<T>
): String =
getString(
when (clazz) {
BiometricPromptRequest.Credential.Pin::class -> R.string.biometric_dialog_wrong_pin
BiometricPromptRequest.Credential.Password::class ->
R.string.biometric_dialog_wrong_password
BiometricPromptRequest.Credential.Pattern::class ->
R.string.biometric_dialog_wrong_pattern
else -> R.string.biometric_dialog_wrong_password
}
)
private fun Context.asLockIcon(userId: Int): Drawable {
val id =
if (Utils.isManagedProfile(this, userId)) {
R.drawable.auth_dialog_enterprise
} else {
R.drawable.auth_dialog_lock
}
return resources.getDrawable(id, theme)
}
private class BiometricPromptHeaderViewModelImpl(
val request: BiometricPromptRequest.Credential,
override val user: UserHandle,
override val title: String,
override val subtitle: String,
override val description: String,
override val icon: Drawable,
) : HeaderViewModel
private fun HeaderViewModel.asRequest(): BiometricPromptRequest.Credential =
(this as BiometricPromptHeaderViewModelImpl).request

View File

@@ -0,0 +1,13 @@
package com.android.systemui.biometrics.ui.viewmodel
import android.graphics.drawable.Drawable
import android.os.UserHandle
/** View model for the top-level header / info area of BiometricPrompt. */
interface HeaderViewModel {
val user: UserHandle
val title: String
val subtitle: String
val description: String
val icon: Drawable
}

View File

@@ -0,0 +1,4 @@
package com.android.systemui.biometrics.ui.viewmodel
/** Metadata about the number of credential attempts the user has left [remaining], if known. */
data class RemainingAttempts(val remaining: Int? = null, val message: String = "")

View File

@@ -41,10 +41,15 @@ import com.android.internal.jank.InteractionJankMonitor
import com.android.internal.widget.LockPatternUtils
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.biometrics.data.repository.FakePromptRepository
import com.android.systemui.biometrics.domain.interactor.FakeCredentialInteractor
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
import com.android.systemui.keyguard.WakefulnessLifecycle
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Rule
import org.junit.Test
@@ -80,6 +85,15 @@ class AuthContainerViewTest : SysuiTestCase() {
@Mock
lateinit var interactionJankMonitor: InteractionJankMonitor
private val biometricPromptRepository = FakePromptRepository()
private val credentialInteractor = FakeCredentialInteractor()
private val bpCredentialInteractor = BiometricPromptCredentialInteractor(
Dispatchers.Main.immediate,
biometricPromptRepository,
credentialInteractor
)
private val credentialViewModel = CredentialViewModel(mContext, bpCredentialInteractor)
private var authContainer: TestAuthContainerView? = null
@After
@@ -466,6 +480,8 @@ class AuthContainerViewTest : SysuiTestCase() {
userManager,
lockPatternUtils,
interactionJankMonitor,
{ bpCredentialInteractor },
{ credentialViewModel },
Handler(TestableLooper.get(this).looper),
FakeExecutor(FakeSystemClock())
) {

View File

@@ -87,6 +87,8 @@ import com.android.internal.R;
import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.widget.LockPatternUtils;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor;
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel;
import com.android.systemui.keyguard.WakefulnessLifecycle;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.CommandQueue;
@@ -163,6 +165,11 @@ public class AuthControllerTest extends SysuiTestCase {
private UdfpsLogger mUdfpsLogger;
@Mock
private InteractionJankMonitor mInteractionJankMonitor;
@Mock
private BiometricPromptCredentialInteractor mBiometricPromptCredentialInteractor;
@Mock
private CredentialViewModel mCredentialViewModel;
@Captor
private ArgumentCaptor<IFingerprintAuthenticatorsRegisteredCallback> mFpAuthenticatorsRegisteredCaptor;
@Captor
@@ -981,6 +988,7 @@ public class AuthControllerTest extends SysuiTestCase {
fingerprintManager, faceManager, udfpsControllerFactory,
sidefpsControllerFactory, mDisplayManager, mWakefulnessLifecycle,
mUserManager, mLockPatternUtils, mUdfpsLogger, statusBarStateController,
() -> mBiometricPromptCredentialInteractor, () -> mCredentialViewModel,
mInteractionJankMonitor, mHandler, mBackgroundExecutor, vibratorHelper);
}

View File

@@ -0,0 +1,181 @@
package com.android.systemui.biometrics.ui.viewmodel
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.biometrics.data.model.PromptKind
import com.android.systemui.biometrics.data.repository.FakePromptRepository
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor
import com.android.systemui.biometrics.domain.interactor.CredentialStatus
import com.android.systemui.biometrics.domain.interactor.FakeCredentialInteractor
import com.android.systemui.biometrics.promptInfo
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
private const val USER_ID = 9
private const val OPERATION_ID = 10L
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class CredentialViewModelTest : SysuiTestCase() {
private val dispatcher = UnconfinedTestDispatcher()
private val promptRepository = FakePromptRepository()
private val credentialInteractor = FakeCredentialInteractor()
private lateinit var viewModel: CredentialViewModel
@Before
fun setup() {
viewModel =
CredentialViewModel(
mContext,
BiometricPromptCredentialInteractor(
dispatcher,
promptRepository,
credentialInteractor
)
)
}
@Test fun setsPinInputFlags() = setsInputFlags(PromptKind.PIN, expectFlags = true)
@Test fun setsPasswordInputFlags() = setsInputFlags(PromptKind.PASSWORD, expectFlags = false)
@Test fun setsPatternInputFlags() = setsInputFlags(PromptKind.PATTERN, expectFlags = false)
private fun setsInputFlags(type: PromptKind, expectFlags: Boolean) =
runTestWithKind(type) {
var flags: Int? = null
val job = launch { viewModel.inputFlags.collect { flags = it } }
if (expectFlags) {
assertThat(flags).isNotNull()
} else {
assertThat(flags).isNull()
}
job.cancel()
}
@Test fun isStealthIgnoredByPin() = isStealthMode(PromptKind.PIN, expectStealth = false)
@Test
fun isStealthIgnoredByPassword() = isStealthMode(PromptKind.PASSWORD, expectStealth = false)
@Test fun isStealthUsedByPattern() = isStealthMode(PromptKind.PATTERN, expectStealth = true)
private fun isStealthMode(type: PromptKind, expectStealth: Boolean) =
runTestWithKind(type, init = { credentialInteractor.stealthMode = true }) {
var stealth: Boolean? = null
val job = launch { viewModel.stealthMode.collect { stealth = it } }
assertThat(stealth).isEqualTo(expectStealth)
job.cancel()
}
@Test
fun animatesContents() = runTestWithKind {
val expected = arrayOf(true, false, true)
val animate = mutableListOf<Boolean>()
val job = launch { viewModel.animateContents.toList(animate) }
for (value in expected) {
viewModel.setAnimateContents(value)
viewModel.setAnimateContents(value)
}
assertThat(animate).containsExactly(*expected).inOrder()
job.cancel()
}
@Test
fun showAndClearErrors() = runTestWithKind {
var error = ""
val job = launch { viewModel.errorMessage.collect { error = it } }
assertThat(error).isEmpty()
viewModel.showPatternTooShortError()
assertThat(error).isNotEmpty()
viewModel.resetErrorMessage()
assertThat(error).isEmpty()
job.cancel()
}
@Test
fun checkCredential() = runTestWithKind {
val hat = ByteArray(2)
credentialInteractor.verifyCredentialResponse = { _ ->
flowOf(CredentialStatus.Success.Verified(hat))
}
val attestations = mutableListOf<ByteArray?>()
val remainingAttempts = mutableListOf<RemainingAttempts?>()
var header: HeaderViewModel? = null
val job = launch {
launch { viewModel.validatedAttestation.toList(attestations) }
launch { viewModel.remainingAttempts.toList(remainingAttempts) }
launch { viewModel.header.collect { header = it } }
}
assertThat(header).isNotNull()
viewModel.checkCredential("p", header!!)
val attestation = attestations.removeLastOrNull()
assertThat(attestation).isSameInstanceAs(hat)
assertThat(attestations).isEmpty()
assertThat(remainingAttempts).containsExactly(RemainingAttempts())
job.cancel()
}
@Test
fun checkCredentialWhenBad() = runTestWithKind {
val remaining = 2
val urgentError = "wow"
credentialInteractor.verifyCredentialResponse = { _ ->
flowOf(CredentialStatus.Fail.Error("error", remaining, urgentError))
}
val attestations = mutableListOf<ByteArray?>()
val remainingAttempts = mutableListOf<RemainingAttempts?>()
var header: HeaderViewModel? = null
val job = launch {
launch { viewModel.validatedAttestation.toList(attestations) }
launch { viewModel.remainingAttempts.toList(remainingAttempts) }
launch { viewModel.header.collect { header = it } }
}
assertThat(header).isNotNull()
viewModel.checkCredential("1111", header!!)
assertThat(attestations).containsExactly(null)
val attemptInfo = remainingAttempts.removeLastOrNull()
assertThat(attemptInfo).isNotNull()
assertThat(attemptInfo!!.remaining).isEqualTo(remaining)
assertThat(attemptInfo.message).isEqualTo(urgentError)
assertThat(remainingAttempts).containsExactly(RemainingAttempts()) // initial value
job.cancel()
}
private fun runTestWithKind(
kind: PromptKind = PromptKind.PIN,
init: () -> Unit = {},
block: suspend TestScope.() -> Unit,
) =
runTest(dispatcher) {
init()
promptRepository.setPrompt(promptInfo(), USER_ID, OPERATION_ID, kind)
block()
}
}