Merge changes from topic "bio-refactor-example"
* changes: Refactor biometric prompt credential screens - ui layer. Refactor biometric prompt credential screens - domain layer Refactor biometric prompt credential screen - data layer.
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<com.android.systemui.biometrics.AuthCredentialPasswordView
|
||||
<com.android.systemui.biometrics.ui.CredentialPasswordView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@@ -86,4 +86,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.android.systemui.biometrics.AuthCredentialPasswordView>
|
||||
</com.android.systemui.biometrics.ui.CredentialPasswordView>
|
||||
@@ -14,7 +14,7 @@
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<com.android.systemui.biometrics.AuthCredentialPatternView
|
||||
<com.android.systemui.biometrics.ui.CredentialPatternView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@@ -83,4 +83,4 @@
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</com.android.systemui.biometrics.AuthCredentialPatternView>
|
||||
</com.android.systemui.biometrics.ui.CredentialPatternView>
|
||||
@@ -14,7 +14,7 @@
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<com.android.systemui.biometrics.AuthCredentialPasswordView
|
||||
<com.android.systemui.biometrics.ui.CredentialPasswordView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@@ -83,4 +83,4 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.android.systemui.biometrics.AuthCredentialPasswordView>
|
||||
</com.android.systemui.biometrics.ui.CredentialPasswordView>
|
||||
@@ -14,7 +14,7 @@
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<com.android.systemui.biometrics.AuthCredentialPatternView
|
||||
<com.android.systemui.biometrics.ui.CredentialPatternView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@@ -78,4 +78,4 @@
|
||||
android:layout_gravity="center_horizontal|bottom"/>
|
||||
</FrameLayout>
|
||||
|
||||
</com.android.systemui.biometrics.AuthCredentialPatternView>
|
||||
</com.android.systemui.biometrics.ui.CredentialPatternView>
|
||||
|
||||
@@ -26,6 +26,7 @@ import android.annotation.DurationMillisLong;
|
||||
import android.annotation.IntDef;
|
||||
import android.annotation.NonNull;
|
||||
import android.annotation.Nullable;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.hardware.biometrics.BiometricAuthenticator.Modality;
|
||||
@@ -63,6 +64,9 @@ import com.android.internal.widget.LockPatternUtils;
|
||||
import com.android.systemui.R;
|
||||
import com.android.systemui.animation.Interpolators;
|
||||
import com.android.systemui.biometrics.AuthController.ScaleFactorProvider;
|
||||
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor;
|
||||
import com.android.systemui.biometrics.ui.CredentialView;
|
||||
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel;
|
||||
import com.android.systemui.dagger.qualifiers.Background;
|
||||
import com.android.systemui.keyguard.WakefulnessLifecycle;
|
||||
import com.android.systemui.util.concurrency.DelayableExecutor;
|
||||
@@ -74,11 +78,13 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.inject.Provider;
|
||||
|
||||
/**
|
||||
* Top level container/controller for the BiometricPrompt UI.
|
||||
*/
|
||||
public class AuthContainerView extends LinearLayout
|
||||
implements AuthDialog, WakefulnessLifecycle.Observer {
|
||||
implements AuthDialog, WakefulnessLifecycle.Observer, CredentialView.Host {
|
||||
|
||||
private static final String TAG = "AuthContainerView";
|
||||
|
||||
@@ -112,15 +118,18 @@ public class AuthContainerView extends LinearLayout
|
||||
private final IBinder mWindowToken = new Binder();
|
||||
private final WindowManager mWindowManager;
|
||||
private final Interpolator mLinearOutSlowIn;
|
||||
private final CredentialCallback mCredentialCallback;
|
||||
private final LockPatternUtils mLockPatternUtils;
|
||||
private final WakefulnessLifecycle mWakefulnessLifecycle;
|
||||
private final InteractionJankMonitor mInteractionJankMonitor;
|
||||
|
||||
// TODO: these should be migrated out once ready
|
||||
private final Provider<BiometricPromptCredentialInteractor> mBiometricPromptInteractor;
|
||||
private final Provider<CredentialViewModel> mCredentialViewModelProvider;
|
||||
|
||||
@VisibleForTesting final BiometricCallback mBiometricCallback;
|
||||
|
||||
@Nullable private AuthBiometricView mBiometricView;
|
||||
@Nullable private AuthCredentialView mCredentialView;
|
||||
@Nullable private View mCredentialView;
|
||||
private final AuthPanelController mPanelController;
|
||||
private final FrameLayout mFrameLayout;
|
||||
private final ImageView mBackgroundView;
|
||||
@@ -229,11 +238,13 @@ public class AuthContainerView extends LinearLayout
|
||||
@NonNull WakefulnessLifecycle wakefulnessLifecycle,
|
||||
@NonNull UserManager userManager,
|
||||
@NonNull LockPatternUtils lockPatternUtils,
|
||||
@NonNull InteractionJankMonitor jankMonitor) {
|
||||
@NonNull InteractionJankMonitor jankMonitor,
|
||||
@NonNull Provider<BiometricPromptCredentialInteractor> biometricPromptInteractor,
|
||||
@NonNull Provider<CredentialViewModel> credentialViewModelProvider) {
|
||||
mConfig.mSensorIds = sensorIds;
|
||||
return new AuthContainerView(mConfig, fpProps, faceProps, wakefulnessLifecycle,
|
||||
userManager, lockPatternUtils, jankMonitor, new Handler(Looper.getMainLooper()),
|
||||
bgExecutor);
|
||||
userManager, lockPatternUtils, jankMonitor, biometricPromptInteractor,
|
||||
credentialViewModelProvider, new Handler(Looper.getMainLooper()), bgExecutor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,14 +282,51 @@ public class AuthContainerView extends LinearLayout
|
||||
}
|
||||
}
|
||||
|
||||
final class CredentialCallback implements AuthCredentialView.Callback {
|
||||
@Override
|
||||
public void onCredentialMatched(byte[] attestation) {
|
||||
mCredentialAttestation = attestation;
|
||||
animateAway(AuthDialogCallback.DISMISSED_CREDENTIAL_AUTHENTICATED);
|
||||
@Override
|
||||
public void onCredentialMatched(@NonNull byte[] attestation) {
|
||||
mCredentialAttestation = attestation;
|
||||
animateAway(AuthDialogCallback.DISMISSED_CREDENTIAL_AUTHENTICATED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCredentialAborted() {
|
||||
sendEarlyUserCanceled();
|
||||
animateAway(AuthDialogCallback.DISMISSED_USER_CANCELED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCredentialAttemptsRemaining(int remaining, @NonNull String messageBody) {
|
||||
// Only show dialog if <=1 attempts are left before wiping.
|
||||
if (remaining == 1) {
|
||||
showLastAttemptBeforeWipeDialog(messageBody);
|
||||
} else if (remaining <= 0) {
|
||||
showNowWipingDialog(messageBody);
|
||||
}
|
||||
}
|
||||
|
||||
private void showLastAttemptBeforeWipeDialog(@NonNull String messageBody) {
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
|
||||
.setTitle(R.string.biometric_dialog_last_attempt_before_wipe_dialog_title)
|
||||
.setMessage(messageBody)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.create();
|
||||
alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL);
|
||||
alertDialog.show();
|
||||
}
|
||||
|
||||
private void showNowWipingDialog(@NonNull String messageBody) {
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(mContext)
|
||||
.setMessage(messageBody)
|
||||
.setPositiveButton(
|
||||
com.android.settingslib.R.string.failed_attempts_now_wiping_dialog_dismiss,
|
||||
null /* OnClickListener */)
|
||||
.setOnDismissListener(
|
||||
dialog -> animateAway(AuthDialogCallback.DISMISSED_ERROR))
|
||||
.create();
|
||||
alertDialog.getWindow().setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL);
|
||||
alertDialog.show();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
AuthContainerView(Config config,
|
||||
@Nullable List<FingerprintSensorPropertiesInternal> fpProps,
|
||||
@@ -287,6 +335,8 @@ public class AuthContainerView extends LinearLayout
|
||||
@NonNull UserManager userManager,
|
||||
@NonNull LockPatternUtils lockPatternUtils,
|
||||
@NonNull InteractionJankMonitor jankMonitor,
|
||||
@NonNull Provider<BiometricPromptCredentialInteractor> biometricPromptInteractor,
|
||||
@NonNull Provider<CredentialViewModel> credentialViewModelProvider,
|
||||
@NonNull Handler mainHandler,
|
||||
@NonNull @Background DelayableExecutor bgExecutor) {
|
||||
super(config.mContext);
|
||||
@@ -302,7 +352,6 @@ public class AuthContainerView extends LinearLayout
|
||||
.getDimension(R.dimen.biometric_dialog_animation_translation_offset);
|
||||
mLinearOutSlowIn = Interpolators.LINEAR_OUT_SLOW_IN;
|
||||
mBiometricCallback = new BiometricCallback();
|
||||
mCredentialCallback = new CredentialCallback();
|
||||
|
||||
final LayoutInflater layoutInflater = LayoutInflater.from(mContext);
|
||||
mFrameLayout = (FrameLayout) layoutInflater.inflate(
|
||||
@@ -314,6 +363,8 @@ public class AuthContainerView extends LinearLayout
|
||||
mPanelController = new AuthPanelController(mContext, mPanelView);
|
||||
mBackgroundExecutor = bgExecutor;
|
||||
mInteractionJankMonitor = jankMonitor;
|
||||
mBiometricPromptInteractor = biometricPromptInteractor;
|
||||
mCredentialViewModelProvider = credentialViewModelProvider;
|
||||
|
||||
// Inflate biometric view only if necessary.
|
||||
if (Utils.isBiometricAllowed(mConfig.mPromptInfo)) {
|
||||
@@ -404,12 +455,12 @@ public class AuthContainerView extends LinearLayout
|
||||
|
||||
switch (credentialType) {
|
||||
case Utils.CREDENTIAL_PATTERN:
|
||||
mCredentialView = (AuthCredentialView) factory.inflate(
|
||||
mCredentialView = factory.inflate(
|
||||
R.layout.auth_credential_pattern_view, null, false);
|
||||
break;
|
||||
case Utils.CREDENTIAL_PIN:
|
||||
case Utils.CREDENTIAL_PASSWORD:
|
||||
mCredentialView = (AuthCredentialView) factory.inflate(
|
||||
mCredentialView = factory.inflate(
|
||||
R.layout.auth_credential_password_view, null, false);
|
||||
break;
|
||||
default:
|
||||
@@ -422,16 +473,12 @@ public class AuthContainerView extends LinearLayout
|
||||
mBackgroundView.setOnClickListener(null);
|
||||
mBackgroundView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
|
||||
mCredentialView.setContainerView(this);
|
||||
mCredentialView.setUserId(mConfig.mUserId);
|
||||
mCredentialView.setOperationId(mConfig.mOperationId);
|
||||
mCredentialView.setEffectiveUserId(mEffectiveUserId);
|
||||
mCredentialView.setCredentialType(credentialType);
|
||||
mCredentialView.setCallback(mCredentialCallback);
|
||||
mCredentialView.setPromptInfo(mConfig.mPromptInfo);
|
||||
mCredentialView.setPanelController(mPanelController, animatePanel);
|
||||
mCredentialView.setShouldAnimateContents(animateContents);
|
||||
mCredentialView.setBackgroundExecutor(mBackgroundExecutor);
|
||||
mBiometricPromptInteractor.get().useCredentialsForAuthentication(
|
||||
mConfig.mPromptInfo, credentialType, mConfig.mUserId, mConfig.mOperationId);
|
||||
final CredentialViewModel vm = mCredentialViewModelProvider.get();
|
||||
vm.setAnimateContents(animateContents);
|
||||
((CredentialView) mCredentialView).init(vm, this, mPanelController, animatePanel);
|
||||
|
||||
mFrameLayout.addView(mCredentialView);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ import com.android.internal.jank.InteractionJankMonitor;
|
||||
import com.android.internal.os.SomeArgs;
|
||||
import com.android.internal.widget.LockPatternUtils;
|
||||
import com.android.systemui.CoreStartable;
|
||||
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor;
|
||||
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel;
|
||||
import com.android.systemui.dagger.SysUISingleton;
|
||||
import com.android.systemui.dagger.qualifiers.Background;
|
||||
import com.android.systemui.dagger.qualifiers.Main;
|
||||
@@ -122,6 +124,10 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
|
||||
private final Provider<UdfpsController> mUdfpsControllerFactory;
|
||||
private final Provider<SidefpsController> mSidefpsControllerFactory;
|
||||
|
||||
// TODO: these should be migrated out once ready
|
||||
@NonNull private final Provider<BiometricPromptCredentialInteractor> mBiometricPromptInteractor;
|
||||
@NonNull private final Provider<CredentialViewModel> mCredentialViewModelProvider;
|
||||
|
||||
private final Display mDisplay;
|
||||
private float mScaleFactor = 1f;
|
||||
// sensor locations without any resolution scaling nor rotation adjustments:
|
||||
@@ -693,6 +699,8 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
|
||||
@NonNull LockPatternUtils lockPatternUtils,
|
||||
@NonNull UdfpsLogger udfpsLogger,
|
||||
@NonNull StatusBarStateController statusBarStateController,
|
||||
@NonNull Provider<BiometricPromptCredentialInteractor> biometricPromptInteractor,
|
||||
@NonNull Provider<CredentialViewModel> credentialViewModelProvider,
|
||||
@NonNull InteractionJankMonitor jankMonitor,
|
||||
@Main Handler handler,
|
||||
@Background DelayableExecutor bgExecutor,
|
||||
@@ -717,6 +725,9 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
|
||||
mFaceEnrolledForUser = new SparseBooleanArray();
|
||||
mVibratorHelper = vibrator;
|
||||
|
||||
mBiometricPromptInteractor = biometricPromptInteractor;
|
||||
mCredentialViewModelProvider = credentialViewModelProvider;
|
||||
|
||||
mOrientationListener = new BiometricDisplayListener(
|
||||
context,
|
||||
mDisplayManager,
|
||||
@@ -1079,6 +1090,11 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
|
||||
return mUdfpsEnrolledForUser.get(userId);
|
||||
}
|
||||
|
||||
/** If BiometricPrompt is currently being shown to the user. */
|
||||
public boolean isShowing() {
|
||||
return mCurrentDialog != null;
|
||||
}
|
||||
|
||||
private void showDialog(SomeArgs args, boolean skipAnimation, Bundle savedState) {
|
||||
mCurrentDialogArgs = args;
|
||||
|
||||
@@ -1210,7 +1226,8 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
|
||||
.setMultiSensorConfig(multiSensorConfig)
|
||||
.setScaleFactorProvider(() -> getScaleFactor())
|
||||
.build(bgExecutor, sensorIds, mFpProps, mFaceProps, wakefulnessLifecycle,
|
||||
userManager, lockPatternUtils, mInteractionJankMonitor);
|
||||
userManager, lockPatternUtils, mInteractionJankMonitor,
|
||||
mBiometricPromptInteractor, mCredentialViewModelProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2019 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.android.systemui.biometrics;
|
||||
|
||||
import android.annotation.NonNull;
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import com.android.internal.widget.LockPatternChecker;
|
||||
import com.android.internal.widget.LockPatternUtils;
|
||||
import com.android.internal.widget.LockPatternView;
|
||||
import com.android.internal.widget.LockscreenCredential;
|
||||
import com.android.internal.widget.VerifyCredentialResponse;
|
||||
import com.android.systemui.R;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pattern UI
|
||||
*/
|
||||
public class AuthCredentialPatternView extends AuthCredentialView {
|
||||
|
||||
private LockPatternView mLockPatternView;
|
||||
|
||||
private class UnlockPatternListener implements LockPatternView.OnPatternListener {
|
||||
|
||||
@Override
|
||||
public void onPatternStart() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPatternCleared() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPatternCellAdded(List<LockPatternView.Cell> pattern) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPatternDetected(List<LockPatternView.Cell> pattern) {
|
||||
if (mPendingLockCheck != null) {
|
||||
mPendingLockCheck.cancel(false);
|
||||
}
|
||||
|
||||
mLockPatternView.setEnabled(false);
|
||||
|
||||
if (pattern.size() < LockPatternUtils.MIN_PATTERN_REGISTER_FAIL) {
|
||||
// Pattern size is less than the minimum, do not count it as a failed attempt.
|
||||
onPatternVerified(VerifyCredentialResponse.ERROR, 0 /* timeoutMs */);
|
||||
return;
|
||||
}
|
||||
|
||||
try (LockscreenCredential credential = LockscreenCredential.createPattern(pattern)) {
|
||||
// Request LockSettingsService to return the Gatekeeper Password in the
|
||||
// VerifyCredentialResponse so that we can request a Gatekeeper HAT with the
|
||||
// Gatekeeper Password and operationId.
|
||||
mPendingLockCheck = LockPatternChecker.verifyCredential(
|
||||
mLockPatternUtils,
|
||||
credential,
|
||||
mEffectiveUserId,
|
||||
LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE,
|
||||
this::onPatternVerified);
|
||||
}
|
||||
}
|
||||
|
||||
private void onPatternVerified(@NonNull VerifyCredentialResponse response, int timeoutMs) {
|
||||
AuthCredentialPatternView.this.onCredentialVerified(response, timeoutMs);
|
||||
if (timeoutMs > 0) {
|
||||
mLockPatternView.setEnabled(false);
|
||||
} else {
|
||||
mLockPatternView.setEnabled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onErrorTimeoutFinish() {
|
||||
super.onErrorTimeoutFinish();
|
||||
// select to enable marquee unless a screen reader is enabled
|
||||
mLockPatternView.setEnabled(!mAccessibilityManager.isEnabled()
|
||||
|| !mAccessibilityManager.isTouchExplorationEnabled());
|
||||
}
|
||||
|
||||
public AuthCredentialPatternView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
mLockPatternView = findViewById(R.id.lockPattern);
|
||||
mLockPatternView.setOnPatternListener(new UnlockPatternListener());
|
||||
mLockPatternView.setInStealthMode(
|
||||
!mLockPatternUtils.isVisiblePatternEnabled(mUserId));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -177,11 +177,11 @@ public class AuthPanelController extends ViewOutlineProvider {
|
||||
}
|
||||
}
|
||||
|
||||
int getContainerWidth() {
|
||||
public int getContainerWidth() {
|
||||
return mContainerWidth;
|
||||
}
|
||||
|
||||
int getContainerHeight() {
|
||||
public int getContainerHeight() {
|
||||
return mContainerHeight;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.android.systemui.biometrics.data.repository
|
||||
|
||||
import android.hardware.biometrics.PromptInfo
|
||||
import com.android.systemui.biometrics.AuthController
|
||||
import com.android.systemui.biometrics.data.model.PromptKind
|
||||
import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
|
||||
import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
|
||||
import com.android.systemui.dagger.SysUISingleton
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* A repository for the global state of BiometricPrompt.
|
||||
*
|
||||
* There is never more than one instance of the prompt at any given time.
|
||||
*/
|
||||
interface PromptRepository {
|
||||
|
||||
/** If the prompt is showing. */
|
||||
val isShowing: Flow<Boolean>
|
||||
|
||||
/** The app-specific details to show in the prompt. */
|
||||
val promptInfo: StateFlow<PromptInfo?>
|
||||
|
||||
/** The user that the prompt is for. */
|
||||
val userId: StateFlow<Int?>
|
||||
|
||||
/** The gatekeeper challenge, if one is associated with this prompt. */
|
||||
val challenge: StateFlow<Long?>
|
||||
|
||||
/** The kind of credential to use (biometric, pin, pattern, etc.). */
|
||||
val kind: StateFlow<PromptKind>
|
||||
|
||||
/** Update the prompt configuration, which should be set before [isShowing]. */
|
||||
fun setPrompt(
|
||||
promptInfo: PromptInfo,
|
||||
userId: Int,
|
||||
gatekeeperChallenge: Long?,
|
||||
kind: PromptKind = PromptKind.ANY_BIOMETRIC,
|
||||
)
|
||||
|
||||
/** Unset the prompt info. */
|
||||
fun unsetPrompt()
|
||||
}
|
||||
|
||||
@SysUISingleton
|
||||
class PromptRepositoryImpl @Inject constructor(private val authController: AuthController) :
|
||||
PromptRepository {
|
||||
|
||||
override val isShowing: Flow<Boolean> = conflatedCallbackFlow {
|
||||
val callback =
|
||||
object : AuthController.Callback {
|
||||
override fun onBiometricPromptShown() =
|
||||
trySendWithFailureLogging(true, TAG, "set isShowing")
|
||||
|
||||
override fun onBiometricPromptDismissed() =
|
||||
trySendWithFailureLogging(false, TAG, "unset isShowing")
|
||||
}
|
||||
authController.addCallback(callback)
|
||||
trySendWithFailureLogging(authController.isShowing, TAG, "update isShowing")
|
||||
awaitClose { authController.removeCallback(callback) }
|
||||
}
|
||||
|
||||
private val _promptInfo: MutableStateFlow<PromptInfo?> = MutableStateFlow(null)
|
||||
override val promptInfo = _promptInfo.asStateFlow()
|
||||
|
||||
private val _challenge: MutableStateFlow<Long?> = MutableStateFlow(null)
|
||||
override val challenge: StateFlow<Long?> = _challenge.asStateFlow()
|
||||
|
||||
private val _userId: MutableStateFlow<Int?> = MutableStateFlow(null)
|
||||
override val userId = _userId.asStateFlow()
|
||||
|
||||
private val _kind: MutableStateFlow<PromptKind> = MutableStateFlow(PromptKind.ANY_BIOMETRIC)
|
||||
override val kind = _kind.asStateFlow()
|
||||
|
||||
override fun setPrompt(
|
||||
promptInfo: PromptInfo,
|
||||
userId: Int,
|
||||
gatekeeperChallenge: Long?,
|
||||
kind: PromptKind,
|
||||
) {
|
||||
_kind.value = kind
|
||||
_userId.value = userId
|
||||
_challenge.value = gatekeeperChallenge
|
||||
_promptInfo.value = promptInfo
|
||||
}
|
||||
|
||||
override fun unsetPrompt() {
|
||||
_promptInfo.value = null
|
||||
_userId.value = null
|
||||
_challenge.value = null
|
||||
_kind.value = PromptKind.ANY_BIOMETRIC
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BiometricPromptRepository"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.android.systemui.biometrics.domain.interactor
|
||||
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.app.admin.DevicePolicyResources
|
||||
import android.content.Context
|
||||
import android.os.UserManager
|
||||
import com.android.internal.widget.LockPatternUtils
|
||||
import com.android.internal.widget.LockscreenCredential
|
||||
import com.android.internal.widget.VerifyCredentialResponse
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
|
||||
import com.android.systemui.dagger.qualifiers.Application
|
||||
import com.android.systemui.util.time.SystemClock
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* A wrapper for [LockPatternUtils] to verify PIN, pattern, or password credentials.
|
||||
*
|
||||
* This class also uses the [DevicePolicyManager] to generate appropriate error messages when policy
|
||||
* exceptions are raised (i.e. wipe device due to excessive failed attempts, etc.).
|
||||
*/
|
||||
interface CredentialInteractor {
|
||||
/** If the user's pattern credential should be hidden */
|
||||
fun isStealthModeActive(userId: Int): Boolean
|
||||
|
||||
/** Get the effective user id (profile owner, if one exists) */
|
||||
fun getCredentialOwnerOrSelfId(userId: Int): Int
|
||||
|
||||
/**
|
||||
* Verifies a credential and returns a stream of results.
|
||||
*
|
||||
* The final emitted value will either be a [CredentialStatus.Fail.Error] or a
|
||||
* [CredentialStatus.Success.Verified].
|
||||
*/
|
||||
fun verifyCredential(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
credential: LockscreenCredential,
|
||||
): Flow<CredentialStatus>
|
||||
}
|
||||
|
||||
/** Standard implementation of [CredentialInteractor]. */
|
||||
class CredentialInteractorImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@Application private val applicationContext: Context,
|
||||
private val lockPatternUtils: LockPatternUtils,
|
||||
private val userManager: UserManager,
|
||||
private val devicePolicyManager: DevicePolicyManager,
|
||||
private val systemClock: SystemClock,
|
||||
) : CredentialInteractor {
|
||||
|
||||
override fun isStealthModeActive(userId: Int): Boolean =
|
||||
!lockPatternUtils.isVisiblePatternEnabled(userId)
|
||||
|
||||
override fun getCredentialOwnerOrSelfId(userId: Int): Int =
|
||||
userManager.getCredentialOwnerProfile(userId)
|
||||
|
||||
override fun verifyCredential(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
credential: LockscreenCredential,
|
||||
): Flow<CredentialStatus> = flow {
|
||||
// Request LockSettingsService to return the Gatekeeper Password in the
|
||||
// VerifyCredentialResponse so that we can request a Gatekeeper HAT with the
|
||||
// Gatekeeper Password and operationId.
|
||||
val effectiveUserId = request.userInfo.deviceCredentialOwnerId
|
||||
val response =
|
||||
lockPatternUtils.verifyCredential(
|
||||
credential,
|
||||
effectiveUserId,
|
||||
LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE
|
||||
)
|
||||
|
||||
if (response.isMatched) {
|
||||
lockPatternUtils.userPresent(effectiveUserId)
|
||||
|
||||
// The response passed into this method contains the Gatekeeper
|
||||
// Password. We still have to request Gatekeeper to create a
|
||||
// Hardware Auth Token with the Gatekeeper Password and Challenge
|
||||
// (keystore operationId in this case)
|
||||
val pwHandle = response.gatekeeperPasswordHandle
|
||||
val gkResponse: VerifyCredentialResponse =
|
||||
lockPatternUtils.verifyGatekeeperPasswordHandle(
|
||||
pwHandle,
|
||||
request.operationInfo.gatekeeperChallenge,
|
||||
effectiveUserId
|
||||
)
|
||||
val hat = gkResponse.gatekeeperHAT
|
||||
lockPatternUtils.removeGatekeeperPasswordHandle(pwHandle)
|
||||
emit(CredentialStatus.Success.Verified(hat))
|
||||
} else if (response.timeout > 0) {
|
||||
// if requests are being throttled, update the error message every
|
||||
// second until the temporary lock has expired
|
||||
val deadline: Long =
|
||||
lockPatternUtils.setLockoutAttemptDeadline(effectiveUserId, response.timeout)
|
||||
val interval = LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS
|
||||
var remaining = deadline - systemClock.elapsedRealtime()
|
||||
while (remaining > 0) {
|
||||
emit(
|
||||
CredentialStatus.Fail.Throttled(
|
||||
applicationContext.getString(
|
||||
R.string.biometric_dialog_credential_too_many_attempts,
|
||||
remaining / 1000
|
||||
)
|
||||
)
|
||||
)
|
||||
delay(interval)
|
||||
remaining -= interval
|
||||
}
|
||||
emit(CredentialStatus.Fail.Error(""))
|
||||
} else { // bad request, but not throttled
|
||||
val numAttempts = lockPatternUtils.getCurrentFailedPasswordAttempts(effectiveUserId) + 1
|
||||
val maxAttempts = lockPatternUtils.getMaximumFailedPasswordsForWipe(effectiveUserId)
|
||||
if (maxAttempts <= 0 || numAttempts <= 0) {
|
||||
// use a generic message if there's no maximum number of attempts
|
||||
emit(CredentialStatus.Fail.Error())
|
||||
} else {
|
||||
val remainingAttempts = (maxAttempts - numAttempts).coerceAtLeast(0)
|
||||
emit(
|
||||
CredentialStatus.Fail.Error(
|
||||
applicationContext.getString(
|
||||
R.string.biometric_dialog_credential_attempts_before_wipe,
|
||||
numAttempts,
|
||||
maxAttempts
|
||||
),
|
||||
remainingAttempts,
|
||||
fetchFinalAttemptMessageOrNull(request, remainingAttempts)
|
||||
)
|
||||
)
|
||||
}
|
||||
lockPatternUtils.reportFailedPasswordAttempt(effectiveUserId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchFinalAttemptMessageOrNull(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
remainingAttempts: Int?,
|
||||
): String? =
|
||||
if (remainingAttempts != null && remainingAttempts <= 1) {
|
||||
applicationContext.getFinalAttemptMessageOrBlank(
|
||||
request,
|
||||
devicePolicyManager,
|
||||
userManager.getUserTypeForWipe(
|
||||
devicePolicyManager,
|
||||
request.userInfo.deviceCredentialOwnerId
|
||||
),
|
||||
remainingAttempts
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private enum class UserType {
|
||||
PRIMARY,
|
||||
MANAGED_PROFILE,
|
||||
SECONDARY,
|
||||
}
|
||||
|
||||
private fun UserManager.getUserTypeForWipe(
|
||||
devicePolicyManager: DevicePolicyManager,
|
||||
effectiveUserId: Int,
|
||||
): UserType {
|
||||
val userToBeWiped =
|
||||
getUserInfo(
|
||||
devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(effectiveUserId)
|
||||
)
|
||||
return when {
|
||||
userToBeWiped == null || userToBeWiped.isPrimary -> UserType.PRIMARY
|
||||
userToBeWiped.isManagedProfile -> UserType.MANAGED_PROFILE
|
||||
else -> UserType.SECONDARY
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.getFinalAttemptMessageOrBlank(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
devicePolicyManager: DevicePolicyManager,
|
||||
userType: UserType,
|
||||
remaining: Int,
|
||||
): String =
|
||||
when {
|
||||
remaining == 1 -> getLastAttemptBeforeWipeMessage(request, devicePolicyManager, userType)
|
||||
remaining <= 0 -> getNowWipingMessage(devicePolicyManager, userType)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun Context.getLastAttemptBeforeWipeMessage(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
devicePolicyManager: DevicePolicyManager,
|
||||
userType: UserType,
|
||||
): String =
|
||||
when (userType) {
|
||||
UserType.PRIMARY -> getLastAttemptBeforeWipeDeviceMessage(request)
|
||||
UserType.MANAGED_PROFILE ->
|
||||
getLastAttemptBeforeWipeProfileMessage(request, devicePolicyManager)
|
||||
UserType.SECONDARY -> getLastAttemptBeforeWipeUserMessage(request)
|
||||
}
|
||||
|
||||
private fun Context.getLastAttemptBeforeWipeDeviceMessage(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
): String {
|
||||
val id =
|
||||
when (request) {
|
||||
is BiometricPromptRequest.Credential.Pin ->
|
||||
R.string.biometric_dialog_last_pin_attempt_before_wipe_device
|
||||
is BiometricPromptRequest.Credential.Pattern ->
|
||||
R.string.biometric_dialog_last_pattern_attempt_before_wipe_device
|
||||
is BiometricPromptRequest.Credential.Password ->
|
||||
R.string.biometric_dialog_last_password_attempt_before_wipe_device
|
||||
}
|
||||
return getString(id)
|
||||
}
|
||||
|
||||
private fun Context.getLastAttemptBeforeWipeProfileMessage(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
devicePolicyManager: DevicePolicyManager,
|
||||
): String {
|
||||
val id =
|
||||
when (request) {
|
||||
is BiometricPromptRequest.Credential.Pin ->
|
||||
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PIN_LAST_ATTEMPT
|
||||
is BiometricPromptRequest.Credential.Pattern ->
|
||||
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PATTERN_LAST_ATTEMPT
|
||||
is BiometricPromptRequest.Credential.Password ->
|
||||
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PASSWORD_LAST_ATTEMPT
|
||||
}
|
||||
return devicePolicyManager.resources.getString(id) {
|
||||
// use fallback a string if not found
|
||||
val defaultId =
|
||||
when (request) {
|
||||
is BiometricPromptRequest.Credential.Pin ->
|
||||
R.string.biometric_dialog_last_pin_attempt_before_wipe_profile
|
||||
is BiometricPromptRequest.Credential.Pattern ->
|
||||
R.string.biometric_dialog_last_pattern_attempt_before_wipe_profile
|
||||
is BiometricPromptRequest.Credential.Password ->
|
||||
R.string.biometric_dialog_last_password_attempt_before_wipe_profile
|
||||
}
|
||||
getString(defaultId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.getLastAttemptBeforeWipeUserMessage(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
): String {
|
||||
val resId =
|
||||
when (request) {
|
||||
is BiometricPromptRequest.Credential.Pin ->
|
||||
R.string.biometric_dialog_last_pin_attempt_before_wipe_user
|
||||
is BiometricPromptRequest.Credential.Pattern ->
|
||||
R.string.biometric_dialog_last_pattern_attempt_before_wipe_user
|
||||
is BiometricPromptRequest.Credential.Password ->
|
||||
R.string.biometric_dialog_last_password_attempt_before_wipe_user
|
||||
}
|
||||
return getString(resId)
|
||||
}
|
||||
|
||||
private fun Context.getNowWipingMessage(
|
||||
devicePolicyManager: DevicePolicyManager,
|
||||
userType: UserType,
|
||||
): String {
|
||||
val id =
|
||||
when (userType) {
|
||||
UserType.MANAGED_PROFILE ->
|
||||
DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_LOCK_FAILED_ATTEMPTS
|
||||
else -> DevicePolicyResources.UNDEFINED
|
||||
}
|
||||
return devicePolicyManager.resources.getString(id) {
|
||||
// use fallback a string if not found
|
||||
val defaultId =
|
||||
when (userType) {
|
||||
UserType.PRIMARY ->
|
||||
com.android.settingslib.R.string.failed_attempts_now_wiping_device
|
||||
UserType.MANAGED_PROFILE ->
|
||||
com.android.settingslib.R.string.failed_attempts_now_wiping_profile
|
||||
UserType.SECONDARY ->
|
||||
com.android.settingslib.R.string.failed_attempts_now_wiping_user
|
||||
}
|
||||
getString(defaultId)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.android.systemui.biometrics.domain.interactor
|
||||
|
||||
import android.hardware.biometrics.PromptInfo
|
||||
import com.android.internal.widget.LockPatternView
|
||||
import com.android.internal.widget.LockscreenCredential
|
||||
import com.android.systemui.biometrics.Utils
|
||||
import com.android.systemui.biometrics.data.model.PromptKind
|
||||
import com.android.systemui.biometrics.data.repository.PromptRepository
|
||||
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
|
||||
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
|
||||
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
|
||||
import com.android.systemui.dagger.qualifiers.Background
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.lastOrNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Business logic for BiometricPrompt's CredentialViews, which primarily includes checking a users
|
||||
* PIN, pattern, or password credential instead of a biometric.
|
||||
*/
|
||||
class BiometricPromptCredentialInteractor
|
||||
@Inject
|
||||
constructor(
|
||||
@Background private val bgDispatcher: CoroutineDispatcher,
|
||||
private val biometricPromptRepository: PromptRepository,
|
||||
private val credentialInteractor: CredentialInteractor,
|
||||
) {
|
||||
/** If the prompt is currently showing. */
|
||||
val isShowing: Flow<Boolean> = biometricPromptRepository.isShowing
|
||||
|
||||
/** Metadata about the current credential prompt, including app-supplied preferences. */
|
||||
val prompt: Flow<BiometricPromptRequest?> =
|
||||
combine(
|
||||
biometricPromptRepository.promptInfo,
|
||||
biometricPromptRepository.challenge,
|
||||
biometricPromptRepository.userId,
|
||||
biometricPromptRepository.kind
|
||||
) { promptInfo, challenge, userId, kind ->
|
||||
if (promptInfo == null || userId == null || challenge == null) {
|
||||
return@combine null
|
||||
}
|
||||
|
||||
when (kind) {
|
||||
PromptKind.PIN ->
|
||||
BiometricPromptRequest.Credential.Pin(
|
||||
info = promptInfo,
|
||||
userInfo = userInfo(userId),
|
||||
operationInfo = operationInfo(challenge)
|
||||
)
|
||||
PromptKind.PATTERN ->
|
||||
BiometricPromptRequest.Credential.Pattern(
|
||||
info = promptInfo,
|
||||
userInfo = userInfo(userId),
|
||||
operationInfo = operationInfo(challenge),
|
||||
stealthMode = credentialInteractor.isStealthModeActive(userId)
|
||||
)
|
||||
PromptKind.PASSWORD ->
|
||||
BiometricPromptRequest.Credential.Password(
|
||||
info = promptInfo,
|
||||
userInfo = userInfo(userId),
|
||||
operationInfo = operationInfo(challenge)
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
private fun userInfo(userId: Int): BiometricUserInfo =
|
||||
BiometricUserInfo(
|
||||
userId = userId,
|
||||
deviceCredentialOwnerId = credentialInteractor.getCredentialOwnerOrSelfId(userId)
|
||||
)
|
||||
|
||||
private fun operationInfo(challenge: Long): BiometricOperationInfo =
|
||||
BiometricOperationInfo(gatekeeperChallenge = challenge)
|
||||
|
||||
/** Most recent error due to [verifyCredential]. */
|
||||
private val _verificationError = MutableStateFlow<CredentialStatus.Fail?>(null)
|
||||
val verificationError: Flow<CredentialStatus.Fail?> = _verificationError.asStateFlow()
|
||||
|
||||
/** Update the current request to use credential-based authentication instead of biometrics. */
|
||||
fun useCredentialsForAuthentication(
|
||||
promptInfo: PromptInfo,
|
||||
@Utils.CredentialType kind: Int,
|
||||
userId: Int,
|
||||
challenge: Long,
|
||||
) {
|
||||
biometricPromptRepository.setPrompt(
|
||||
promptInfo,
|
||||
userId,
|
||||
challenge,
|
||||
kind.asBiometricPromptCredential()
|
||||
)
|
||||
}
|
||||
|
||||
/** Unset the current authentication request. */
|
||||
fun resetPrompt() {
|
||||
biometricPromptRepository.unsetPrompt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a credential and return the attestation token (HAT) if successful.
|
||||
*
|
||||
* This method will not return if credential checks are being throttled until the throttling has
|
||||
* expired and the user can try again. It will periodically update the [verificationError] until
|
||||
* cancelled or the throttling has completed. If the request is not throttled, but unsuccessful,
|
||||
* the [verificationError] will be set and an optional
|
||||
* [CredentialStatus.Fail.Error.urgentMessage] message may be provided to indicate additional
|
||||
* hints to the user (i.e. device will be wiped on next failure, etc.).
|
||||
*
|
||||
* The check happens on the background dispatcher given in the constructor.
|
||||
*/
|
||||
suspend fun checkCredential(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
text: CharSequence? = null,
|
||||
pattern: List<LockPatternView.Cell>? = null,
|
||||
): CredentialStatus =
|
||||
withContext(bgDispatcher) {
|
||||
val credential =
|
||||
when (request) {
|
||||
is BiometricPromptRequest.Credential.Pin ->
|
||||
LockscreenCredential.createPinOrNone(text ?: "")
|
||||
is BiometricPromptRequest.Credential.Password ->
|
||||
LockscreenCredential.createPasswordOrNone(text ?: "")
|
||||
is BiometricPromptRequest.Credential.Pattern ->
|
||||
LockscreenCredential.createPattern(pattern ?: listOf())
|
||||
}
|
||||
|
||||
credential.use { c -> verifyCredential(request, c) }
|
||||
}
|
||||
|
||||
private suspend fun verifyCredential(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
credential: LockscreenCredential?
|
||||
): CredentialStatus {
|
||||
if (credential == null || credential.isNone) {
|
||||
return CredentialStatus.Fail.Error()
|
||||
}
|
||||
|
||||
val finalStatus =
|
||||
credentialInteractor
|
||||
.verifyCredential(request, credential)
|
||||
.onEach { status ->
|
||||
when (status) {
|
||||
is CredentialStatus.Success -> _verificationError.value = null
|
||||
is CredentialStatus.Fail -> _verificationError.value = status
|
||||
}
|
||||
}
|
||||
.lastOrNull()
|
||||
|
||||
return finalStatus ?: CredentialStatus.Fail.Error()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a user-visible error.
|
||||
*
|
||||
* Use this instead of calling [verifyCredential] when it is not necessary because the check
|
||||
* will obviously fail (i.e. too short, empty, etc.)
|
||||
*/
|
||||
fun setVerificationError(error: CredentialStatus.Fail.Error?) {
|
||||
if (error != null) {
|
||||
_verificationError.value = error
|
||||
} else {
|
||||
resetVerificationError()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the current error message, if any. */
|
||||
fun resetVerificationError() {
|
||||
_verificationError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(b/251476085): remove along with Utils.CredentialType
|
||||
/** Convert a [Utils.CredentialType] to the corresponding [PromptKind]. */
|
||||
private fun @receiver:Utils.CredentialType Int.asBiometricPromptCredential(): PromptKind =
|
||||
when (this) {
|
||||
Utils.CREDENTIAL_PIN -> PromptKind.PIN
|
||||
Utils.CREDENTIAL_PASSWORD -> PromptKind.PASSWORD
|
||||
Utils.CREDENTIAL_PATTERN -> PromptKind.PATTERN
|
||||
else -> PromptKind.ANY_BIOMETRIC
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.android.systemui.biometrics.ui.binder
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.android.internal.widget.LockPatternUtils
|
||||
import com.android.internal.widget.LockPatternView
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.biometrics.ui.CredentialPatternView
|
||||
import com.android.systemui.biometrics.ui.CredentialView
|
||||
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel
|
||||
import com.android.systemui.lifecycle.repeatWhenAttached
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Sub-binder for the [CredentialPatternView]. */
|
||||
object CredentialPatternViewBinder {
|
||||
|
||||
/** Bind the view. */
|
||||
fun bind(
|
||||
view: CredentialPatternView,
|
||||
host: CredentialView.Host,
|
||||
viewModel: CredentialViewModel,
|
||||
) {
|
||||
val lockPatternView: LockPatternView = view.requireViewById(R.id.lockPattern)
|
||||
|
||||
view.repeatWhenAttached {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
// observe credential validation attempts and submit/cancel buttons
|
||||
launch {
|
||||
viewModel.header.collect { header ->
|
||||
lockPatternView.setOnPatternListener(
|
||||
OnPatternDetectedListener { pattern ->
|
||||
if (pattern.isPatternLongEnough()) {
|
||||
// Pattern size is less than the minimum
|
||||
// do not count it as a failed attempt
|
||||
viewModel.showPatternTooShortError()
|
||||
} else {
|
||||
lockPatternView.isEnabled = false
|
||||
launch { viewModel.checkCredential(pattern, header) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
launch { viewModel.stealthMode.collect { lockPatternView.isInStealthMode = it } }
|
||||
|
||||
// dismiss on a valid credential check
|
||||
launch {
|
||||
viewModel.validatedAttestation.collect { attestation ->
|
||||
val matched = attestation != null
|
||||
lockPatternView.isEnabled = !matched
|
||||
if (matched) {
|
||||
host.onCredentialMatched(attestation!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class OnPatternDetectedListener(
|
||||
private val onDetected: (pattern: List<LockPatternView.Cell>) -> Unit
|
||||
) : LockPatternView.OnPatternListener {
|
||||
override fun onPatternCellAdded(pattern: List<LockPatternView.Cell>) {}
|
||||
override fun onPatternCleared() {}
|
||||
override fun onPatternStart() {}
|
||||
override fun onPatternDetected(pattern: List<LockPatternView.Cell>) {
|
||||
onDetected(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<LockPatternView.Cell>.isPatternLongEnough(): Boolean =
|
||||
size < LockPatternUtils.MIN_PATTERN_REGISTER_FAIL
|
||||
@@ -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()
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.android.systemui.biometrics.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.UserHandle
|
||||
import android.text.InputType
|
||||
import com.android.internal.widget.LockPatternView
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.biometrics.Utils
|
||||
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor
|
||||
import com.android.systemui.biometrics.domain.interactor.CredentialStatus
|
||||
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
|
||||
import com.android.systemui.dagger.qualifiers.Application
|
||||
import javax.inject.Inject
|
||||
import kotlin.reflect.KClass
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/** View-model for all CredentialViews within BiometricPrompt. */
|
||||
class CredentialViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@Application private val applicationContext: Context,
|
||||
private val credentialInteractor: BiometricPromptCredentialInteractor,
|
||||
) {
|
||||
|
||||
/** Top level information about the prompt. */
|
||||
val header: Flow<HeaderViewModel> =
|
||||
credentialInteractor.prompt.filterIsInstance<BiometricPromptRequest.Credential>().map {
|
||||
request ->
|
||||
BiometricPromptHeaderViewModelImpl(
|
||||
request,
|
||||
user = UserHandle.of(request.userInfo.userId),
|
||||
title = request.title,
|
||||
subtitle = request.subtitle,
|
||||
description = request.description,
|
||||
icon = applicationContext.asLockIcon(request.userInfo.deviceCredentialOwnerId),
|
||||
)
|
||||
}
|
||||
|
||||
/** Input flags for text based credential views */
|
||||
val inputFlags: Flow<Int?> =
|
||||
credentialInteractor.prompt.map {
|
||||
when (it) {
|
||||
is BiometricPromptRequest.Credential.Pin ->
|
||||
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** If stealth mode is active (hide user credential input). */
|
||||
val stealthMode: Flow<Boolean> =
|
||||
credentialInteractor.prompt.map {
|
||||
when (it) {
|
||||
is BiometricPromptRequest.Credential.Pattern -> it.stealthMode
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private val _animateContents: MutableStateFlow<Boolean> = MutableStateFlow(true)
|
||||
/** If this view should be animated on transitions. */
|
||||
val animateContents = _animateContents.asStateFlow()
|
||||
|
||||
/** Error messages to show the user. */
|
||||
val errorMessage: Flow<String> =
|
||||
combine(credentialInteractor.verificationError, credentialInteractor.prompt) { error, p ->
|
||||
when (error) {
|
||||
is CredentialStatus.Fail.Error -> error.error
|
||||
?: applicationContext.asBadCredentialErrorMessage(p)
|
||||
is CredentialStatus.Fail.Throttled -> error.error
|
||||
null -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private val _validatedAttestation: MutableSharedFlow<ByteArray?> = MutableSharedFlow()
|
||||
/** Results of [checkPatternCredential]. A non-null attestation is supplied on success. */
|
||||
val validatedAttestation: Flow<ByteArray?> = _validatedAttestation.asSharedFlow()
|
||||
|
||||
private val _remainingAttempts: MutableStateFlow<RemainingAttempts> =
|
||||
MutableStateFlow(RemainingAttempts())
|
||||
/** If set, the number of remaining attempts before the user must stop. */
|
||||
val remainingAttempts: Flow<RemainingAttempts> = _remainingAttempts.asStateFlow()
|
||||
|
||||
/** Enable transition animations. */
|
||||
fun setAnimateContents(animate: Boolean) {
|
||||
_animateContents.value = animate
|
||||
}
|
||||
|
||||
/** Show an error message to inform the user the pattern is too short to attempt validation. */
|
||||
fun showPatternTooShortError() {
|
||||
credentialInteractor.setVerificationError(
|
||||
CredentialStatus.Fail.Error(
|
||||
applicationContext.asBadCredentialErrorMessage(
|
||||
BiometricPromptRequest.Credential.Pattern::class
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Reset the error message to an empty string. */
|
||||
fun resetErrorMessage() {
|
||||
credentialInteractor.resetVerificationError()
|
||||
}
|
||||
|
||||
/** Check a PIN or password and update [validatedAttestation] or [remainingAttempts]. */
|
||||
suspend fun checkCredential(text: CharSequence, header: HeaderViewModel) =
|
||||
checkCredential(credentialInteractor.checkCredential(header.asRequest(), text = text))
|
||||
|
||||
/** Check a pattern and update [validatedAttestation] or [remainingAttempts]. */
|
||||
suspend fun checkCredential(pattern: List<LockPatternView.Cell>, header: HeaderViewModel) =
|
||||
checkCredential(credentialInteractor.checkCredential(header.asRequest(), pattern = pattern))
|
||||
|
||||
private suspend fun checkCredential(result: CredentialStatus) {
|
||||
when (result) {
|
||||
is CredentialStatus.Success.Verified -> {
|
||||
_validatedAttestation.emit(result.hat)
|
||||
_remainingAttempts.value = RemainingAttempts()
|
||||
}
|
||||
is CredentialStatus.Fail.Error -> {
|
||||
_validatedAttestation.emit(null)
|
||||
_remainingAttempts.value =
|
||||
RemainingAttempts(result.remainingAttempts, result.urgentMessage ?: "")
|
||||
}
|
||||
is CredentialStatus.Fail.Throttled -> {
|
||||
// required for completeness, but a throttled error cannot be the final result
|
||||
_validatedAttestation.emit(null)
|
||||
_remainingAttempts.value = RemainingAttempts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.asBadCredentialErrorMessage(prompt: BiometricPromptRequest?): String =
|
||||
asBadCredentialErrorMessage(
|
||||
if (prompt != null) prompt::class else BiometricPromptRequest.Credential.Password::class
|
||||
)
|
||||
|
||||
private fun <T : BiometricPromptRequest> Context.asBadCredentialErrorMessage(
|
||||
clazz: KClass<T>
|
||||
): String =
|
||||
getString(
|
||||
when (clazz) {
|
||||
BiometricPromptRequest.Credential.Pin::class -> R.string.biometric_dialog_wrong_pin
|
||||
BiometricPromptRequest.Credential.Password::class ->
|
||||
R.string.biometric_dialog_wrong_password
|
||||
BiometricPromptRequest.Credential.Pattern::class ->
|
||||
R.string.biometric_dialog_wrong_pattern
|
||||
else -> R.string.biometric_dialog_wrong_password
|
||||
}
|
||||
)
|
||||
|
||||
private fun Context.asLockIcon(userId: Int): Drawable {
|
||||
val id =
|
||||
if (Utils.isManagedProfile(this, userId)) {
|
||||
R.drawable.auth_dialog_enterprise
|
||||
} else {
|
||||
R.drawable.auth_dialog_lock
|
||||
}
|
||||
return resources.getDrawable(id, theme)
|
||||
}
|
||||
|
||||
private class BiometricPromptHeaderViewModelImpl(
|
||||
val request: BiometricPromptRequest.Credential,
|
||||
override val user: UserHandle,
|
||||
override val title: String,
|
||||
override val subtitle: String,
|
||||
override val description: String,
|
||||
override val icon: Drawable,
|
||||
) : HeaderViewModel
|
||||
|
||||
private fun HeaderViewModel.asRequest(): BiometricPromptRequest.Credential =
|
||||
(this as BiometricPromptHeaderViewModelImpl).request
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = "")
|
||||
@@ -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())
|
||||
) {
|
||||
|
||||
@@ -89,6 +89,8 @@ import com.android.internal.R;
|
||||
import com.android.internal.jank.InteractionJankMonitor;
|
||||
import com.android.internal.widget.LockPatternUtils;
|
||||
import com.android.systemui.SysuiTestCase;
|
||||
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor;
|
||||
import com.android.systemui.biometrics.ui.viewmodel.CredentialViewModel;
|
||||
import com.android.systemui.keyguard.WakefulnessLifecycle;
|
||||
import com.android.systemui.plugins.statusbar.StatusBarStateController;
|
||||
import com.android.systemui.statusbar.CommandQueue;
|
||||
@@ -165,6 +167,11 @@ public class AuthControllerTest extends SysuiTestCase {
|
||||
private UdfpsLogger mUdfpsLogger;
|
||||
@Mock
|
||||
private InteractionJankMonitor mInteractionJankMonitor;
|
||||
@Mock
|
||||
private BiometricPromptCredentialInteractor mBiometricPromptCredentialInteractor;
|
||||
@Mock
|
||||
private CredentialViewModel mCredentialViewModel;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<IFingerprintAuthenticatorsRegisteredCallback> mFpAuthenticatorsRegisteredCaptor;
|
||||
@Captor
|
||||
@@ -1009,6 +1016,7 @@ public class AuthControllerTest extends SysuiTestCase {
|
||||
fingerprintManager, faceManager, udfpsControllerFactory,
|
||||
sidefpsControllerFactory, mDisplayManager, mWakefulnessLifecycle,
|
||||
mUserManager, mLockPatternUtils, mUdfpsLogger, statusBarStateController,
|
||||
() -> mBiometricPromptCredentialInteractor, () -> mCredentialViewModel,
|
||||
mInteractionJankMonitor, mHandler, mBackgroundExecutor, vibratorHelper);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,11 @@ import android.hardware.biometrics.BiometricManager
|
||||
import android.hardware.biometrics.ComponentInfoInternal
|
||||
import android.hardware.biometrics.PromptInfo
|
||||
import android.hardware.biometrics.SensorProperties
|
||||
import android.hardware.face.FaceSensorPropertiesInternal
|
||||
import android.hardware.face.FaceSensorProperties
|
||||
import android.hardware.face.FaceSensorPropertiesInternal
|
||||
import android.hardware.fingerprint.FingerprintSensorProperties
|
||||
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
|
||||
import android.os.Bundle
|
||||
|
||||
import android.testing.ViewUtils
|
||||
import android.view.LayoutInflater
|
||||
|
||||
@@ -83,26 +82,31 @@ internal fun AuthBiometricView?.destroyDialog() {
|
||||
internal fun fingerprintSensorPropertiesInternal(
|
||||
ids: List<Int> = listOf(0)
|
||||
): List<FingerprintSensorPropertiesInternal> {
|
||||
val componentInfo = listOf(
|
||||
val componentInfo =
|
||||
listOf(
|
||||
ComponentInfoInternal(
|
||||
"fingerprintSensor" /* componentId */,
|
||||
"vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */,
|
||||
"00000001" /* serialNumber */, "" /* softwareVersion */
|
||||
"fingerprintSensor" /* componentId */,
|
||||
"vendor/model/revision" /* hardwareVersion */,
|
||||
"1.01" /* firmwareVersion */,
|
||||
"00000001" /* serialNumber */,
|
||||
"" /* softwareVersion */
|
||||
),
|
||||
ComponentInfoInternal(
|
||||
"matchingAlgorithm" /* componentId */,
|
||||
"" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */,
|
||||
"vendor/version/revision" /* softwareVersion */
|
||||
"matchingAlgorithm" /* componentId */,
|
||||
"" /* hardwareVersion */,
|
||||
"" /* firmwareVersion */,
|
||||
"" /* serialNumber */,
|
||||
"vendor/version/revision" /* softwareVersion */
|
||||
)
|
||||
)
|
||||
)
|
||||
return ids.map { id ->
|
||||
FingerprintSensorPropertiesInternal(
|
||||
id,
|
||||
SensorProperties.STRENGTH_STRONG,
|
||||
5 /* maxEnrollmentsPerUser */,
|
||||
componentInfo,
|
||||
FingerprintSensorProperties.TYPE_REAR,
|
||||
false /* resetLockoutRequiresHardwareAuthToken */
|
||||
id,
|
||||
SensorProperties.STRENGTH_STRONG,
|
||||
5 /* maxEnrollmentsPerUser */,
|
||||
componentInfo,
|
||||
FingerprintSensorProperties.TYPE_REAR,
|
||||
false /* resetLockoutRequiresHardwareAuthToken */
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -111,28 +115,53 @@ internal fun fingerprintSensorPropertiesInternal(
|
||||
internal fun faceSensorPropertiesInternal(
|
||||
ids: List<Int> = listOf(1)
|
||||
): List<FaceSensorPropertiesInternal> {
|
||||
val componentInfo = listOf(
|
||||
val componentInfo =
|
||||
listOf(
|
||||
ComponentInfoInternal(
|
||||
"faceSensor" /* componentId */,
|
||||
"vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */,
|
||||
"00000001" /* serialNumber */, "" /* softwareVersion */
|
||||
"faceSensor" /* componentId */,
|
||||
"vendor/model/revision" /* hardwareVersion */,
|
||||
"1.01" /* firmwareVersion */,
|
||||
"00000001" /* serialNumber */,
|
||||
"" /* softwareVersion */
|
||||
),
|
||||
ComponentInfoInternal(
|
||||
"matchingAlgorithm" /* componentId */,
|
||||
"" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */,
|
||||
"vendor/version/revision" /* softwareVersion */
|
||||
"matchingAlgorithm" /* componentId */,
|
||||
"" /* hardwareVersion */,
|
||||
"" /* firmwareVersion */,
|
||||
"" /* serialNumber */,
|
||||
"vendor/version/revision" /* softwareVersion */
|
||||
)
|
||||
)
|
||||
)
|
||||
return ids.map { id ->
|
||||
FaceSensorPropertiesInternal(
|
||||
id,
|
||||
SensorProperties.STRENGTH_STRONG,
|
||||
2 /* maxEnrollmentsPerUser */,
|
||||
componentInfo,
|
||||
FaceSensorProperties.TYPE_RGB,
|
||||
true /* supportsFaceDetection */,
|
||||
true /* supportsSelfIllumination */,
|
||||
false /* resetLockoutRequiresHardwareAuthToken */
|
||||
id,
|
||||
SensorProperties.STRENGTH_STRONG,
|
||||
2 /* maxEnrollmentsPerUser */,
|
||||
componentInfo,
|
||||
FaceSensorProperties.TYPE_RGB,
|
||||
true /* supportsFaceDetection */,
|
||||
true /* supportsSelfIllumination */,
|
||||
false /* resetLockoutRequiresHardwareAuthToken */
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun promptInfo(
|
||||
title: String = "title",
|
||||
subtitle: String = "sub",
|
||||
description: String = "desc",
|
||||
credentialTitle: String? = "cred title",
|
||||
credentialSubtitle: String? = "cred sub",
|
||||
credentialDescription: String? = "cred desc",
|
||||
negativeButton: String = "neg",
|
||||
): PromptInfo {
|
||||
val info = PromptInfo()
|
||||
info.title = title
|
||||
info.subtitle = subtitle
|
||||
info.description = description
|
||||
credentialTitle?.let { info.deviceCredentialTitle = it }
|
||||
credentialSubtitle?.let { info.deviceCredentialSubtitle = it }
|
||||
credentialDescription?.let { info.deviceCredentialDescription = it }
|
||||
info.negativeButtonText = negativeButton
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.android.systemui.biometrics.data.repository
|
||||
|
||||
import android.hardware.biometrics.PromptInfo
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.biometrics.AuthController
|
||||
import com.android.systemui.biometrics.data.model.PromptKind
|
||||
import com.android.systemui.util.mockito.whenever
|
||||
import com.android.systemui.util.mockito.withArgCaptor
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
import org.mockito.ArgumentMatchers.eq
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.junit.MockitoJUnit
|
||||
|
||||
@SmallTest
|
||||
@RunWith(JUnit4::class)
|
||||
class PromptRepositoryImplTest : SysuiTestCase() {
|
||||
|
||||
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
|
||||
|
||||
@Mock private lateinit var authController: AuthController
|
||||
|
||||
private lateinit var repository: PromptRepositoryImpl
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
repository = PromptRepositoryImpl(authController)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isShowing() = runBlockingTest {
|
||||
whenever(authController.isShowing).thenReturn(true)
|
||||
|
||||
val values = mutableListOf<Boolean>()
|
||||
val job = launch { repository.isShowing.toList(values) }
|
||||
assertThat(values).containsExactly(true)
|
||||
|
||||
withArgCaptor<AuthController.Callback> {
|
||||
verify(authController).addCallback(capture())
|
||||
|
||||
value.onBiometricPromptShown()
|
||||
assertThat(values).containsExactly(true, true)
|
||||
|
||||
value.onBiometricPromptDismissed()
|
||||
assertThat(values).containsExactly(true, true, false).inOrder()
|
||||
|
||||
job.cancel()
|
||||
verify(authController).removeCallback(eq(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setsAndUnsetsPrompt() = runBlockingTest {
|
||||
val kind = PromptKind.PIN
|
||||
val uid = 8
|
||||
val challenge = 90L
|
||||
val promptInfo = PromptInfo()
|
||||
|
||||
repository.setPrompt(promptInfo, uid, challenge, kind)
|
||||
|
||||
assertThat(repository.kind.value).isEqualTo(kind)
|
||||
assertThat(repository.userId.value).isEqualTo(uid)
|
||||
assertThat(repository.challenge.value).isEqualTo(challenge)
|
||||
assertThat(repository.promptInfo.value).isSameInstanceAs(promptInfo)
|
||||
|
||||
repository.unsetPrompt()
|
||||
|
||||
assertThat(repository.promptInfo.value).isNull()
|
||||
assertThat(repository.userId.value).isNull()
|
||||
assertThat(repository.challenge.value).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.android.systemui.biometrics.domain.interactor
|
||||
|
||||
import android.app.admin.DevicePolicyManager
|
||||
import android.app.admin.DevicePolicyResourcesManager
|
||||
import android.content.pm.UserInfo
|
||||
import android.os.UserManager
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.internal.widget.LockPatternUtils
|
||||
import com.android.internal.widget.LockscreenCredential
|
||||
import com.android.internal.widget.VerifyCredentialResponse
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
|
||||
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
|
||||
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
|
||||
import com.android.systemui.biometrics.promptInfo
|
||||
import com.android.systemui.util.mockito.any
|
||||
import com.android.systemui.util.mockito.eq
|
||||
import com.android.systemui.util.mockito.whenever
|
||||
import com.android.systemui.util.time.FakeSystemClock
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
import org.mockito.ArgumentMatchers.anyInt
|
||||
import org.mockito.ArgumentMatchers.anyLong
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.junit.MockitoJUnit
|
||||
|
||||
private const val USER_ID = 22
|
||||
private const val OPERATION_ID = 100L
|
||||
private const val MAX_ATTEMPTS = 5
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@SmallTest
|
||||
@RunWith(JUnit4::class)
|
||||
class CredentialInteractorImplTest : SysuiTestCase() {
|
||||
|
||||
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
|
||||
|
||||
@Mock private lateinit var lockPatternUtils: LockPatternUtils
|
||||
@Mock private lateinit var userManager: UserManager
|
||||
@Mock private lateinit var devicePolicyManager: DevicePolicyManager
|
||||
@Mock private lateinit var devicePolicyResourcesManager: DevicePolicyResourcesManager
|
||||
|
||||
private val systemClock = FakeSystemClock()
|
||||
|
||||
private lateinit var interactor: CredentialInteractorImpl
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
whenever(devicePolicyManager.resources).thenReturn(devicePolicyResourcesManager)
|
||||
whenever(lockPatternUtils.getMaximumFailedPasswordsForWipe(anyInt()))
|
||||
.thenReturn(MAX_ATTEMPTS)
|
||||
whenever(userManager.getUserInfo(eq(USER_ID))).thenReturn(UserInfo(USER_ID, "", 0))
|
||||
whenever(devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(eq(USER_ID)))
|
||||
.thenReturn(USER_ID)
|
||||
|
||||
interactor =
|
||||
CredentialInteractorImpl(
|
||||
mContext,
|
||||
lockPatternUtils,
|
||||
userManager,
|
||||
devicePolicyManager,
|
||||
systemClock
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStealthMode() {
|
||||
for (value in listOf(true, false, false, true)) {
|
||||
whenever(lockPatternUtils.isVisiblePatternEnabled(eq(USER_ID))).thenReturn(value)
|
||||
|
||||
assertThat(interactor.isStealthModeActive(USER_ID)).isEqualTo(!value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCredentialOwner() {
|
||||
for (value in listOf(12, 8, 4)) {
|
||||
whenever(userManager.getCredentialOwnerProfile(eq(USER_ID))).thenReturn(value)
|
||||
|
||||
assertThat(interactor.getCredentialOwnerOrSelfId(USER_ID)).isEqualTo(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun pinCredentialWhenGood() = pinCredential(goodCredential())
|
||||
|
||||
@Test fun pinCredentialWhenBad() = pinCredential(badCredential())
|
||||
|
||||
@Test fun pinCredentialWhenBadAndThrottled() = pinCredential(badCredential(timeout = 5_000))
|
||||
|
||||
private fun pinCredential(result: VerifyCredentialResponse) = runTest {
|
||||
val usedAttempts = 1
|
||||
whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID)))
|
||||
.thenReturn(usedAttempts)
|
||||
whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt())).thenReturn(result)
|
||||
whenever(lockPatternUtils.verifyGatekeeperPasswordHandle(anyLong(), anyLong(), eq(USER_ID)))
|
||||
.thenReturn(result)
|
||||
whenever(lockPatternUtils.setLockoutAttemptDeadline(anyInt(), anyInt())).thenAnswer {
|
||||
systemClock.elapsedRealtime() + (it.arguments[1] as Int)
|
||||
}
|
||||
|
||||
// wrap in an async block so the test can advance the clock if throttling credential
|
||||
// checks prevents the method from returning
|
||||
val statusList = mutableListOf<CredentialStatus>()
|
||||
interactor
|
||||
.verifyCredential(pinRequest(), LockscreenCredential.createPin("1234"))
|
||||
.toList(statusList)
|
||||
|
||||
val last = statusList.removeLastOrNull()
|
||||
if (result.isMatched) {
|
||||
assertThat(statusList).isEmpty()
|
||||
val successfulResult = last as? CredentialStatus.Success.Verified
|
||||
assertThat(successfulResult).isNotNull()
|
||||
assertThat(successfulResult!!.hat).isEqualTo(result.gatekeeperHAT)
|
||||
|
||||
verify(lockPatternUtils).userPresent(eq(USER_ID))
|
||||
verify(lockPatternUtils)
|
||||
.removeGatekeeperPasswordHandle(eq(result.gatekeeperPasswordHandle))
|
||||
} else {
|
||||
val failedResult = last as? CredentialStatus.Fail.Error
|
||||
assertThat(failedResult).isNotNull()
|
||||
assertThat(failedResult!!.remainingAttempts)
|
||||
.isEqualTo(if (result.timeout > 0) null else MAX_ATTEMPTS - usedAttempts - 1)
|
||||
assertThat(failedResult.urgentMessage).isNull()
|
||||
|
||||
if (result.timeout > 0) { // failed and throttled
|
||||
// messages are in the throttled errors, so the final Error.error is empty
|
||||
assertThat(failedResult.error).isEmpty()
|
||||
assertThat(statusList).isNotEmpty()
|
||||
assertThat(statusList.filterIsInstance(CredentialStatus.Fail.Throttled::class.java))
|
||||
.hasSize(statusList.size)
|
||||
|
||||
verify(lockPatternUtils).setLockoutAttemptDeadline(eq(USER_ID), eq(result.timeout))
|
||||
} else { // failed
|
||||
assertThat(failedResult.error)
|
||||
.matches(Regex("(.*)try again(.*)", RegexOption.IGNORE_CASE).toPattern())
|
||||
assertThat(statusList).isEmpty()
|
||||
|
||||
verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinCredentialWhenBadAndFinalAttempt() = runTest {
|
||||
whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt()))
|
||||
.thenReturn(badCredential())
|
||||
whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID)))
|
||||
.thenReturn(MAX_ATTEMPTS - 2)
|
||||
|
||||
val statusList = mutableListOf<CredentialStatus>()
|
||||
interactor
|
||||
.verifyCredential(pinRequest(), LockscreenCredential.createPin("1234"))
|
||||
.toList(statusList)
|
||||
|
||||
val result = statusList.removeLastOrNull() as? CredentialStatus.Fail.Error
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.remainingAttempts).isEqualTo(1)
|
||||
assertThat(result.urgentMessage).isNotEmpty()
|
||||
assertThat(statusList).isEmpty()
|
||||
|
||||
verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinCredentialWhenBadAndNoMoreAttempts() = runTest {
|
||||
whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt()))
|
||||
.thenReturn(badCredential())
|
||||
whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID)))
|
||||
.thenReturn(MAX_ATTEMPTS - 1)
|
||||
whenever(devicePolicyResourcesManager.getString(any(), any())).thenReturn("wipe")
|
||||
|
||||
val statusList = mutableListOf<CredentialStatus>()
|
||||
interactor
|
||||
.verifyCredential(pinRequest(), LockscreenCredential.createPin("1234"))
|
||||
.toList(statusList)
|
||||
|
||||
val result = statusList.removeLastOrNull() as? CredentialStatus.Fail.Error
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result!!.remainingAttempts).isEqualTo(0)
|
||||
assertThat(result.urgentMessage).isNotEmpty()
|
||||
assertThat(statusList).isEmpty()
|
||||
|
||||
verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID))
|
||||
}
|
||||
}
|
||||
|
||||
private fun pinRequest(): BiometricPromptRequest.Credential.Pin =
|
||||
BiometricPromptRequest.Credential.Pin(
|
||||
promptInfo(),
|
||||
BiometricUserInfo(USER_ID),
|
||||
BiometricOperationInfo(OPERATION_ID)
|
||||
)
|
||||
|
||||
private fun goodCredential(
|
||||
passwordHandle: Long = 90,
|
||||
hat: ByteArray = ByteArray(69),
|
||||
): VerifyCredentialResponse =
|
||||
VerifyCredentialResponse.Builder()
|
||||
.setGatekeeperPasswordHandle(passwordHandle)
|
||||
.setGatekeeperHAT(hat)
|
||||
.build()
|
||||
|
||||
private fun badCredential(timeout: Int = 0): VerifyCredentialResponse =
|
||||
if (timeout > 0) {
|
||||
VerifyCredentialResponse.fromTimeout(timeout)
|
||||
} else {
|
||||
VerifyCredentialResponse.fromError()
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.android.systemui.biometrics.domain.interactor
|
||||
|
||||
import android.hardware.biometrics.PromptInfo
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.biometrics.Utils
|
||||
import com.android.systemui.biometrics.data.repository.FakePromptRepository
|
||||
import com.android.systemui.biometrics.domain.model.BiometricOperationInfo
|
||||
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
|
||||
import com.android.systemui.biometrics.domain.model.BiometricUserInfo
|
||||
import com.android.systemui.biometrics.promptInfo
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
import org.mockito.junit.MockitoJUnit
|
||||
|
||||
private const val USER_ID = 22
|
||||
private const val OPERATION_ID = 100L
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@SmallTest
|
||||
@RunWith(JUnit4::class)
|
||||
class PromptCredentialInteractorTest : SysuiTestCase() {
|
||||
|
||||
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
private val biometricPromptRepository = FakePromptRepository()
|
||||
private val credentialInteractor = FakeCredentialInteractor()
|
||||
|
||||
private lateinit var interactor: BiometricPromptCredentialInteractor
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
interactor =
|
||||
BiometricPromptCredentialInteractor(
|
||||
dispatcher,
|
||||
biometricPromptRepository,
|
||||
credentialInteractor
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsShowing() =
|
||||
runTest(dispatcher) {
|
||||
var showing = false
|
||||
val job = launch { interactor.isShowing.collect { showing = it } }
|
||||
|
||||
biometricPromptRepository.setIsShowing(false)
|
||||
assertThat(showing).isFalse()
|
||||
|
||||
biometricPromptRepository.setIsShowing(true)
|
||||
assertThat(showing).isTrue()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShowError() =
|
||||
runTest(dispatcher) {
|
||||
var error: CredentialStatus.Fail? = null
|
||||
val job = launch { interactor.verificationError.collect { error = it } }
|
||||
|
||||
for (msg in listOf("once", "again")) {
|
||||
interactor.setVerificationError(error(msg))
|
||||
assertThat(error).isEqualTo(error(msg))
|
||||
}
|
||||
|
||||
interactor.resetVerificationError()
|
||||
assertThat(error).isNull()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullWhenNoPromptInfo() =
|
||||
runTest(dispatcher) {
|
||||
var prompt: BiometricPromptRequest? = null
|
||||
val job = launch { interactor.prompt.collect { prompt = it } }
|
||||
|
||||
assertThat(prompt).isNull()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test fun usePinCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PIN)
|
||||
|
||||
@Test fun usePasswordCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PASSWORD)
|
||||
|
||||
@Test fun usePatternCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PATTERN)
|
||||
|
||||
private fun useCredentialForPrompt(kind: Int) =
|
||||
runTest(dispatcher) {
|
||||
val isStealth = false
|
||||
credentialInteractor.stealthMode = isStealth
|
||||
|
||||
var prompt: BiometricPromptRequest? = null
|
||||
val job = launch { interactor.prompt.collect { prompt = it } }
|
||||
|
||||
val title = "what a prompt"
|
||||
val subtitle = "s"
|
||||
val description = "something to see"
|
||||
|
||||
interactor.useCredentialsForAuthentication(
|
||||
PromptInfo().also {
|
||||
it.title = title
|
||||
it.description = description
|
||||
it.subtitle = subtitle
|
||||
},
|
||||
kind = kind,
|
||||
userId = USER_ID,
|
||||
challenge = OPERATION_ID
|
||||
)
|
||||
|
||||
val p = prompt as? BiometricPromptRequest.Credential
|
||||
assertThat(p).isNotNull()
|
||||
assertThat(p!!.title).isEqualTo(title)
|
||||
assertThat(p.subtitle).isEqualTo(subtitle)
|
||||
assertThat(p.description).isEqualTo(description)
|
||||
assertThat(p.userInfo).isEqualTo(BiometricUserInfo(USER_ID))
|
||||
assertThat(p.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID))
|
||||
assertThat(p)
|
||||
.isInstanceOf(
|
||||
when (kind) {
|
||||
Utils.CREDENTIAL_PIN -> BiometricPromptRequest.Credential.Pin::class.java
|
||||
Utils.CREDENTIAL_PASSWORD ->
|
||||
BiometricPromptRequest.Credential.Password::class.java
|
||||
Utils.CREDENTIAL_PATTERN ->
|
||||
BiometricPromptRequest.Credential.Pattern::class.java
|
||||
else -> throw Exception("wrong kind")
|
||||
}
|
||||
)
|
||||
if (p is BiometricPromptRequest.Credential.Pattern) {
|
||||
assertThat(p.stealthMode).isEqualTo(isStealth)
|
||||
}
|
||||
|
||||
interactor.resetPrompt()
|
||||
|
||||
assertThat(prompt).isNull()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkCredential() =
|
||||
runTest(dispatcher) {
|
||||
val hat = ByteArray(4)
|
||||
credentialInteractor.verifyCredentialResponse = { _ -> flowOf(verified(hat)) }
|
||||
|
||||
val errors = mutableListOf<CredentialStatus.Fail?>()
|
||||
val job = launch { interactor.verificationError.toList(errors) }
|
||||
|
||||
val checked =
|
||||
interactor.checkCredential(pinRequest(), text = "1234")
|
||||
as? CredentialStatus.Success.Verified
|
||||
|
||||
assertThat(checked).isNotNull()
|
||||
assertThat(checked!!.hat).isSameInstanceAs(hat)
|
||||
assertThat(errors.map { it?.error }).containsExactly(null)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkCredentialWhenBad() =
|
||||
runTest(dispatcher) {
|
||||
val errorMessage = "bad"
|
||||
val remainingAttempts = 12
|
||||
credentialInteractor.verifyCredentialResponse = { _ ->
|
||||
flowOf(error(errorMessage, remainingAttempts))
|
||||
}
|
||||
|
||||
val errors = mutableListOf<CredentialStatus.Fail?>()
|
||||
val job = launch { interactor.verificationError.toList(errors) }
|
||||
|
||||
val checked =
|
||||
interactor.checkCredential(pinRequest(), text = "1234")
|
||||
as? CredentialStatus.Fail.Error
|
||||
|
||||
assertThat(checked).isNotNull()
|
||||
assertThat(checked!!.remainingAttempts).isEqualTo(remainingAttempts)
|
||||
assertThat(checked.urgentMessage).isNull()
|
||||
assertThat(errors.map { it?.error }).containsExactly(null, errorMessage).inOrder()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkCredentialWhenBadAndUrgentMessage() =
|
||||
runTest(dispatcher) {
|
||||
val error = "not so bad"
|
||||
val urgentMessage = "really bad"
|
||||
credentialInteractor.verifyCredentialResponse = { _ ->
|
||||
flowOf(error(error, 10, urgentMessage))
|
||||
}
|
||||
|
||||
val errors = mutableListOf<CredentialStatus.Fail?>()
|
||||
val job = launch { interactor.verificationError.toList(errors) }
|
||||
|
||||
val checked =
|
||||
interactor.checkCredential(pinRequest(), text = "1234")
|
||||
as? CredentialStatus.Fail.Error
|
||||
|
||||
assertThat(checked).isNotNull()
|
||||
assertThat(checked!!.urgentMessage).isEqualTo(urgentMessage)
|
||||
assertThat(errors.map { it?.error }).containsExactly(null, error).inOrder()
|
||||
assertThat(errors.last() as? CredentialStatus.Fail.Error)
|
||||
.isEqualTo(error(error, 10, urgentMessage))
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkCredentialWhenBadAndThrottled() =
|
||||
runTest(dispatcher) {
|
||||
val remainingAttempts = 3
|
||||
val error = ":("
|
||||
val urgentMessage = ":D"
|
||||
credentialInteractor.verifyCredentialResponse = { _ ->
|
||||
flow {
|
||||
for (i in 1..3) {
|
||||
emit(throttled("$i"))
|
||||
delay(100)
|
||||
}
|
||||
emit(error(error, remainingAttempts, urgentMessage))
|
||||
}
|
||||
}
|
||||
val errors = mutableListOf<CredentialStatus.Fail?>()
|
||||
val job = launch { interactor.verificationError.toList(errors) }
|
||||
|
||||
val checked =
|
||||
interactor.checkCredential(pinRequest(), text = "1234")
|
||||
as? CredentialStatus.Fail.Error
|
||||
|
||||
assertThat(checked).isNotNull()
|
||||
assertThat(checked!!.remainingAttempts).isEqualTo(remainingAttempts)
|
||||
assertThat(checked.urgentMessage).isEqualTo(urgentMessage)
|
||||
assertThat(errors.map { it?.error })
|
||||
.containsExactly(null, "1", "2", "3", error)
|
||||
.inOrder()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pinRequest(): BiometricPromptRequest.Credential.Pin =
|
||||
BiometricPromptRequest.Credential.Pin(
|
||||
promptInfo(),
|
||||
BiometricUserInfo(USER_ID),
|
||||
BiometricOperationInfo(OPERATION_ID)
|
||||
)
|
||||
|
||||
private fun verified(hat: ByteArray) = CredentialStatus.Success.Verified(hat)
|
||||
|
||||
private fun throttled(error: String) = CredentialStatus.Fail.Throttled(error)
|
||||
|
||||
private fun error(error: String? = null, remaining: Int? = null, urgentMessage: String? = null) =
|
||||
CredentialStatus.Fail.Error(error, remaining, urgentMessage)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.android.systemui.biometrics.ui.viewmodel
|
||||
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.biometrics.data.model.PromptKind
|
||||
import com.android.systemui.biometrics.data.repository.FakePromptRepository
|
||||
import com.android.systemui.biometrics.domain.interactor.BiometricPromptCredentialInteractor
|
||||
import com.android.systemui.biometrics.domain.interactor.CredentialStatus
|
||||
import com.android.systemui.biometrics.domain.interactor.FakeCredentialInteractor
|
||||
import com.android.systemui.biometrics.promptInfo
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
|
||||
private const val USER_ID = 9
|
||||
private const val OPERATION_ID = 10L
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@SmallTest
|
||||
@RunWith(JUnit4::class)
|
||||
class CredentialViewModelTest : SysuiTestCase() {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
private val promptRepository = FakePromptRepository()
|
||||
private val credentialInteractor = FakeCredentialInteractor()
|
||||
|
||||
private lateinit var viewModel: CredentialViewModel
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
viewModel =
|
||||
CredentialViewModel(
|
||||
mContext,
|
||||
BiometricPromptCredentialInteractor(
|
||||
dispatcher,
|
||||
promptRepository,
|
||||
credentialInteractor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun setsPinInputFlags() = setsInputFlags(PromptKind.PIN, expectFlags = true)
|
||||
@Test fun setsPasswordInputFlags() = setsInputFlags(PromptKind.PASSWORD, expectFlags = false)
|
||||
@Test fun setsPatternInputFlags() = setsInputFlags(PromptKind.PATTERN, expectFlags = false)
|
||||
|
||||
private fun setsInputFlags(type: PromptKind, expectFlags: Boolean) =
|
||||
runTestWithKind(type) {
|
||||
var flags: Int? = null
|
||||
val job = launch { viewModel.inputFlags.collect { flags = it } }
|
||||
|
||||
if (expectFlags) {
|
||||
assertThat(flags).isNotNull()
|
||||
} else {
|
||||
assertThat(flags).isNull()
|
||||
}
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test fun isStealthIgnoredByPin() = isStealthMode(PromptKind.PIN, expectStealth = false)
|
||||
@Test
|
||||
fun isStealthIgnoredByPassword() = isStealthMode(PromptKind.PASSWORD, expectStealth = false)
|
||||
@Test fun isStealthUsedByPattern() = isStealthMode(PromptKind.PATTERN, expectStealth = true)
|
||||
|
||||
private fun isStealthMode(type: PromptKind, expectStealth: Boolean) =
|
||||
runTestWithKind(type, init = { credentialInteractor.stealthMode = true }) {
|
||||
var stealth: Boolean? = null
|
||||
val job = launch { viewModel.stealthMode.collect { stealth = it } }
|
||||
|
||||
assertThat(stealth).isEqualTo(expectStealth)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun animatesContents() = runTestWithKind {
|
||||
val expected = arrayOf(true, false, true)
|
||||
val animate = mutableListOf<Boolean>()
|
||||
val job = launch { viewModel.animateContents.toList(animate) }
|
||||
|
||||
for (value in expected) {
|
||||
viewModel.setAnimateContents(value)
|
||||
viewModel.setAnimateContents(value)
|
||||
}
|
||||
assertThat(animate).containsExactly(*expected).inOrder()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showAndClearErrors() = runTestWithKind {
|
||||
var error = ""
|
||||
val job = launch { viewModel.errorMessage.collect { error = it } }
|
||||
assertThat(error).isEmpty()
|
||||
|
||||
viewModel.showPatternTooShortError()
|
||||
assertThat(error).isNotEmpty()
|
||||
|
||||
viewModel.resetErrorMessage()
|
||||
assertThat(error).isEmpty()
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkCredential() = runTestWithKind {
|
||||
val hat = ByteArray(2)
|
||||
credentialInteractor.verifyCredentialResponse = { _ ->
|
||||
flowOf(CredentialStatus.Success.Verified(hat))
|
||||
}
|
||||
|
||||
val attestations = mutableListOf<ByteArray?>()
|
||||
val remainingAttempts = mutableListOf<RemainingAttempts?>()
|
||||
var header: HeaderViewModel? = null
|
||||
val job = launch {
|
||||
launch { viewModel.validatedAttestation.toList(attestations) }
|
||||
launch { viewModel.remainingAttempts.toList(remainingAttempts) }
|
||||
launch { viewModel.header.collect { header = it } }
|
||||
}
|
||||
assertThat(header).isNotNull()
|
||||
|
||||
viewModel.checkCredential("p", header!!)
|
||||
|
||||
val attestation = attestations.removeLastOrNull()
|
||||
assertThat(attestation).isSameInstanceAs(hat)
|
||||
assertThat(attestations).isEmpty()
|
||||
assertThat(remainingAttempts).containsExactly(RemainingAttempts())
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkCredentialWhenBad() = runTestWithKind {
|
||||
val remaining = 2
|
||||
val urgentError = "wow"
|
||||
credentialInteractor.verifyCredentialResponse = { _ ->
|
||||
flowOf(CredentialStatus.Fail.Error("error", remaining, urgentError))
|
||||
}
|
||||
|
||||
val attestations = mutableListOf<ByteArray?>()
|
||||
val remainingAttempts = mutableListOf<RemainingAttempts?>()
|
||||
var header: HeaderViewModel? = null
|
||||
val job = launch {
|
||||
launch { viewModel.validatedAttestation.toList(attestations) }
|
||||
launch { viewModel.remainingAttempts.toList(remainingAttempts) }
|
||||
launch { viewModel.header.collect { header = it } }
|
||||
}
|
||||
assertThat(header).isNotNull()
|
||||
|
||||
viewModel.checkCredential("1111", header!!)
|
||||
|
||||
assertThat(attestations).containsExactly(null)
|
||||
|
||||
val attemptInfo = remainingAttempts.removeLastOrNull()
|
||||
assertThat(attemptInfo).isNotNull()
|
||||
assertThat(attemptInfo!!.remaining).isEqualTo(remaining)
|
||||
assertThat(attemptInfo.message).isEqualTo(urgentError)
|
||||
assertThat(remainingAttempts).containsExactly(RemainingAttempts()) // initial value
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun runTestWithKind(
|
||||
kind: PromptKind = PromptKind.PIN,
|
||||
init: () -> Unit = {},
|
||||
block: suspend TestScope.() -> Unit,
|
||||
) =
|
||||
runTest(dispatcher) {
|
||||
init()
|
||||
promptRepository.setPrompt(promptInfo(), USER_ID, OPERATION_ID, kind)
|
||||
block()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.android.systemui.biometrics.data.repository
|
||||
|
||||
import android.hardware.biometrics.PromptInfo
|
||||
import com.android.systemui.biometrics.data.model.PromptKind
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** Fake implementation of [PromptRepository] for tests. */
|
||||
class FakePromptRepository : PromptRepository {
|
||||
|
||||
private val _isShowing = MutableStateFlow(false)
|
||||
override val isShowing = _isShowing.asStateFlow()
|
||||
|
||||
private val _promptInfo = MutableStateFlow<PromptInfo?>(null)
|
||||
override val promptInfo = _promptInfo.asStateFlow()
|
||||
|
||||
private val _userId = MutableStateFlow<Int?>(null)
|
||||
override val userId = _userId.asStateFlow()
|
||||
|
||||
private var _challenge = MutableStateFlow<Long?>(null)
|
||||
override val challenge = _challenge.asStateFlow()
|
||||
|
||||
private val _kind = MutableStateFlow(PromptKind.ANY_BIOMETRIC)
|
||||
override val kind = _kind.asStateFlow()
|
||||
|
||||
override fun setPrompt(
|
||||
promptInfo: PromptInfo,
|
||||
userId: Int,
|
||||
gatekeeperChallenge: Long?,
|
||||
kind: PromptKind
|
||||
) {
|
||||
_promptInfo.value = promptInfo
|
||||
_userId.value = userId
|
||||
_challenge.value = gatekeeperChallenge
|
||||
_kind.value = kind
|
||||
}
|
||||
|
||||
override fun unsetPrompt() {
|
||||
_promptInfo.value = null
|
||||
_userId.value = null
|
||||
_challenge.value = null
|
||||
_kind.value = PromptKind.ANY_BIOMETRIC
|
||||
}
|
||||
|
||||
fun setIsShowing(showing: Boolean) {
|
||||
_isShowing.value = showing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.android.systemui.biometrics.domain.interactor
|
||||
|
||||
import com.android.internal.widget.LockscreenCredential
|
||||
import com.android.systemui.biometrics.domain.model.BiometricPromptRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/** Fake implementation of [CredentialInteractor] for tests. */
|
||||
class FakeCredentialInteractor : CredentialInteractor {
|
||||
|
||||
/** Sets return value for [isStealthModeActive]. */
|
||||
var stealthMode: Boolean = false
|
||||
|
||||
/** Sets return value for [getCredentialOwnerOrSelfId]. */
|
||||
var credentialOwnerId: Int? = null
|
||||
|
||||
override fun isStealthModeActive(userId: Int): Boolean = stealthMode
|
||||
|
||||
override fun getCredentialOwnerOrSelfId(userId: Int): Int = credentialOwnerId ?: userId
|
||||
|
||||
override fun verifyCredential(
|
||||
request: BiometricPromptRequest.Credential,
|
||||
credential: LockscreenCredential,
|
||||
): Flow<CredentialStatus> = verifyCredentialResponse(credential)
|
||||
|
||||
/** Sets the result value for [verifyCredential]. */
|
||||
var verifyCredentialResponse: (credential: LockscreenCredential) -> Flow<CredentialStatus> =
|
||||
{ _ ->
|
||||
flowOf(CredentialStatus.Fail.Error("invalid"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user