diff --git a/packages/SystemUI/res/layout/auth_biometric_contents.xml b/packages/SystemUI/res/layout/auth_biometric_contents.xml index aed200f69bc3c..925e4fad103d6 100644 --- a/packages/SystemUI/res/layout/auth_biometric_contents.xml +++ b/packages/SystemUI/res/layout/auth_biometric_contents.xml @@ -56,7 +56,7 @@ android:scaleType="fitXY" /> mCallback.onAction(Callback.ACTION_AUTHENTICATED), getDelayAfterAuthenticatedDurationMs()); @@ -364,9 +368,10 @@ public abstract class AuthBiometricView extends LinearLayout { mNegativeButton.setText(R.string.cancel); mNegativeButton.setContentDescription(getResources().getString(R.string.cancel)); mPositiveButton.setEnabled(true); - mErrorView.setTextColor(mTextColorHint); - mErrorView.setText(R.string.biometric_dialog_tap_confirm); - mErrorView.setVisibility(View.VISIBLE); + mPositiveButton.setVisibility(View.VISIBLE); + mIndicatorView.setTextColor(mTextColorHint); + mIndicatorView.setText(R.string.biometric_dialog_tap_confirm); + mIndicatorView.setVisibility(View.VISIBLE); break; case STATE_ERROR: @@ -409,6 +414,27 @@ public abstract class AuthBiometricView extends LinearLayout { updateState(STATE_HELP); } + public void onSaveState(@NonNull Bundle outState) { + outState.putInt(AuthDialog.KEY_BIOMETRIC_TRY_AGAIN_VISIBILITY, + mTryAgainButton.getVisibility()); + outState.putInt(AuthDialog.KEY_BIOMETRIC_STATE, mState); + outState.putString(AuthDialog.KEY_BIOMETRIC_INDICATOR_STRING, + mIndicatorView.getText().toString()); + outState.putBoolean(AuthDialog.KEY_BIOMETRIC_INDICATOR_ERROR_SHOWING, + mHandler.hasCallbacks(mResetErrorRunnable)); + outState.putBoolean(AuthDialog.KEY_BIOMETRIC_INDICATOR_HELP_SHOWING, + mHandler.hasCallbacks(mResetHelpRunnable)); + outState.putInt(AuthDialog.KEY_BIOMETRIC_DIALOG_SIZE, mSize); + } + + /** + * Invoked after inflation but before being attached to window. + * @param savedState + */ + public void restoreState(@Nullable Bundle savedState) { + mSavedState = savedState; + } + private void setTextOrHide(TextView view, String string) { if (TextUtils.isEmpty(string)) { view.setVisibility(View.GONE); @@ -429,25 +455,28 @@ public abstract class AuthBiometricView extends LinearLayout { private void showTemporaryMessage(String message, Runnable resetMessageRunnable) { removePendingAnimations(); - mErrorView.setText(message); - mErrorView.setTextColor(mTextColorError); - mErrorView.setVisibility(View.VISIBLE); + mIndicatorView.setText(message); + mIndicatorView.setTextColor(mTextColorError); + mIndicatorView.setVisibility(View.VISIBLE); mHandler.postDelayed(resetMessageRunnable, BiometricPrompt.HIDE_DIALOG_DELAY); } @Override protected void onFinishInflate() { super.onFinishInflate(); - initializeViews(); + onFinishInflateInternal(); } + /** + * After inflation, but before things like restoreState, onAttachedToWindow, etc. + */ @VisibleForTesting - void initializeViews() { + void onFinishInflateInternal() { mTitleView = mInjector.getTitleView(); mSubtitleView = mInjector.getSubtitleView(); mDescriptionView = mInjector.getDescriptionView(); mIconView = mInjector.getIconView(); - mErrorView = mInjector.getErrorView(); + mIndicatorView = mInjector.getIndicatorView(); mNegativeButton = mInjector.getNegativeButton(); mPositiveButton = mInjector.getPositiveButton(); mTryAgainButton = mInjector.getTryAgainButton(); @@ -474,13 +503,49 @@ public abstract class AuthBiometricView extends LinearLayout { @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); + onAttachedToWindowInternal(); + } + + /** + * Contains all the testable logic that should be invoked when {@link #onAttachedToWindow()} is + * invoked. + */ + @VisibleForTesting + void onAttachedToWindowInternal() { setText(mTitleView, mBundle.getString(BiometricPrompt.KEY_TITLE)); setText(mNegativeButton, mBundle.getString(BiometricPrompt.KEY_NEGATIVE_TEXT)); setTextOrHide(mSubtitleView, mBundle.getString(BiometricPrompt.KEY_SUBTITLE)); setTextOrHide(mDescriptionView, mBundle.getString(BiometricPrompt.KEY_DESCRIPTION)); - updateState(STATE_AUTHENTICATING_ANIMATING_IN); + if (mSavedState == null) { + updateState(STATE_AUTHENTICATING_ANIMATING_IN); + } else { + // Restore as much state as possible first + updateState(mSavedState.getInt(AuthDialog.KEY_BIOMETRIC_STATE)); + + // Restore positive button state + mTryAgainButton.setVisibility( + mSavedState.getInt(AuthDialog.KEY_BIOMETRIC_TRY_AGAIN_VISIBILITY)); + + // Restore indicator text state + final String indicatorText = + mSavedState.getString(AuthDialog.KEY_BIOMETRIC_INDICATOR_STRING); + if (mSavedState.getBoolean(AuthDialog.KEY_BIOMETRIC_INDICATOR_HELP_SHOWING)) { + onHelp(indicatorText); + } else if (mSavedState.getBoolean(AuthDialog.KEY_BIOMETRIC_INDICATOR_ERROR_SHOWING)) { + onAuthenticationFailed(indicatorText); + } + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + // Empty the handler, otherwise things like ACTION_AUTHENTICATED may be duplicated once + // the new dialog is restored. + mHandler.removeCallbacksAndMessages(null /* all */); } @Override @@ -521,13 +586,24 @@ public abstract class AuthBiometricView extends LinearLayout { @Override public void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); + onLayoutInternal(); + } + /** + * Contains all the testable logic that should be invoked when + * {@link #onLayout(boolean, int, int, int, int)}, is invoked. + */ + @VisibleForTesting + void onLayoutInternal() { // Start with initial size only once. Subsequent layout changes don't matter since we // only care about the initial icon position. if (mIconOriginalY == 0) { mIconOriginalY = mIconView.getY(); - updateSize(mRequireConfirmation ? AuthDialog.SIZE_MEDIUM - : AuthDialog.SIZE_SMALL); + if (mSavedState == null) { + updateSize(mRequireConfirmation ? AuthDialog.SIZE_MEDIUM : AuthDialog.SIZE_SMALL); + } else { + updateSize(mSavedState.getInt(AuthDialog.KEY_BIOMETRIC_DIALOG_SIZE)); + } } } } diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java index 9cb5fcf4de00b..e198060da9aa8 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java +++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthContainerView.java @@ -17,6 +17,8 @@ package com.android.systemui.biometrics; import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; import android.content.Context; import android.graphics.PixelFormat; import android.os.Binder; @@ -269,7 +271,8 @@ public class AuthContainerView extends LinearLayout } @Override - public void show(WindowManager wm) { + public void show(WindowManager wm, @Nullable Bundle savedState) { + mBiometricView.restoreState(savedState); wm.addView(this, getLayoutParams(mWindowToken)); } @@ -308,13 +311,8 @@ public class AuthContainerView extends LinearLayout } @Override - public void onSaveState(Bundle outState) { - - } - - @Override - public void restoreState(Bundle savedState) { - + public void onSaveState(@NonNull Bundle outState) { + mBiometricView.onSaveState(outState); } @Override diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java index dcd01c6827260..ab89034c3b656 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java +++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthController.java @@ -272,11 +272,7 @@ public class AuthController extends SystemUI implements CommandQueue.Callbacks, + " type: " + type); } - if (savedState != null) { - // SavedState is only non-null if it's from onConfigurationChanged. Restore the state - // even though it may be removed / re-created again - newDialog.restoreState(savedState); - } else if (mCurrentDialog != null) { + if (mCurrentDialog != null) { // If somehow we're asked to show a dialog, the old one doesn't need to be animated // away. This can happen if the app cancels and re-starts auth during configuration // change. This is ugly because we also have to do things on onConfigurationChanged @@ -286,7 +282,7 @@ public class AuthController extends SystemUI implements CommandQueue.Callbacks, mReceiver = (IBiometricServiceReceiverInternal) args.arg2; mCurrentDialog = newDialog; - mCurrentDialog.show(mWindowManager); + mCurrentDialog.show(mWindowManager, savedState); } private void onDialogDismissed(@DismissedReason int reason) { diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/AuthDialog.java b/packages/SystemUI/src/com/android/systemui/biometrics/AuthDialog.java index a6a857ca5945e..fb904231c175a 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/AuthDialog.java +++ b/packages/SystemUI/src/com/android/systemui/biometrics/AuthDialog.java @@ -17,7 +17,8 @@ package com.android.systemui.biometrics; import android.annotation.IntDef; -import android.hardware.biometrics.BiometricPrompt; +import android.annotation.NonNull; +import android.annotation.Nullable; import android.os.Bundle; import android.view.WindowManager; @@ -28,28 +29,12 @@ import java.lang.annotation.RetentionPolicy; * Interface for the biometric dialog UI. */ public interface AuthDialog { - - // TODO: Clean up save/restore state - String[] KEYS_TO_BACKUP = { - BiometricPrompt.KEY_TITLE, - BiometricPrompt.KEY_USE_DEFAULT_TITLE, - BiometricPrompt.KEY_SUBTITLE, - BiometricPrompt.KEY_DESCRIPTION, - BiometricPrompt.KEY_POSITIVE_TEXT, - BiometricPrompt.KEY_NEGATIVE_TEXT, - BiometricPrompt.KEY_REQUIRE_CONFIRMATION, - BiometricPrompt.KEY_ALLOW_DEVICE_CREDENTIAL, - BiometricPrompt.KEY_FROM_CONFIRM_DEVICE_CREDENTIAL, - - BiometricDialogView.KEY_TRY_AGAIN_VISIBILITY, - BiometricDialogView.KEY_CONFIRM_VISIBILITY, - BiometricDialogView.KEY_CONFIRM_ENABLED, - BiometricDialogView.KEY_STATE, - BiometricDialogView.KEY_ERROR_TEXT_VISIBILITY, - BiometricDialogView.KEY_ERROR_TEXT_STRING, - BiometricDialogView.KEY_ERROR_TEXT_IS_TEMPORARY, - BiometricDialogView.KEY_ERROR_TEXT_COLOR, - }; + String KEY_BIOMETRIC_TRY_AGAIN_VISIBILITY = "try_agian_visibility"; + String KEY_BIOMETRIC_STATE = "state"; + String KEY_BIOMETRIC_INDICATOR_STRING = "indicator_string"; // error / help / hint + String KEY_BIOMETRIC_INDICATOR_ERROR_SHOWING = "error_is_temporary"; + String KEY_BIOMETRIC_INDICATOR_HELP_SHOWING = "hint_is_temporary"; + String KEY_BIOMETRIC_DIALOG_SIZE = "size"; int SIZE_UNKNOWN = 0; int SIZE_SMALL = 1; @@ -68,7 +53,7 @@ public interface AuthDialog { * Show the dialog. * @param wm */ - void show(WindowManager wm); + void show(WindowManager wm, @Nullable Bundle savedState); /** * Dismiss the dialog without sending a callback. @@ -107,13 +92,7 @@ public interface AuthDialog { * Save the current state. * @param outState */ - void onSaveState(Bundle outState); - - /** - * Restore a previous state. - * @param savedState - */ - void restoreState(Bundle savedState); + void onSaveState(@NonNull Bundle outState); /** * Get the client's package name diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/BiometricDialogView.java b/packages/SystemUI/src/com/android/systemui/biometrics/BiometricDialogView.java index 89d08d7951284..b985e1c2a4d44 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/BiometricDialogView.java +++ b/packages/SystemUI/src/com/android/systemui/biometrics/BiometricDialogView.java @@ -23,6 +23,7 @@ import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.annotation.IntDef; +import android.annotation.Nullable; import android.app.admin.DevicePolicyManager; import android.content.Context; import android.graphics.Outline; @@ -738,7 +739,10 @@ public abstract class BiometricDialogView extends LinearLayout implements AuthDi } @Override - public void show(WindowManager wm) { + public void show(WindowManager wm, @Nullable Bundle savedState) { + if (savedState != null) { + restoreState(savedState); + } wm.addView(this, getLayoutParams(mWindowToken)); } @@ -832,7 +836,6 @@ public abstract class BiometricDialogView extends LinearLayout implements AuthDi bundle.putInt(KEY_DIALOG_SIZE, mSize); } - @Override public void restoreState(Bundle bundle) { mRestoredState = bundle; diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricFaceViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricFaceViewTest.java index 128e819ccedd9..b907cdb54bbff 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricFaceViewTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricFaceViewTest.java @@ -63,7 +63,7 @@ public class AuthBiometricFaceViewTest extends SysuiTestCase { mFaceView.mNegativeButton = mNegativeButton; mFaceView.mPositiveButton = mPositiveButton; mFaceView.mTryAgainButton = mTryAgainButton; - mFaceView.mErrorView = mErrorView; + mFaceView.mIndicatorView = mErrorView; } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricViewTest.java index ffcb293ba3983..fc1870738375c 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricViewTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthBiometricViewTest.java @@ -17,12 +17,15 @@ package com.android.systemui.biometrics; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import android.content.Context; +import android.hardware.biometrics.BiometricPrompt; +import android.os.Bundle; import android.test.suitebuilder.annotation.SmallTest; import android.testing.AndroidTestingRunner; import android.testing.TestableLooper.RunWithLooper; @@ -55,10 +58,10 @@ public class AuthBiometricViewTest extends SysuiTestCase { @Mock private TextView mTitleView; @Mock private TextView mSubtitleView; @Mock private TextView mDescriptionView; - @Mock private TextView mErrorView; + @Mock private TextView mIndicatorView; @Mock private ImageView mIconView; - TestableBiometricView mBiometricView; + private TestableBiometricView mBiometricView; @Before public void setup() { @@ -87,8 +90,8 @@ public class AuthBiometricViewTest extends SysuiTestCase { verify(mCallback, never()).onAction(anyInt()); verify(mBiometricView.mNegativeButton).setText(eq(R.string.cancel)); verify(mBiometricView.mPositiveButton).setEnabled(eq(true)); - verify(mErrorView).setText(eq(R.string.biometric_dialog_tap_confirm)); - verify(mErrorView).setVisibility(eq(View.VISIBLE)); + verify(mIndicatorView).setText(eq(R.string.biometric_dialog_tap_confirm)); + verify(mIndicatorView).setVisibility(eq(View.VISIBLE)); } @Test @@ -193,11 +196,88 @@ public class AuthBiometricViewTest extends SysuiTestCase { verify(mCallback, never()).onAction(eq(AuthBiometricView.Callback.ACTION_USER_CANCELED)); } + @Test + public void testRestoresState() { + final boolean requireConfirmation = true; // set/init from AuthController + + Button tryAgainButton = new Button(mContext); + TextView indicatorView = new TextView(mContext); + initDialog(mContext, mCallback, new MockInjector() { + @Override + public Button getTryAgainButton() { + return tryAgainButton; + } + @Override + public TextView getIndicatorView() { + return indicatorView; + } + }); + + final String failureMessage = "testFailureMessage"; + mBiometricView.setRequireConfirmation(requireConfirmation); + mBiometricView.onAuthenticationFailed(failureMessage); + waitForIdleSync(); + + Bundle state = new Bundle(); + mBiometricView.onSaveState(state); + + assertEquals(View.VISIBLE, tryAgainButton.getVisibility()); + assertEquals(View.VISIBLE, state.getInt(AuthDialog.KEY_BIOMETRIC_TRY_AGAIN_VISIBILITY)); + + assertEquals(AuthBiometricView.STATE_ERROR, mBiometricView.mState); + assertEquals(AuthBiometricView.STATE_ERROR, state.getInt(AuthDialog.KEY_BIOMETRIC_STATE)); + + assertEquals(View.VISIBLE, mBiometricView.mIndicatorView.getVisibility()); + assertTrue(state.getBoolean(AuthDialog.KEY_BIOMETRIC_INDICATOR_ERROR_SHOWING)); + + assertEquals(failureMessage, mBiometricView.mIndicatorView.getText()); + assertEquals(failureMessage, state.getString(AuthDialog.KEY_BIOMETRIC_INDICATOR_STRING)); + + // TODO: Test dialog size. Should move requireConfirmation to buildBiometricPromptBundle + + // Create new dialog and restore the previous state into it + Button tryAgainButton2 = new Button(mContext); + TextView indicatorView2 = new TextView(mContext); + initDialog(mContext, mCallback, state, new MockInjector() { + @Override + public Button getTryAgainButton() { + return tryAgainButton2; + } + @Override + public TextView getIndicatorView() { + return indicatorView2; + } + }); + mBiometricView.setRequireConfirmation(requireConfirmation); + waitForIdleSync(); + + // Test restored state + assertEquals(View.VISIBLE, tryAgainButton.getVisibility()); + assertEquals(AuthBiometricView.STATE_ERROR, mBiometricView.mState); + assertEquals(View.VISIBLE, mBiometricView.mIndicatorView.getVisibility()); + assertEquals(failureMessage, mBiometricView.mIndicatorView.getText()); + } + + private Bundle buildBiometricPromptBundle() { + Bundle bundle = new Bundle(); + bundle.putCharSequence(BiometricPrompt.KEY_TITLE, "Title"); + bundle.putCharSequence(BiometricPrompt.KEY_NEGATIVE_TEXT, "Negative"); + return bundle; + } + + private void initDialog(Context context, AuthBiometricView.Callback callback, + Bundle savedState, MockInjector injector) { + mBiometricView = new TestableBiometricView(context, null, injector); + mBiometricView.setBiometricPromptBundle(buildBiometricPromptBundle()); + mBiometricView.setCallback(callback); + mBiometricView.restoreState(savedState); + mBiometricView.onFinishInflateInternal(); + mBiometricView.onAttachedToWindowInternal(); + } + private void initDialog(Context context, AuthBiometricView.Callback callback, MockInjector injector) { - mBiometricView = new TestableBiometricView(context, null, injector); - mBiometricView.setCallback(callback); - mBiometricView.initializeViews(); + initDialog(context, callback, null /* savedState */, injector); } private class MockInjector extends AuthBiometricView.Injector { @@ -232,8 +312,8 @@ public class AuthBiometricViewTest extends SysuiTestCase { } @Override - public TextView getErrorView() { - return mErrorView; + public TextView getIndicatorView() { + return mIndicatorView; } @Override diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java index a5e468e0545d1..eb7be4fa63323 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/AuthControllerTest.java @@ -147,7 +147,7 @@ public class AuthControllerTest extends SysuiTestCase { public void testShowInvoked_whenSystemRequested() throws Exception { showDialog(BiometricPrompt.TYPE_FACE); - verify(mDialog1).show(any()); + verify(mDialog1).show(any(), any()); } @Test @@ -215,7 +215,7 @@ public class AuthControllerTest extends SysuiTestCase { @Test public void testShowNewDialog_beforeOldDialogDismissed_SkipsAnimations() throws Exception { showDialog(BiometricPrompt.TYPE_FACE); - verify(mDialog1).show(any()); + verify(mDialog1).show(any(), any()); showDialog(BiometricPrompt.TYPE_FACE); @@ -223,13 +223,13 @@ public class AuthControllerTest extends SysuiTestCase { verify(mDialog1).dismissWithoutCallback(eq(false) /* animate */); // Second dialog should be shown without animation - verify(mDialog2).show(any()); + verify(mDialog2).show(any(), any()); } @Test public void testConfigurationPersists_whenOnConfigurationChanged() throws Exception { showDialog(BiometricPrompt.TYPE_FACE); - verify(mDialog1).show(any()); + verify(mDialog1).show(any(), any()); mBiometricDialogImpl.onConfigurationChanged(new Configuration()); @@ -241,10 +241,7 @@ public class AuthControllerTest extends SysuiTestCase { // Saved state is restored into new dialog ArgumentCaptor captor2 = ArgumentCaptor.forClass(Bundle.class); - verify(mDialog2).restoreState(captor2.capture()); - - // Dialog for new configuration skips intro - verify(mDialog2).show(any()); + verify(mDialog2).show(any(), captor2.capture()); // TODO: This should check all values we want to save/restore assertEquals(captor.getValue(), captor2.getValue());