Merge changes from topic "bio-refactor-example"

* changes:
  Refactor biometric prompt credential screens - ui layer.
  Refactor biometric prompt credential screens - domain layer
  Refactor biometric prompt credential screen - data layer.
This commit is contained in:
Joe Bolinger
2022-10-27 00:27:56 +00:00
committed by Android (Google) Code Review
38 changed files with 2531 additions and 996 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:
@@ -693,6 +699,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,
@@ -717,6 +725,9 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
mFaceEnrolledForUser = new SparseBooleanArray();
mVibratorHelper = vibrator;
mBiometricPromptInteractor = biometricPromptInteractor;
mCredentialViewModelProvider = credentialViewModelProvider;
mOrientationListener = new BiometricDisplayListener(
context,
mDisplayManager,
@@ -1079,6 +1090,11 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
return mUdfpsEnrolledForUser.get(userId);
}
/** If BiometricPrompt is currently being shown to the user. */
public boolean isShowing() {
return mCurrentDialog != null;
}
private void showDialog(SomeArgs args, boolean skipAnimation, Bundle savedState) {
mCurrentDialogArgs = args;
@@ -1210,7 +1226,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

@@ -16,32 +16,45 @@
package com.android.systemui.biometrics.dagger
import com.android.systemui.biometrics.data.repository.PromptRepository
import com.android.systemui.biometrics.data.repository.PromptRepositoryImpl
import com.android.systemui.biometrics.domain.interactor.CredentialInteractor
import com.android.systemui.biometrics.domain.interactor.CredentialInteractorImpl
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.util.concurrency.ThreadFactory
import dagger.Binds
import dagger.Module
import dagger.Provides
import java.util.concurrent.Executor
import javax.inject.Qualifier
/**
* Dagger module for all things biometric.
*/
/** Dagger module for all things biometric. */
@Module
object BiometricsModule {
interface BiometricsModule {
/** Background [Executor] for HAL related operations. */
@Provides
@Binds
@SysUISingleton
@JvmStatic
@BiometricsBackground
fun providesPluginExecutor(threadFactory: ThreadFactory): Executor =
threadFactory.buildExecutorOnNewThread("biometrics")
fun biometricPromptRepository(impl: PromptRepositoryImpl): PromptRepository
@Binds
@SysUISingleton
fun providesCredentialInteractor(impl: CredentialInteractorImpl): CredentialInteractor
companion object {
/** Background [Executor] for HAL related operations. */
@Provides
@SysUISingleton
@JvmStatic
@BiometricsBackground
fun providesPluginExecutor(threadFactory: ThreadFactory): Executor =
threadFactory.buildExecutorOnNewThread("biometrics")
}
}
/**
* Background executor for HAL operations that are latency sensitive but too
* slow to run on the main thread. Prefer the shared executors, such as
* [com.android.systemui.dagger.qualifiers.Background] when a HAL is not directly involved.
* Background executor for HAL operations that are latency sensitive but too slow to run on the main
* thread. Prefer the shared executors, such as [com.android.systemui.dagger.qualifiers.Background]
* when a HAL is not directly involved.
*/
@Qualifier
@MustBeDocumented

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2022 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.data.model
import com.android.systemui.biometrics.Utils
// TODO(b/251476085): this should eventually replace Utils.CredentialType
/** Credential options for biometric prompt. Shadows [Utils.CredentialType]. */
enum class PromptKind {
ANY_BIOMETRIC,
PIN,
PATTERN,
PASSWORD,
}

View File

@@ -0,0 +1,102 @@
package com.android.systemui.biometrics.data.repository
import android.hardware.biometrics.PromptInfo
import com.android.systemui.biometrics.AuthController
import com.android.systemui.biometrics.data.model.PromptKind
import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import com.android.systemui.dagger.SysUISingleton
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* A repository for the global state of BiometricPrompt.
*
* There is never more than one instance of the prompt at any given time.
*/
interface PromptRepository {
/** If the prompt is showing. */
val isShowing: Flow<Boolean>
/** The app-specific details to show in the prompt. */
val promptInfo: StateFlow<PromptInfo?>
/** The user that the prompt is for. */
val userId: StateFlow<Int?>
/** The gatekeeper challenge, if one is associated with this prompt. */
val challenge: StateFlow<Long?>
/** The kind of credential to use (biometric, pin, pattern, etc.). */
val kind: StateFlow<PromptKind>
/** Update the prompt configuration, which should be set before [isShowing]. */
fun setPrompt(
promptInfo: PromptInfo,
userId: Int,
gatekeeperChallenge: Long?,
kind: PromptKind = PromptKind.ANY_BIOMETRIC,
)
/** Unset the prompt info. */
fun unsetPrompt()
}
@SysUISingleton
class PromptRepositoryImpl @Inject constructor(private val authController: AuthController) :
PromptRepository {
override val isShowing: Flow<Boolean> = conflatedCallbackFlow {
val callback =
object : AuthController.Callback {
override fun onBiometricPromptShown() =
trySendWithFailureLogging(true, TAG, "set isShowing")
override fun onBiometricPromptDismissed() =
trySendWithFailureLogging(false, TAG, "unset isShowing")
}
authController.addCallback(callback)
trySendWithFailureLogging(authController.isShowing, TAG, "update isShowing")
awaitClose { authController.removeCallback(callback) }
}
private val _promptInfo: MutableStateFlow<PromptInfo?> = MutableStateFlow(null)
override val promptInfo = _promptInfo.asStateFlow()
private val _challenge: MutableStateFlow<Long?> = MutableStateFlow(null)
override val challenge: StateFlow<Long?> = _challenge.asStateFlow()
private val _userId: MutableStateFlow<Int?> = MutableStateFlow(null)
override val userId = _userId.asStateFlow()
private val _kind: MutableStateFlow<PromptKind> = MutableStateFlow(PromptKind.ANY_BIOMETRIC)
override val kind = _kind.asStateFlow()
override fun setPrompt(
promptInfo: PromptInfo,
userId: Int,
gatekeeperChallenge: Long?,
kind: PromptKind,
) {
_kind.value = kind
_userId.value = userId
_challenge.value = gatekeeperChallenge
_promptInfo.value = promptInfo
}
override fun unsetPrompt() {
_promptInfo.value = null
_userId.value = null
_challenge.value = null
_kind.value = PromptKind.ANY_BIOMETRIC
}
companion object {
private const val TAG = "BiometricPromptRepository"
}
}

View File

@@ -0,0 +1,282 @@
package com.android.systemui.biometrics.domain.interactor
import android.app.admin.DevicePolicyManager
import android.app.admin.DevicePolicyResources
import android.content.Context
import android.os.UserManager
import com.android.internal.widget.LockPatternUtils
import com.android.internal.widget.LockscreenCredential
import com.android.internal.widget.VerifyCredentialResponse
import com.android.systemui.R
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/**
* A wrapper for [LockPatternUtils] to verify PIN, pattern, or password credentials.
*
* This class also uses the [DevicePolicyManager] to generate appropriate error messages when policy
* exceptions are raised (i.e. wipe device due to excessive failed attempts, etc.).
*/
interface CredentialInteractor {
/** If the user's pattern credential should be hidden */
fun isStealthModeActive(userId: Int): Boolean
/** Get the effective user id (profile owner, if one exists) */
fun getCredentialOwnerOrSelfId(userId: Int): Int
/**
* Verifies a credential and returns a stream of results.
*
* The final emitted value will either be a [CredentialStatus.Fail.Error] or a
* [CredentialStatus.Success.Verified].
*/
fun verifyCredential(
request: BiometricPromptRequest.Credential,
credential: LockscreenCredential,
): Flow<CredentialStatus>
}
/** Standard implementation of [CredentialInteractor]. */
class CredentialInteractorImpl
@Inject
constructor(
@Application private val applicationContext: Context,
private val lockPatternUtils: LockPatternUtils,
private val userManager: UserManager,
private val devicePolicyManager: DevicePolicyManager,
private val systemClock: SystemClock,
) : CredentialInteractor {
override fun isStealthModeActive(userId: Int): Boolean =
!lockPatternUtils.isVisiblePatternEnabled(userId)
override fun getCredentialOwnerOrSelfId(userId: Int): Int =
userManager.getCredentialOwnerProfile(userId)
override fun verifyCredential(
request: BiometricPromptRequest.Credential,
credential: LockscreenCredential,
): Flow<CredentialStatus> = flow {
// Request LockSettingsService to return the Gatekeeper Password in the
// VerifyCredentialResponse so that we can request a Gatekeeper HAT with the
// Gatekeeper Password and operationId.
val effectiveUserId = request.userInfo.deviceCredentialOwnerId
val response =
lockPatternUtils.verifyCredential(
credential,
effectiveUserId,
LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE
)
if (response.isMatched) {
lockPatternUtils.userPresent(effectiveUserId)
// 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)
val pwHandle = response.gatekeeperPasswordHandle
val gkResponse: VerifyCredentialResponse =
lockPatternUtils.verifyGatekeeperPasswordHandle(
pwHandle,
request.operationInfo.gatekeeperChallenge,
effectiveUserId
)
val hat = gkResponse.gatekeeperHAT
lockPatternUtils.removeGatekeeperPasswordHandle(pwHandle)
emit(CredentialStatus.Success.Verified(hat))
} else if (response.timeout > 0) {
// if requests are being throttled, update the error message every
// second until the temporary lock has expired
val deadline: Long =
lockPatternUtils.setLockoutAttemptDeadline(effectiveUserId, response.timeout)
val interval = LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS
var remaining = deadline - systemClock.elapsedRealtime()
while (remaining > 0) {
emit(
CredentialStatus.Fail.Throttled(
applicationContext.getString(
R.string.biometric_dialog_credential_too_many_attempts,
remaining / 1000
)
)
)
delay(interval)
remaining -= interval
}
emit(CredentialStatus.Fail.Error(""))
} else { // bad request, but not throttled
val numAttempts = lockPatternUtils.getCurrentFailedPasswordAttempts(effectiveUserId) + 1
val maxAttempts = lockPatternUtils.getMaximumFailedPasswordsForWipe(effectiveUserId)
if (maxAttempts <= 0 || numAttempts <= 0) {
// use a generic message if there's no maximum number of attempts
emit(CredentialStatus.Fail.Error())
} else {
val remainingAttempts = (maxAttempts - numAttempts).coerceAtLeast(0)
emit(
CredentialStatus.Fail.Error(
applicationContext.getString(
R.string.biometric_dialog_credential_attempts_before_wipe,
numAttempts,
maxAttempts
),
remainingAttempts,
fetchFinalAttemptMessageOrNull(request, remainingAttempts)
)
)
}
lockPatternUtils.reportFailedPasswordAttempt(effectiveUserId)
}
}
private fun fetchFinalAttemptMessageOrNull(
request: BiometricPromptRequest.Credential,
remainingAttempts: Int?,
): String? =
if (remainingAttempts != null && remainingAttempts <= 1) {
applicationContext.getFinalAttemptMessageOrBlank(
request,
devicePolicyManager,
userManager.getUserTypeForWipe(
devicePolicyManager,
request.userInfo.deviceCredentialOwnerId
),
remainingAttempts
)
} else {
null
}
}
private enum class UserType {
PRIMARY,
MANAGED_PROFILE,
SECONDARY,
}
private fun UserManager.getUserTypeForWipe(
devicePolicyManager: DevicePolicyManager,
effectiveUserId: Int,
): UserType {
val userToBeWiped =
getUserInfo(
devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(effectiveUserId)
)
return when {
userToBeWiped == null || userToBeWiped.isPrimary -> UserType.PRIMARY
userToBeWiped.isManagedProfile -> UserType.MANAGED_PROFILE
else -> UserType.SECONDARY
}
}
private fun Context.getFinalAttemptMessageOrBlank(
request: BiometricPromptRequest.Credential,
devicePolicyManager: DevicePolicyManager,
userType: UserType,
remaining: Int,
): String =
when {
remaining == 1 -> getLastAttemptBeforeWipeMessage(request, devicePolicyManager, userType)
remaining <= 0 -> getNowWipingMessage(devicePolicyManager, userType)
else -> ""
}
private fun Context.getLastAttemptBeforeWipeMessage(
request: BiometricPromptRequest.Credential,
devicePolicyManager: DevicePolicyManager,
userType: UserType,
): String =
when (userType) {
UserType.PRIMARY -> getLastAttemptBeforeWipeDeviceMessage(request)
UserType.MANAGED_PROFILE ->
getLastAttemptBeforeWipeProfileMessage(request, devicePolicyManager)
UserType.SECONDARY -> getLastAttemptBeforeWipeUserMessage(request)
}
private fun Context.getLastAttemptBeforeWipeDeviceMessage(
request: BiometricPromptRequest.Credential,
): String {
val id =
when (request) {
is BiometricPromptRequest.Credential.Pin ->
R.string.biometric_dialog_last_pin_attempt_before_wipe_device
is BiometricPromptRequest.Credential.Pattern ->
R.string.biometric_dialog_last_pattern_attempt_before_wipe_device
is BiometricPromptRequest.Credential.Password ->
R.string.biometric_dialog_last_password_attempt_before_wipe_device
}
return getString(id)
}
private fun Context.getLastAttemptBeforeWipeProfileMessage(
request: BiometricPromptRequest.Credential,
devicePolicyManager: DevicePolicyManager,
): String {
val id =
when (request) {
is BiometricPromptRequest.Credential.Pin ->
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PIN_LAST_ATTEMPT
is BiometricPromptRequest.Credential.Pattern ->
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PATTERN_LAST_ATTEMPT
is BiometricPromptRequest.Credential.Password ->
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PASSWORD_LAST_ATTEMPT
}
return devicePolicyManager.resources.getString(id) {
// use fallback a string if not found
val defaultId =
when (request) {
is BiometricPromptRequest.Credential.Pin ->
R.string.biometric_dialog_last_pin_attempt_before_wipe_profile
is BiometricPromptRequest.Credential.Pattern ->
R.string.biometric_dialog_last_pattern_attempt_before_wipe_profile
is BiometricPromptRequest.Credential.Password ->
R.string.biometric_dialog_last_password_attempt_before_wipe_profile
}
getString(defaultId)
}
}
private fun Context.getLastAttemptBeforeWipeUserMessage(
request: BiometricPromptRequest.Credential,
): String {
val resId =
when (request) {
is BiometricPromptRequest.Credential.Pin ->
R.string.biometric_dialog_last_pin_attempt_before_wipe_user
is BiometricPromptRequest.Credential.Pattern ->
R.string.biometric_dialog_last_pattern_attempt_before_wipe_user
is BiometricPromptRequest.Credential.Password ->
R.string.biometric_dialog_last_password_attempt_before_wipe_user
}
return getString(resId)
}
private fun Context.getNowWipingMessage(
devicePolicyManager: DevicePolicyManager,
userType: UserType,
): String {
val id =
when (userType) {
UserType.MANAGED_PROFILE ->
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_LOCK_FAILED_ATTEMPTS
else -> DevicePolicyResources.UNDEFINED
}
return devicePolicyManager.resources.getString(id) {
// use fallback a string if not found
val defaultId =
when (userType) {
UserType.PRIMARY ->
com.android.settingslib.R.string.failed_attempts_now_wiping_device
UserType.MANAGED_PROFILE ->
com.android.settingslib.R.string.failed_attempts_now_wiping_profile
UserType.SECONDARY ->
com.android.settingslib.R.string.failed_attempts_now_wiping_user
}
getString(defaultId)
}
}

View File

@@ -0,0 +1,23 @@
package com.android.systemui.biometrics.domain.interactor
/** Result of a [CredentialInteractor.verifyCredential] check. */
sealed interface CredentialStatus {
/** A successful result. */
sealed interface Success : CredentialStatus {
/** The credential is valid and a [hat] has been generated. */
data class Verified(val hat: ByteArray) : Success
}
/** A failed result. */
sealed interface Fail : CredentialStatus {
val error: String?
/** The credential check failed with an [error]. */
data class Error(
override val error: String? = null,
val remainingAttempts: Int? = null,
val urgentMessage: String? = null,
) : Fail
/** The credential check failed with an [error] and is temporarily locked out. */
data class Throttled(override val error: String) : Fail
}
}

View File

@@ -0,0 +1,189 @@
package com.android.systemui.biometrics.domain.interactor
import android.hardware.biometrics.PromptInfo
import com.android.internal.widget.LockPatternView
import com.android.internal.widget.LockscreenCredential
import com.android.systemui.biometrics.Utils
import com.android.systemui.biometrics.data.model.PromptKind
import com.android.systemui.biometrics.data.repository.PromptRepository
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
import com.android.systemui.dagger.qualifiers.Background
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
/**
* Business logic for BiometricPrompt's CredentialViews, which primarily includes checking a users
* PIN, pattern, or password credential instead of a biometric.
*/
class BiometricPromptCredentialInteractor
@Inject
constructor(
@Background private val bgDispatcher: CoroutineDispatcher,
private val biometricPromptRepository: PromptRepository,
private val credentialInteractor: CredentialInteractor,
) {
/** If the prompt is currently showing. */
val isShowing: Flow<Boolean> = biometricPromptRepository.isShowing
/** Metadata about the current credential prompt, including app-supplied preferences. */
val prompt: Flow<BiometricPromptRequest?> =
combine(
biometricPromptRepository.promptInfo,
biometricPromptRepository.challenge,
biometricPromptRepository.userId,
biometricPromptRepository.kind
) { promptInfo, challenge, userId, kind ->
if (promptInfo == null || userId == null || challenge == null) {
return@combine null
}
when (kind) {
PromptKind.PIN ->
BiometricPromptRequest.Credential.Pin(
info = promptInfo,
userInfo = userInfo(userId),
operationInfo = operationInfo(challenge)
)
PromptKind.PATTERN ->
BiometricPromptRequest.Credential.Pattern(
info = promptInfo,
userInfo = userInfo(userId),
operationInfo = operationInfo(challenge),
stealthMode = credentialInteractor.isStealthModeActive(userId)
)
PromptKind.PASSWORD ->
BiometricPromptRequest.Credential.Password(
info = promptInfo,
userInfo = userInfo(userId),
operationInfo = operationInfo(challenge)
)
else -> null
}
}
.distinctUntilChanged()
private fun userInfo(userId: Int): BiometricUserInfo =
BiometricUserInfo(
userId = userId,
deviceCredentialOwnerId = credentialInteractor.getCredentialOwnerOrSelfId(userId)
)
private fun operationInfo(challenge: Long): BiometricOperationInfo =
BiometricOperationInfo(gatekeeperChallenge = challenge)
/** Most recent error due to [verifyCredential]. */
private val _verificationError = MutableStateFlow<CredentialStatus.Fail?>(null)
val verificationError: Flow<CredentialStatus.Fail?> = _verificationError.asStateFlow()
/** Update the current request to use credential-based authentication instead of biometrics. */
fun useCredentialsForAuthentication(
promptInfo: PromptInfo,
@Utils.CredentialType kind: Int,
userId: Int,
challenge: Long,
) {
biometricPromptRepository.setPrompt(
promptInfo,
userId,
challenge,
kind.asBiometricPromptCredential()
)
}
/** Unset the current authentication request. */
fun resetPrompt() {
biometricPromptRepository.unsetPrompt()
}
/**
* Check a credential and return the attestation token (HAT) if successful.
*
* This method will not return if credential checks are being throttled until the throttling has
* expired and the user can try again. It will periodically update the [verificationError] until
* cancelled or the throttling has completed. If the request is not throttled, but unsuccessful,
* the [verificationError] will be set and an optional
* [CredentialStatus.Fail.Error.urgentMessage] message may be provided to indicate additional
* hints to the user (i.e. device will be wiped on next failure, etc.).
*
* The check happens on the background dispatcher given in the constructor.
*/
suspend fun checkCredential(
request: BiometricPromptRequest.Credential,
text: CharSequence? = null,
pattern: List<LockPatternView.Cell>? = null,
): CredentialStatus =
withContext(bgDispatcher) {
val credential =
when (request) {
is BiometricPromptRequest.Credential.Pin ->
LockscreenCredential.createPinOrNone(text ?: "")
is BiometricPromptRequest.Credential.Password ->
LockscreenCredential.createPasswordOrNone(text ?: "")
is BiometricPromptRequest.Credential.Pattern ->
LockscreenCredential.createPattern(pattern ?: listOf())
}
credential.use { c -> verifyCredential(request, c) }
}
private suspend fun verifyCredential(
request: BiometricPromptRequest.Credential,
credential: LockscreenCredential?
): CredentialStatus {
if (credential == null || credential.isNone) {
return CredentialStatus.Fail.Error()
}
val finalStatus =
credentialInteractor
.verifyCredential(request, credential)
.onEach { status ->
when (status) {
is CredentialStatus.Success -> _verificationError.value = null
is CredentialStatus.Fail -> _verificationError.value = status
}
}
.lastOrNull()
return finalStatus ?: CredentialStatus.Fail.Error()
}
/**
* Report a user-visible error.
*
* Use this instead of calling [verifyCredential] when it is not necessary because the check
* will obviously fail (i.e. too short, empty, etc.)
*/
fun setVerificationError(error: CredentialStatus.Fail.Error?) {
if (error != null) {
_verificationError.value = error
} else {
resetVerificationError()
}
}
/** Clear the current error message, if any. */
fun resetVerificationError() {
_verificationError.value = null
}
}
// TODO(b/251476085): remove along with Utils.CredentialType
/** Convert a [Utils.CredentialType] to the corresponding [PromptKind]. */
private fun @receiver:Utils.CredentialType Int.asBiometricPromptCredential(): PromptKind =
when (this) {
Utils.CREDENTIAL_PIN -> PromptKind.PIN
Utils.CREDENTIAL_PASSWORD -> PromptKind.PASSWORD
Utils.CREDENTIAL_PATTERN -> PromptKind.PATTERN
else -> PromptKind.ANY_BIOMETRIC
}

View File

@@ -0,0 +1,4 @@
package com.android.systemui.biometrics.domain.model
/** Metadata about an in-progress biometric operation. */
data class BiometricOperationInfo(val gatekeeperChallenge: Long = -1)

View File

@@ -0,0 +1,69 @@
package com.android.systemui.biometrics.domain.model
import android.hardware.biometrics.PromptInfo
/**
* Preferences for BiometricPrompt, such as title & description, that are immutable while the prompt
* is showing.
*
* This roughly corresponds to a "request" by the system or an app to show BiometricPrompt and it
* contains a subset of the information in a [PromptInfo] that is relevant to SysUI.
*/
sealed class BiometricPromptRequest(
val title: String,
val subtitle: String,
val description: String,
val userInfo: BiometricUserInfo,
val operationInfo: BiometricOperationInfo,
) {
/** Prompt using one or more biometrics. */
class Biometric(
info: PromptInfo,
userInfo: BiometricUserInfo,
operationInfo: BiometricOperationInfo,
) :
BiometricPromptRequest(
title = info.title?.toString() ?: "",
subtitle = info.subtitle?.toString() ?: "",
description = info.description?.toString() ?: "",
userInfo = userInfo,
operationInfo = operationInfo
)
/** Prompt using a credential (pin, pattern, password). */
sealed class Credential(
info: PromptInfo,
userInfo: BiometricUserInfo,
operationInfo: BiometricOperationInfo,
) :
BiometricPromptRequest(
title = (info.deviceCredentialTitle ?: info.title)?.toString() ?: "",
subtitle = (info.deviceCredentialSubtitle ?: info.subtitle)?.toString() ?: "",
description = (info.deviceCredentialDescription ?: info.description)?.toString() ?: "",
userInfo = userInfo,
operationInfo = operationInfo,
) {
/** PIN prompt. */
class Pin(
info: PromptInfo,
userInfo: BiometricUserInfo,
operationInfo: BiometricOperationInfo,
) : Credential(info, userInfo, operationInfo)
/** Password prompt. */
class Password(
info: PromptInfo,
userInfo: BiometricUserInfo,
operationInfo: BiometricOperationInfo,
) : Credential(info, userInfo, operationInfo)
/** Pattern prompt. */
class Pattern(
info: PromptInfo,
userInfo: BiometricUserInfo,
operationInfo: BiometricOperationInfo,
val stealthMode: Boolean,
) : Credential(info, userInfo, operationInfo)
}
}

View File

@@ -0,0 +1,7 @@
package com.android.systemui.biometrics.domain.model
/** Metadata about the current user BiometricPrompt is being shown to. */
data class BiometricUserInfo(
val userId: Int,
val deviceCredentialOwnerId: Int = userId,
)

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

@@ -89,6 +89,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;
@@ -165,6 +167,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
@@ -1009,6 +1016,7 @@ public class AuthControllerTest extends SysuiTestCase {
fingerprintManager, faceManager, udfpsControllerFactory,
sidefpsControllerFactory, mDisplayManager, mWakefulnessLifecycle,
mUserManager, mLockPatternUtils, mUdfpsLogger, statusBarStateController,
() -> mBiometricPromptCredentialInteractor, () -> mCredentialViewModel,
mInteractionJankMonitor, mHandler, mBackgroundExecutor, vibratorHelper);
}

View File

@@ -22,12 +22,11 @@ import android.hardware.biometrics.BiometricManager
import android.hardware.biometrics.ComponentInfoInternal
import android.hardware.biometrics.PromptInfo
import android.hardware.biometrics.SensorProperties
import android.hardware.face.FaceSensorPropertiesInternal
import android.hardware.face.FaceSensorProperties
import android.hardware.face.FaceSensorPropertiesInternal
import android.hardware.fingerprint.FingerprintSensorProperties
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import android.os.Bundle
import android.testing.ViewUtils
import android.view.LayoutInflater
@@ -83,26 +82,31 @@ internal fun AuthBiometricView?.destroyDialog() {
internal fun fingerprintSensorPropertiesInternal(
ids: List<Int> = listOf(0)
): List<FingerprintSensorPropertiesInternal> {
val componentInfo = listOf(
val componentInfo =
listOf(
ComponentInfoInternal(
"fingerprintSensor" /* componentId */,
"vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */,
"00000001" /* serialNumber */, "" /* softwareVersion */
"fingerprintSensor" /* componentId */,
"vendor/model/revision" /* hardwareVersion */,
"1.01" /* firmwareVersion */,
"00000001" /* serialNumber */,
"" /* softwareVersion */
),
ComponentInfoInternal(
"matchingAlgorithm" /* componentId */,
"" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */,
"vendor/version/revision" /* softwareVersion */
"matchingAlgorithm" /* componentId */,
"" /* hardwareVersion */,
"" /* firmwareVersion */,
"" /* serialNumber */,
"vendor/version/revision" /* softwareVersion */
)
)
)
return ids.map { id ->
FingerprintSensorPropertiesInternal(
id,
SensorProperties.STRENGTH_STRONG,
5 /* maxEnrollmentsPerUser */,
componentInfo,
FingerprintSensorProperties.TYPE_REAR,
false /* resetLockoutRequiresHardwareAuthToken */
id,
SensorProperties.STRENGTH_STRONG,
5 /* maxEnrollmentsPerUser */,
componentInfo,
FingerprintSensorProperties.TYPE_REAR,
false /* resetLockoutRequiresHardwareAuthToken */
)
}
}
@@ -111,28 +115,53 @@ internal fun fingerprintSensorPropertiesInternal(
internal fun faceSensorPropertiesInternal(
ids: List<Int> = listOf(1)
): List<FaceSensorPropertiesInternal> {
val componentInfo = listOf(
val componentInfo =
listOf(
ComponentInfoInternal(
"faceSensor" /* componentId */,
"vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */,
"00000001" /* serialNumber */, "" /* softwareVersion */
"faceSensor" /* componentId */,
"vendor/model/revision" /* hardwareVersion */,
"1.01" /* firmwareVersion */,
"00000001" /* serialNumber */,
"" /* softwareVersion */
),
ComponentInfoInternal(
"matchingAlgorithm" /* componentId */,
"" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */,
"vendor/version/revision" /* softwareVersion */
"matchingAlgorithm" /* componentId */,
"" /* hardwareVersion */,
"" /* firmwareVersion */,
"" /* serialNumber */,
"vendor/version/revision" /* softwareVersion */
)
)
)
return ids.map { id ->
FaceSensorPropertiesInternal(
id,
SensorProperties.STRENGTH_STRONG,
2 /* maxEnrollmentsPerUser */,
componentInfo,
FaceSensorProperties.TYPE_RGB,
true /* supportsFaceDetection */,
true /* supportsSelfIllumination */,
false /* resetLockoutRequiresHardwareAuthToken */
id,
SensorProperties.STRENGTH_STRONG,
2 /* maxEnrollmentsPerUser */,
componentInfo,
FaceSensorProperties.TYPE_RGB,
true /* supportsFaceDetection */,
true /* supportsSelfIllumination */,
false /* resetLockoutRequiresHardwareAuthToken */
)
}
}
internal fun promptInfo(
title: String = "title",
subtitle: String = "sub",
description: String = "desc",
credentialTitle: String? = "cred title",
credentialSubtitle: String? = "cred sub",
credentialDescription: String? = "cred desc",
negativeButton: String = "neg",
): PromptInfo {
val info = PromptInfo()
info.title = title
info.subtitle = subtitle
info.description = description
credentialTitle?.let { info.deviceCredentialTitle = it }
credentialSubtitle?.let { info.deviceCredentialSubtitle = it }
credentialDescription?.let { info.deviceCredentialDescription = it }
info.negativeButtonText = negativeButton
return info
}

View File

@@ -0,0 +1,81 @@
package com.android.systemui.biometrics.data.repository
import android.hardware.biometrics.PromptInfo
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.biometrics.AuthController
import com.android.systemui.biometrics.data.model.PromptKind
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.mockito.withArgCaptor
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
@SmallTest
@RunWith(JUnit4::class)
class PromptRepositoryImplTest : SysuiTestCase() {
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
@Mock private lateinit var authController: AuthController
private lateinit var repository: PromptRepositoryImpl
@Before
fun setup() {
repository = PromptRepositoryImpl(authController)
}
@Test
fun isShowing() = runBlockingTest {
whenever(authController.isShowing).thenReturn(true)
val values = mutableListOf<Boolean>()
val job = launch { repository.isShowing.toList(values) }
assertThat(values).containsExactly(true)
withArgCaptor<AuthController.Callback> {
verify(authController).addCallback(capture())
value.onBiometricPromptShown()
assertThat(values).containsExactly(true, true)
value.onBiometricPromptDismissed()
assertThat(values).containsExactly(true, true, false).inOrder()
job.cancel()
verify(authController).removeCallback(eq(value))
}
}
@Test
fun setsAndUnsetsPrompt() = runBlockingTest {
val kind = PromptKind.PIN
val uid = 8
val challenge = 90L
val promptInfo = PromptInfo()
repository.setPrompt(promptInfo, uid, challenge, kind)
assertThat(repository.kind.value).isEqualTo(kind)
assertThat(repository.userId.value).isEqualTo(uid)
assertThat(repository.challenge.value).isEqualTo(challenge)
assertThat(repository.promptInfo.value).isSameInstanceAs(promptInfo)
repository.unsetPrompt()
assertThat(repository.promptInfo.value).isNull()
assertThat(repository.userId.value).isNull()
assertThat(repository.challenge.value).isNull()
}
}

View File

@@ -0,0 +1,216 @@
package com.android.systemui.biometrics.domain.interactor
import android.app.admin.DevicePolicyManager
import android.app.admin.DevicePolicyResourcesManager
import android.content.pm.UserInfo
import android.os.UserManager
import androidx.test.filters.SmallTest
import com.android.internal.widget.LockPatternUtils
import com.android.internal.widget.LockscreenCredential
import com.android.internal.widget.VerifyCredentialResponse
import com.android.systemui.SysuiTestCase
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
import com.android.systemui.biometrics.promptInfo
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
private const val USER_ID = 22
private const val OPERATION_ID = 100L
private const val MAX_ATTEMPTS = 5
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class CredentialInteractorImplTest : SysuiTestCase() {
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
@Mock private lateinit var lockPatternUtils: LockPatternUtils
@Mock private lateinit var userManager: UserManager
@Mock private lateinit var devicePolicyManager: DevicePolicyManager
@Mock private lateinit var devicePolicyResourcesManager: DevicePolicyResourcesManager
private val systemClock = FakeSystemClock()
private lateinit var interactor: CredentialInteractorImpl
@Before
fun setup() {
whenever(devicePolicyManager.resources).thenReturn(devicePolicyResourcesManager)
whenever(lockPatternUtils.getMaximumFailedPasswordsForWipe(anyInt()))
.thenReturn(MAX_ATTEMPTS)
whenever(userManager.getUserInfo(eq(USER_ID))).thenReturn(UserInfo(USER_ID, "", 0))
whenever(devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(eq(USER_ID)))
.thenReturn(USER_ID)
interactor =
CredentialInteractorImpl(
mContext,
lockPatternUtils,
userManager,
devicePolicyManager,
systemClock
)
}
@Test
fun testStealthMode() {
for (value in listOf(true, false, false, true)) {
whenever(lockPatternUtils.isVisiblePatternEnabled(eq(USER_ID))).thenReturn(value)
assertThat(interactor.isStealthModeActive(USER_ID)).isEqualTo(!value)
}
}
@Test
fun testCredentialOwner() {
for (value in listOf(12, 8, 4)) {
whenever(userManager.getCredentialOwnerProfile(eq(USER_ID))).thenReturn(value)
assertThat(interactor.getCredentialOwnerOrSelfId(USER_ID)).isEqualTo(value)
}
}
@Test fun pinCredentialWhenGood() = pinCredential(goodCredential())
@Test fun pinCredentialWhenBad() = pinCredential(badCredential())
@Test fun pinCredentialWhenBadAndThrottled() = pinCredential(badCredential(timeout = 5_000))
private fun pinCredential(result: VerifyCredentialResponse) = runTest {
val usedAttempts = 1
whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID)))
.thenReturn(usedAttempts)
whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt())).thenReturn(result)
whenever(lockPatternUtils.verifyGatekeeperPasswordHandle(anyLong(), anyLong(), eq(USER_ID)))
.thenReturn(result)
whenever(lockPatternUtils.setLockoutAttemptDeadline(anyInt(), anyInt())).thenAnswer {
systemClock.elapsedRealtime() + (it.arguments[1] as Int)
}
// wrap in an async block so the test can advance the clock if throttling credential
// checks prevents the method from returning
val statusList = mutableListOf<CredentialStatus>()
interactor
.verifyCredential(pinRequest(), LockscreenCredential.createPin("1234"))
.toList(statusList)
val last = statusList.removeLastOrNull()
if (result.isMatched) {
assertThat(statusList).isEmpty()
val successfulResult = last as? CredentialStatus.Success.Verified
assertThat(successfulResult).isNotNull()
assertThat(successfulResult!!.hat).isEqualTo(result.gatekeeperHAT)
verify(lockPatternUtils).userPresent(eq(USER_ID))
verify(lockPatternUtils)
.removeGatekeeperPasswordHandle(eq(result.gatekeeperPasswordHandle))
} else {
val failedResult = last as? CredentialStatus.Fail.Error
assertThat(failedResult).isNotNull()
assertThat(failedResult!!.remainingAttempts)
.isEqualTo(if (result.timeout > 0) null else MAX_ATTEMPTS - usedAttempts - 1)
assertThat(failedResult.urgentMessage).isNull()
if (result.timeout > 0) { // failed and throttled
// messages are in the throttled errors, so the final Error.error is empty
assertThat(failedResult.error).isEmpty()
assertThat(statusList).isNotEmpty()
assertThat(statusList.filterIsInstance(CredentialStatus.Fail.Throttled::class.java))
.hasSize(statusList.size)
verify(lockPatternUtils).setLockoutAttemptDeadline(eq(USER_ID), eq(result.timeout))
} else { // failed
assertThat(failedResult.error)
.matches(Regex("(.*)try again(.*)", RegexOption.IGNORE_CASE).toPattern())
assertThat(statusList).isEmpty()
verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID))
}
}
}
@Test
fun pinCredentialWhenBadAndFinalAttempt() = runTest {
whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt()))
.thenReturn(badCredential())
whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID)))
.thenReturn(MAX_ATTEMPTS - 2)
val statusList = mutableListOf<CredentialStatus>()
interactor
.verifyCredential(pinRequest(), LockscreenCredential.createPin("1234"))
.toList(statusList)
val result = statusList.removeLastOrNull() as? CredentialStatus.Fail.Error
assertThat(result).isNotNull()
assertThat(result!!.remainingAttempts).isEqualTo(1)
assertThat(result.urgentMessage).isNotEmpty()
assertThat(statusList).isEmpty()
verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID))
}
@Test
fun pinCredentialWhenBadAndNoMoreAttempts() = runTest {
whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt()))
.thenReturn(badCredential())
whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID)))
.thenReturn(MAX_ATTEMPTS - 1)
whenever(devicePolicyResourcesManager.getString(any(), any())).thenReturn("wipe")
val statusList = mutableListOf<CredentialStatus>()
interactor
.verifyCredential(pinRequest(), LockscreenCredential.createPin("1234"))
.toList(statusList)
val result = statusList.removeLastOrNull() as? CredentialStatus.Fail.Error
assertThat(result).isNotNull()
assertThat(result!!.remainingAttempts).isEqualTo(0)
assertThat(result.urgentMessage).isNotEmpty()
assertThat(statusList).isEmpty()
verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID))
}
}
private fun pinRequest(): BiometricPromptRequest.Credential.Pin =
BiometricPromptRequest.Credential.Pin(
promptInfo(),
BiometricUserInfo(USER_ID),
BiometricOperationInfo(OPERATION_ID)
)
private fun goodCredential(
passwordHandle: Long = 90,
hat: ByteArray = ByteArray(69),
): VerifyCredentialResponse =
VerifyCredentialResponse.Builder()
.setGatekeeperPasswordHandle(passwordHandle)
.setGatekeeperHAT(hat)
.build()
private fun badCredential(timeout: Int = 0): VerifyCredentialResponse =
if (timeout > 0) {
VerifyCredentialResponse.fromTimeout(timeout)
} else {
VerifyCredentialResponse.fromError()
}

