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 0a3da88d68394..428fd6809bdea 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: @@ -683,6 +689,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, @@ -706,6 +714,9 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks, mUdfpsEnrolledForUser = new SparseBooleanArray(); mVibratorHelper = vibrator; + mBiometricPromptInteractor = biometricPromptInteractor; + mCredentialViewModelProvider = credentialViewModelProvider; + mOrientationListener = new BiometricDisplayListener( context, mDisplayManager, @@ -1204,7 +1215,8 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks, .setMultiSensorConfig(multiSensorConfig) .setScaleFactorProvider(() -> getScaleFactor()) .build(bgExecutor, sensorIds, mFpProps, mFaceProps, wakefulnessLifecycle, - userManager, lockPatternUtils, mInteractionJankMonitor); + userManager, lockPatternUtils, mInteractionJankMonitor, + mBiometricPromptInteractor, mCredentialViewModelProvider); } @Override 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/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 8e45067c8e13b..54cfd25a9d97a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java @@ -87,6 +87,8 @@ import com.android.internal.R; import com.android.internal.jank.InteractionJankMonitor; import com.android.internal.widget.LockPatternUtils; import com.android.systemui.SysuiTestCase; +import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor; +import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel; import com.android.systemui.keyguard.WakefulnessLifecycle; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.statusbar.CommandQueue; @@ -163,6 +165,11 @@ public class AuthControllerTest extends SysuiTestCase { private UdfpsLogger mUdfpsLogger; @Mock private InteractionJankMonitor mInteractionJankMonitor; + @Mock + private BiometricPromptCredentialInteractor mBiometricPromptCredentialInteractor; + @Mock + private CredentialViewModel mCredentialViewModel; + @Captor private ArgumentCaptor mFpAuthenticatorsRegisteredCaptor; @Captor @@ -981,6 +988,7 @@ public class AuthControllerTest extends SysuiTestCase { fingerprintManager, faceManager, udfpsControllerFactory, sidefpsControllerFactory, mDisplayManager, mWakefulnessLifecycle, mUserManager, mLockPatternUtils, mUdfpsLogger, statusBarStateController, + () -> mBiometricPromptCredentialInteractor, () -> mCredentialViewModel, mInteractionJankMonitor, mHandler, mBackgroundExecutor, vibratorHelper); } 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() + } +}