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")) + } +}