View File

@@ -0,0 +1,270 @@
package com.android.systemui.biometrics.domain.interactor
import android.hardware.biometrics.PromptInfo
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.biometrics.Utils
import com.android.systemui.biometrics.data.repository.FakePromptRepository
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
import com.android.systemui.biometrics.promptInfo
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.mockito.junit.MockitoJUnit
private const val USER_ID = 22
private const val OPERATION_ID = 100L
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class PromptCredentialInteractorTest : SysuiTestCase() {
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
private val dispatcher = UnconfinedTestDispatcher()
private val biometricPromptRepository = FakePromptRepository()
private val credentialInteractor = FakeCredentialInteractor()
private lateinit var interactor: BiometricPromptCredentialInteractor
@Before
fun setup() {
interactor =
BiometricPromptCredentialInteractor(
dispatcher,
biometricPromptRepository,
credentialInteractor
)
}
@Test
fun testIsShowing() =
runTest(dispatcher) {
var showing = false
val job = launch { interactor.isShowing.collect { showing = it } }
biometricPromptRepository.setIsShowing(false)
assertThat(showing).isFalse()
biometricPromptRepository.setIsShowing(true)
assertThat(showing).isTrue()
job.cancel()
}
@Test
fun testShowError() =
runTest(dispatcher) {
var error: CredentialStatus.Fail? = null
val job = launch { interactor.verificationError.collect { error = it } }
for (msg in listOf("once", "again")) {
interactor.setVerificationError(error(msg))
assertThat(error).isEqualTo(error(msg))
}
interactor.resetVerificationError()
assertThat(error).isNull()
job.cancel()
}
@Test
fun nullWhenNoPromptInfo() =
runTest(dispatcher) {
var prompt: BiometricPromptRequest? = null
val job = launch { interactor.prompt.collect { prompt = it } }
assertThat(prompt).isNull()
job.cancel()
}
@Test fun usePinCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PIN)
@Test fun usePasswordCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PASSWORD)
@Test fun usePatternCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PATTERN)
private fun useCredentialForPrompt(kind: Int) =
runTest(dispatcher) {
val isStealth = false
credentialInteractor.stealthMode = isStealth
var prompt: BiometricPromptRequest? = null
val job = launch { interactor.prompt.collect { prompt = it } }
val title = "what a prompt"
val subtitle = "s"
val description = "something to see"
interactor.useCredentialsForAuthentication(
PromptInfo().also {
it.title = title
it.description = description
it.subtitle = subtitle
},
kind = kind,
userId = USER_ID,
challenge = OPERATION_ID
)
val p = prompt as? BiometricPromptRequest.Credential
assertThat(p).isNotNull()
assertThat(p!!.title).isEqualTo(title)
assertThat(p.subtitle).isEqualTo(subtitle)
assertThat(p.description).isEqualTo(description)
assertThat(p.userInfo).isEqualTo(BiometricUserInfo(USER_ID))
assertThat(p.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID))
assertThat(p)
.isInstanceOf(
when (kind) {
Utils.CREDENTIAL_PIN -> BiometricPromptRequest.Credential.Pin::class.java
Utils.CREDENTIAL_PASSWORD ->
BiometricPromptRequest.Credential.Password::class.java
Utils.CREDENTIAL_PATTERN ->
BiometricPromptRequest.Credential.Pattern::class.java
else -> throw Exception("wrong kind")
}
)
if (p is BiometricPromptRequest.Credential.Pattern) {
assertThat(p.stealthMode).isEqualTo(isStealth)
}
interactor.resetPrompt()
assertThat(prompt).isNull()
job.cancel()
}
@Test
fun checkCredential() =
runTest(dispatcher) {
val hat = ByteArray(4)
credentialInteractor.verifyCredentialResponse = { _ -> flowOf(verified(hat)) }
val errors = mutableListOf<CredentialStatus.Fail?>()
val job = launch { interactor.verificationError.toList(errors) }
val checked =
interactor.checkCredential(pinRequest(), text = "1234")
as? CredentialStatus.Success.Verified
assertThat(checked).isNotNull()
assertThat(checked!!.hat).isSameInstanceAs(hat)
assertThat(errors.map { it?.error }).containsExactly(null)
job.cancel()
}
@Test
fun checkCredentialWhenBad() =
runTest(dispatcher) {
val errorMessage = "bad"
val remainingAttempts = 12
credentialInteractor.verifyCredentialResponse = { _ ->
flowOf(error(errorMessage, remainingAttempts))
}
val errors = mutableListOf<CredentialStatus.Fail?>()
val job = launch { interactor.verificationError.toList(errors) }
val checked =
interactor.checkCredential(pinRequest(), text = "1234")
as? CredentialStatus.Fail.Error
assertThat(checked).isNotNull()
assertThat(checked!!.remainingAttempts).isEqualTo(remainingAttempts)
assertThat(checked.urgentMessage).isNull()
assertThat(errors.map { it?.error }).containsExactly(null, errorMessage).inOrder()
job.cancel()
}
@Test
fun checkCredentialWhenBadAndUrgentMessage() =
runTest(dispatcher) {
val error = "not so bad"
val urgentMessage = "really bad"
credentialInteractor.verifyCredentialResponse = { _ ->
flowOf(error(error, 10, urgentMessage))
}
val errors = mutableListOf<CredentialStatus.Fail?>()
val job = launch { interactor.verificationError.toList(errors) }
val checked =
interactor.checkCredential(pinRequest(), text = "1234")
as? CredentialStatus.Fail.Error
assertThat(checked).isNotNull()
assertThat(checked!!.urgentMessage).isEqualTo(urgentMessage)
assertThat(errors.map { it?.error }).containsExactly(null, error).inOrder()
assertThat(errors.last() as? CredentialStatus.Fail.Error)
.isEqualTo(error(error, 10, urgentMessage))
job.cancel()
}
@Test
fun checkCredentialWhenBadAndThrottled() =
runTest(dispatcher) {
val remainingAttempts = 3
val error = ":("
val urgentMessage = ":D"
credentialInteractor.verifyCredentialResponse = { _ ->
flow {
for (i in 1..3) {
emit(throttled("$i"))
delay(100)
}
emit(error(error, remainingAttempts, urgentMessage))
}
}
val errors = mutableListOf<CredentialStatus.Fail?>()
val job = launch { interactor.verificationError.toList(errors) }
val checked =
interactor.checkCredential(pinRequest(), text = "1234")
as? CredentialStatus.Fail.Error
assertThat(checked).isNotNull()
assertThat(checked!!.remainingAttempts).isEqualTo(remainingAttempts)
assertThat(checked.urgentMessage).isEqualTo(urgentMessage)
assertThat(errors.map { it?.error })
.containsExactly(null, "1", "2", "3", error)
.inOrder()
job.cancel()
}
}
private fun pinRequest(): BiometricPromptRequest.Credential.Pin =
BiometricPromptRequest.Credential.Pin(
promptInfo(),
BiometricUserInfo(USER_ID),
BiometricOperationInfo(OPERATION_ID)
)
private fun verified(hat: ByteArray) = CredentialStatus.Success.Verified(hat)
private fun throttled(error: String) = CredentialStatus.Fail.Throttled(error)
private fun error(error: String? = null, remaining: Int? = null, urgentMessage: String? = null) =
CredentialStatus.Fail.Error(error, remaining, urgentMessage)

