diff --git a/packages/SystemUI/res/layout-land/auth_credential_password_view.xml b/packages/SystemUI/res/layout-land/auth_credential_password_view.xml
index 3bcc37a478c98..e2ce34f5008e6 100644
--- a/packages/SystemUI/res/layout-land/auth_credential_password_view.xml
+++ b/packages/SystemUI/res/layout-land/auth_credential_password_view.xml
@@ -14,7 +14,7 @@
~ limitations under the License.
-->
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout-land/auth_credential_pattern_view.xml b/packages/SystemUI/res/layout-land/auth_credential_pattern_view.xml
index a3dd334bd667a..6e0e38b95ee50 100644
--- a/packages/SystemUI/res/layout-land/auth_credential_pattern_view.xml
+++ b/packages/SystemUI/res/layout-land/auth_credential_pattern_view.xml
@@ -14,7 +14,7 @@
~ limitations under the License.
-->
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/auth_credential_password_view.xml b/packages/SystemUI/res/layout/auth_credential_password_view.xml
index 774b335f913ed..021ebe6e7bffd 100644
--- a/packages/SystemUI/res/layout/auth_credential_password_view.xml
+++ b/packages/SystemUI/res/layout/auth_credential_password_view.xml
@@ -14,7 +14,7 @@
~ limitations under the License.
-->
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/auth_credential_pattern_view.xml b/packages/SystemUI/res/layout/auth_credential_pattern_view.xml
index 4af997017bba1..891c6af4b667f 100644
--- a/packages/SystemUI/res/layout/auth_credential_pattern_view.xml
+++ b/packages/SystemUI/res/layout/auth_credential_pattern_view.xml
@@ -14,7 +14,7 @@
~ limitations under the License.
-->
-
-
\ No newline at end of file
+
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java
index b50bfd7c24f92..f74c721bf1142 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java
@@ -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 mBiometricPromptInteractor;
+ private final Provider 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 biometricPromptInteractor,
+ @NonNull Provider 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 fpProps,
@@ -287,6 +335,8 @@ public class AuthContainerView extends LinearLayout
@NonNull UserManager userManager,
@NonNull LockPatternUtils lockPatternUtils,
@NonNull InteractionJankMonitor jankMonitor,
+ @NonNull Provider biometricPromptInteractor,
+ @NonNull Provider 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);
}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java
index 9c78df598857c..313ff41571559 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java
@@ -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 mUdfpsControllerFactory;
private final Provider mSidefpsControllerFactory;
+ // TODO: these should be migrated out once ready
+ @NonNull private final Provider mBiometricPromptInteractor;
+ @NonNull private final Provider 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 biometricPromptInteractor,
+ @NonNull Provider 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
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialPasswordView.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialPasswordView.java
deleted file mode 100644
index 76cd3f4c4f1d5..0000000000000
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialPasswordView.java
+++ /dev/null
@@ -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());
- }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialPatternView.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialPatternView.java
deleted file mode 100644
index f9e44a0c17243..0000000000000
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialPatternView.java
+++ /dev/null
@@ -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 pattern) {
-
- }
-
- @Override
- public void onPatternDetected(List 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));
- }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialView.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialView.java
deleted file mode 100644
index fa623d146756f..0000000000000
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthCredentialView.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthPanelController.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthPanelController.java
index f1e42e0c54548..5c616f005d4d9 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthPanelController.java
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthPanelController.java
@@ -177,11 +177,11 @@ public class AuthPanelController extends ViewOutlineProvider {
}
}
- int getContainerWidth() {
+ public int getContainerWidth() {
return mContainerWidth;
}
- int getContainerHeight() {
+ public int getContainerHeight() {
return mContainerHeight;
}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt b/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt
index b5d81f2539161..7c0c3b710e664 100644
--- a/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt
@@ -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
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/data/model/PromptKind.kt b/packages/SystemUI/src/com/android/systemui/biometrics/data/model/PromptKind.kt
new file mode 100644
index 0000000000000..e82646f0d8617
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/data/model/PromptKind.kt
@@ -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,
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/PromptRepository.kt b/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/PromptRepository.kt
new file mode 100644
index 0000000000000..92a13cfe538b3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/data/repository/PromptRepository.kt
@@ -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
+
+ /** The app-specific details to show in the prompt. */
+ val promptInfo: StateFlow
+
+ /** The user that the prompt is for. */
+ val userId: StateFlow
+
+ /** The gatekeeper challenge, if one is associated with this prompt. */
+ val challenge: StateFlow
+
+ /** The kind of credential to use (biometric, pin, pattern, etc.). */
+ val kind: StateFlow
+
+ /** 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 = 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 = MutableStateFlow(null)
+ override val promptInfo = _promptInfo.asStateFlow()
+
+ private val _challenge: MutableStateFlow = MutableStateFlow(null)
+ override val challenge: StateFlow = _challenge.asStateFlow()
+
+ private val _userId: MutableStateFlow = MutableStateFlow(null)
+ override val userId = _userId.asStateFlow()
+
+ private val _kind: MutableStateFlow = 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"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractor.kt
new file mode 100644
index 0000000000000..1f1a1b5c83bd5
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractor.kt
@@ -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
+}
+
+/** 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 = 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)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialStatus.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialStatus.kt
new file mode 100644
index 0000000000000..40b76121f2374
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialStatus.kt
@@ -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
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractor.kt
new file mode 100644
index 0000000000000..6362c2f627d3c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractor.kt
@@ -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 = biometricPromptRepository.isShowing
+
+ /** Metadata about the current credential prompt, including app-supplied preferences. */
+ val prompt: Flow =
+ 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(null)
+ val verificationError: Flow = _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? = 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
+ }
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricOperationInfo.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricOperationInfo.kt
new file mode 100644
index 0000000000000..c619b12361c45
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricOperationInfo.kt
@@ -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)
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequest.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequest.kt
new file mode 100644
index 0000000000000..5ee0381db6304
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequest.kt
@@ -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)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricUserInfo.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricUserInfo.kt
new file mode 100644
index 0000000000000..08da04d276064
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricUserInfo.kt
@@ -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,
+)
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialPasswordView.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialPasswordView.kt
new file mode 100644
index 0000000000000..bcc0575651e43
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialPasswordView.kt
@@ -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
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialPatternView.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialPatternView.kt
new file mode 100644
index 0000000000000..75331f0838517
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialPatternView.kt
@@ -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)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialView.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialView.kt
new file mode 100644
index 0000000000000..b7c6a4566108d
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/CredentialView.kt
@@ -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,
+ )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialPasswordViewBinder.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialPasswordViewBinder.kt
new file mode 100644
index 0000000000000..c619648a314cc
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialPasswordViewBinder.kt
@@ -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
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialPatternViewBinder.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialPatternViewBinder.kt
new file mode 100644
index 0000000000000..4765551df3f07
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialPatternViewBinder.kt
@@ -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) -> Unit
+) : LockPatternView.OnPatternListener {
+ override fun onPatternCellAdded(pattern: List) {}
+ override fun onPatternCleared() {}
+ override fun onPatternStart() {}
+ override fun onPatternDetected(pattern: List) {
+ onDetected(pattern)
+ }
+}
+
+private fun List.isPatternLongEnough(): Boolean =
+ size < LockPatternUtils.MIN_PATTERN_REGISTER_FAIL
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialViewBinder.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialViewBinder.kt
new file mode 100644
index 0000000000000..fcc9487569720
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/binder/CredentialViewBinder.kt
@@ -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()
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModel.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModel.kt
new file mode 100644
index 0000000000000..84bbceb38fa76
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModel.kt
@@ -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 =
+ credentialInteractor.prompt.filterIsInstance().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 =
+ 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 =
+ credentialInteractor.prompt.map {
+ when (it) {
+ is BiometricPromptRequest.Credential.Pattern -> it.stealthMode
+ else -> false
+ }
+ }
+
+ private val _animateContents: MutableStateFlow = MutableStateFlow(true)
+ /** If this view should be animated on transitions. */
+ val animateContents = _animateContents.asStateFlow()
+
+ /** Error messages to show the user. */
+ val errorMessage: Flow =
+ 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 = MutableSharedFlow()
+ /** Results of [checkPatternCredential]. A non-null attestation is supplied on success. */
+ val validatedAttestation: Flow = _validatedAttestation.asSharedFlow()
+
+ private val _remainingAttempts: MutableStateFlow =
+ MutableStateFlow(RemainingAttempts())
+ /** If set, the number of remaining attempts before the user must stop. */
+ val remainingAttempts: Flow = _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, 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 Context.asBadCredentialErrorMessage(
+ clazz: KClass
+): 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
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/HeaderViewModel.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/HeaderViewModel.kt
new file mode 100644
index 0000000000000..ba23f1cfa22d6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/HeaderViewModel.kt
@@ -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
+}
diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/RemainingAttempts.kt b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/RemainingAttempts.kt
new file mode 100644
index 0000000000000..0f221734cb44f
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/biometrics/ui/viewmodel/RemainingAttempts.kt
@@ -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 = "")
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt
index d1107c6129775..45b8ce1ce2476 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthContainerViewTest.kt
@@ -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())
) {
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java
index 88a806d93243e..4dd46edd09125 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java
@@ -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 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);
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt
index 8820c164cba4b..1379a0eeebdd3 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt
@@ -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 = listOf(0)
): List {
- 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 = listOf(1)
): List {
- 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
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/PromptRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/PromptRepositoryImplTest.kt
new file mode 100644
index 0000000000000..2d5614c15173f
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/data/repository/PromptRepositoryImplTest.kt
@@ -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()
+ val job = launch { repository.isShowing.toList(values) }
+ assertThat(values).containsExactly(true)
+
+ withArgCaptor {
+ 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()
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt
new file mode 100644
index 0000000000000..97d3e688ed80b
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt
@@ -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()
+ 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()
+ 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()
+ 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()
+ }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt
new file mode 100644
index 0000000000000..dbcbf415221ef
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt
@@ -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()
+ 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()
+ 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()
+ 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()
+ 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)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt
new file mode 100644
index 0000000000000..2eeff9fcdd8a0
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt
new file mode 100644
index 0000000000000..d73cdfc4249f5
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/ui/viewmodel/CredentialViewModelTest.kt
@@ -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()
+ 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()
+ val remainingAttempts = mutableListOf()
+ 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()
+ val remainingAttempts = mutableListOf()
+ 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()
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakePromptRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakePromptRepository.kt
new file mode 100644
index 0000000000000..96658c61109dd
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/data/repository/FakePromptRepository.kt
@@ -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(null)
+ override val promptInfo = _promptInfo.asStateFlow()
+
+ private val _userId = MutableStateFlow(null)
+ override val userId = _userId.asStateFlow()
+
+ private var _challenge = MutableStateFlow(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
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/domain/interactor/FakeCredentialInteractor.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/domain/interactor/FakeCredentialInteractor.kt
new file mode 100644
index 0000000000000..fbe291ebaf5d1
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/domain/interactor/FakeCredentialInteractor.kt
@@ -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 = verifyCredentialResponse(credential)
+
+ /** Sets the result value for [verifyCredential]. */
+ var verifyCredentialResponse: (credential: LockscreenCredential) -> Flow =
+ { _ ->
+ flowOf(CredentialStatus.Fail.Error("invalid"))
+ }
+}