View File

@@ -0,0 +1,92 @@
package com.android.systemui.biometrics.domain.model
import androidx.test.filters.SmallTest
import com.android.systemui.biometrics.promptInfo
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
private const val USER_ID = 2
private const val OPERATION_ID = 8L
@SmallTest
@RunWith(JUnit4::class)
class BiometricPromptRequestTest {
@Test
fun biometricRequestFromPromptInfo() {
val title = "what"
val subtitle = "a"
val description = "request"
val request =
BiometricPromptRequest.Biometric(
promptInfo(title = title, subtitle = subtitle, description = description),
BiometricUserInfo(USER_ID),
BiometricOperationInfo(OPERATION_ID)
)
assertThat(request.title).isEqualTo(title)
assertThat(request.subtitle).isEqualTo(subtitle)
assertThat(request.description).isEqualTo(description)
assertThat(request.userInfo).isEqualTo(BiometricUserInfo(USER_ID))
assertThat(request.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID))
}
@Test
fun credentialRequestFromPromptInfo() {
val title = "what"
val subtitle = "a"
val description = "request"
val stealth = true
val toCheck =
listOf(
BiometricPromptRequest.Credential.Pin(
promptInfo(
title = title,
subtitle = subtitle,
description = description,
credentialTitle = null,
credentialSubtitle = null,
credentialDescription = null
),
BiometricUserInfo(USER_ID),
BiometricOperationInfo(OPERATION_ID)
),
BiometricPromptRequest.Credential.Password(
promptInfo(
credentialTitle = title,
credentialSubtitle = subtitle,
credentialDescription = description
),
BiometricUserInfo(USER_ID),
BiometricOperationInfo(OPERATION_ID)
),
BiometricPromptRequest.Credential.Pattern(
promptInfo(
subtitle = subtitle,
description = description,
credentialTitle = title,
credentialSubtitle = null,
credentialDescription = null
),
BiometricUserInfo(USER_ID),
BiometricOperationInfo(OPERATION_ID),
stealth
)
)
for (request in toCheck) {
assertThat(request.title).isEqualTo(title)
assertThat(request.subtitle).isEqualTo(subtitle)
assertThat(request.description).isEqualTo(description)
assertThat(request.userInfo).isEqualTo(BiometricUserInfo(USER_ID))
assertThat(request.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID))
if (request is BiometricPromptRequest.Credential.Pattern) {
assertThat(request.stealthMode).isEqualTo(stealth)
}
}
}
}

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()
}
}

View File

@@ -0,0 +1,48 @@
package com.android.systemui.biometrics.data.repository
import android.hardware.biometrics.PromptInfo
import com.android.systemui.biometrics.data.model.PromptKind
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Fake implementation of [PromptRepository] for tests. */
class FakePromptRepository : PromptRepository {
private val _isShowing = MutableStateFlow(false)
override val isShowing = _isShowing.asStateFlow()
private val _promptInfo = MutableStateFlow<PromptInfo?>(null)
override val promptInfo = _promptInfo.asStateFlow()
private val _userId = MutableStateFlow<Int?>(null)
override val userId = _userId.asStateFlow()
private var _challenge = MutableStateFlow<Long?>(null)
override val challenge = _challenge.asStateFlow()
private val _kind = MutableStateFlow(PromptKind.ANY_BIOMETRIC)
override val kind = _kind.asStateFlow()
override fun setPrompt(
promptInfo: PromptInfo,
userId: Int,
gatekeeperChallenge: Long?,
kind: PromptKind
) {
_promptInfo.value = promptInfo
_userId.value = userId
_challenge.value = gatekeeperChallenge
_kind.value = kind
}
override fun unsetPrompt() {
_promptInfo.value = null
_userId.value = null
_challenge.value = null
_kind.value = PromptKind.ANY_BIOMETRIC
}
fun setIsShowing(showing: Boolean) {
_isShowing.value = showing
}
}

View File

@@ -0,0 +1,31 @@
package com.android.systemui.biometrics.domain.interactor
import com.android.internal.widget.LockscreenCredential
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/** Fake implementation of [CredentialInteractor] for tests. */
class FakeCredentialInteractor : CredentialInteractor {
/** Sets return value for [isStealthModeActive]. */
var stealthMode: Boolean = false
/** Sets return value for [getCredentialOwnerOrSelfId]. */
var credentialOwnerId: Int? = null
override fun isStealthModeActive(userId: Int): Boolean = stealthMode
override fun getCredentialOwnerOrSelfId(userId: Int): Int = credentialOwnerId ?: userId
override fun verifyCredential(
request: BiometricPromptRequest.Credential,
credential: LockscreenCredential,
): Flow<CredentialStatus> = verifyCredentialResponse(credential)
/** Sets the result value for [verifyCredential]. */
var verifyCredentialResponse: (credential: LockscreenCredential) -> Flow<CredentialStatus> =
{ _ ->
flowOf(CredentialStatus.Fail.Error("invalid"))
}
}