Implement security screen lockout
Implement the functionality of locking the user out from retrying the
lock screen challenge if they have gotten it incorrect too many times.
Bug: 162355574
Test: manual + atest ConfirmLockLockoutHelperTest
Change-Id: If9431f629e7d019a1d57d84b88d363093b01efe1
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 0d5e040..f514e5b 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1221,6 +1221,8 @@
<string name="lockpattern_settings_help_how_to_record">How to draw an unlock pattern</string>
<!-- Message shown when error is encountered when saving pattern [CHAR LIMIT=40] -->
<string name="error_saving_lockpattern">Error saving pattern</string>
+ <!-- Message shown when in pattern unlock screen after too many incorrect attempts [CHAR LIMIT=120]-->
+ <string name="lockpattern_too_many_failed_confirmation_attempts">Too many incorrect attempts. Try again in <xliff:g id="number">%d</xliff:g> seconds.</string>
<!-- Label for button to confirm picker selection [CHAR_LIMIT=20] -->
<string name="okay">OK</string>
<!-- Title of dialog asking the user if they really want to remove the screen lock [CHAR LIMIT=30]-->
diff --git a/src/com/android/car/settings/security/CheckLockWorker.java b/src/com/android/car/settings/security/CheckLockWorker.java
index e49e141..42a0095 100644
--- a/src/com/android/car/settings/security/CheckLockWorker.java
+++ b/src/com/android/car/settings/security/CheckLockWorker.java
@@ -38,6 +38,7 @@
private boolean mHasPendingResult;
private boolean mLockMatched;
+ private int mThrottleTimeoutMs;
private boolean mCheckInProgress;
private Listener mListener;
private LockPatternUtils mLockPatternUtils;
@@ -56,8 +57,9 @@
if (mListener == null) {
mHasPendingResult = true;
mLockMatched = matched;
+ mThrottleTimeoutMs = throttleTimeoutMs;
} else {
- mListener.onCheckCompleted(matched);
+ mListener.onCheckCompleted(matched, throttleTimeoutMs);
}
}
@@ -68,7 +70,7 @@
mListener = listener;
if (mListener != null && mHasPendingResult) {
mHasPendingResult = false;
- mListener.onCheckCompleted(mLockMatched);
+ mListener.onCheckCompleted(mLockMatched, mThrottleTimeoutMs);
}
}
@@ -111,8 +113,10 @@
interface Listener {
/**
* @param matched Whether the entered password matches the stored record.
+ * @param timeoutMs The remaining amount of time that the user is locked out from
+ * retrying the password challenge.
*/
- void onCheckCompleted(boolean matched);
+ void onCheckCompleted(boolean matched, int timeoutMs);
}
}
diff --git a/src/com/android/car/settings/security/ConfirmLockLockoutHelper.java b/src/com/android/car/settings/security/ConfirmLockLockoutHelper.java
new file mode 100644
index 0000000..61d2808
--- /dev/null
+++ b/src/com/android/car/settings/security/ConfirmLockLockoutHelper.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2020 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.car.settings.security;
+
+import android.content.Context;
+import android.os.CountDownTimer;
+import android.os.SystemClock;
+
+import androidx.annotation.Nullable;
+
+import com.android.car.settings.R;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.widget.LockPatternUtils;
+
+/** Common lockout handling code. */
+public class ConfirmLockLockoutHelper {
+
+ private static ConfirmLockLockoutHelper sInstance;
+
+ private final Context mContext;
+ private final int mUserId;
+ private final LockPatternUtils mLockPatternUtils;
+ private ConfirmLockUIController mUiController;
+ private CountDownTimer mCountDownTimer;
+
+ /** Return an instance of {@link ConfirmLockLockoutHelper}. */
+ public static ConfirmLockLockoutHelper getInstance(Context context, int userId) {
+ if (sInstance == null) {
+ sInstance = new ConfirmLockLockoutHelper(context, userId,
+ new LockPatternUtils(context));
+ }
+ return sInstance;
+ }
+
+ @VisibleForTesting
+ ConfirmLockLockoutHelper(Context context, int userId,
+ LockPatternUtils lockPatternUtils) {
+ mContext = context;
+ mUserId = userId;
+ mLockPatternUtils = lockPatternUtils;
+ }
+
+ /** Sets the UI controller. */
+ public void setConfirmLockUIController(ConfirmLockUIController uiController) {
+ mUiController = uiController;
+ }
+
+ /** Gets the lock pattern utils used by this helper. */
+ public LockPatternUtils getLockPatternUtils() {
+ return mLockPatternUtils;
+ }
+
+ /** Handles when the lock check is completed but returns a timeout. */
+ public void onCheckCompletedWithTimeout(int timeoutMs) {
+ if (timeoutMs <= 0) {
+ return;
+ }
+
+ long deadline = mLockPatternUtils.setLockoutAttemptDeadline(mUserId, timeoutMs);
+ handleAttemptLockout(deadline);
+ }
+
+ /** To be called when the UI is resumed to reset the timeout countdown if necessary. */
+ public void onResumeUI() {
+ if (isLockedOut()) {
+ handleAttemptLockout(mLockPatternUtils.getLockoutAttemptDeadline(mUserId));
+ } else {
+ mUiController.refreshUI(isLockedOut());
+ }
+ }
+
+ /** To be called when the UI is paused to cancel the ongoing countdown timer. */
+ public void onPauseUI() {
+ if (mCountDownTimer != null) {
+ mCountDownTimer.cancel();
+ }
+ }
+
+ @VisibleForTesting
+ @Nullable
+ CountDownTimer getCountDownTimer() {
+ return mCountDownTimer;
+ }
+
+ private void handleAttemptLockout(long deadline) {
+ long elapsedRealtime = SystemClock.elapsedRealtime();
+ mUiController.refreshUI(isLockedOut());
+ mCountDownTimer = newCountDownTimer(deadline - elapsedRealtime).start();
+ }
+
+ private boolean isLockedOut() {
+ return mLockPatternUtils.getLockoutAttemptDeadline(mUserId) != 0;
+ }
+
+ private CountDownTimer newCountDownTimer(long countDownMillis) {
+ return new CountDownTimer(countDownMillis,
+ LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS) {
+ @Override
+ public void onTick(long millisUntilFinished) {
+ int secondsCountdown = (int) (millisUntilFinished / 1000);
+ mUiController.setErrorText(
+ mContext.getString(
+ R.string.lockpattern_too_many_failed_confirmation_attempts,
+ secondsCountdown));
+ }
+
+ @Override
+ public void onFinish() {
+ mUiController.refreshUI(/* isLockedOut= */ false);
+ mUiController.setErrorText("");
+ }
+ };
+ }
+
+ /** Interface for controlling the associated lock UI. */
+ public interface ConfirmLockUIController {
+ /** Sets the error text with the given string. */
+ void setErrorText(String text);
+ /** Refreshes the UI based on the locked out state. */
+ void refreshUI(boolean isLockedOut);
+ }
+}
diff --git a/src/com/android/car/settings/security/ConfirmLockPatternFragment.java b/src/com/android/car/settings/security/ConfirmLockPatternFragment.java
index 0ccbcc6..9fbbc7d 100644
--- a/src/com/android/car/settings/security/ConfirmLockPatternFragment.java
+++ b/src/com/android/car/settings/security/ConfirmLockPatternFragment.java
@@ -27,7 +27,6 @@
import com.android.car.settings.R;
import com.android.car.settings.common.BaseFragment;
-import com.android.internal.widget.LockPatternUtils;
import com.android.internal.widget.LockPatternView;
import com.android.internal.widget.LockscreenCredential;
@@ -45,13 +44,14 @@
private LockPatternView mLockPatternView;
private TextView mMsgView;
- private LockPatternUtils mLockPatternUtils;
private CheckLockWorker mCheckLockWorker;
private CheckLockListener mCheckLockListener;
private int mUserId;
private List<LockPatternView.Cell> mPattern;
+ private ConfirmLockLockoutHelper mConfirmLockLockoutHelper;
+
@Override
@LayoutRes
protected int getLayoutId() {
@@ -72,13 +72,9 @@
} else {
throw new RuntimeException("The activity must implement CheckLockListener");
}
- }
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- mLockPatternUtils = new LockPatternUtils(getContext());
mUserId = UserHandle.myUserId();
+ mConfirmLockLockoutHelper = ConfirmLockLockoutHelper.getInstance(requireContext(), mUserId);
}
@Override
@@ -88,8 +84,22 @@
mMsgView = (TextView) view.findViewById(R.id.message);
mLockPatternView = (LockPatternView) view.findViewById(R.id.lockPattern);
mLockPatternView.setFadePattern(false);
- mLockPatternView.setInStealthMode(!mLockPatternUtils.isVisiblePatternEnabled(mUserId));
+ mLockPatternView.setInStealthMode(
+ !mConfirmLockLockoutHelper.getLockPatternUtils().isVisiblePatternEnabled(mUserId));
mLockPatternView.setOnPatternListener(mLockPatternListener);
+ mConfirmLockLockoutHelper.setConfirmLockUIController(
+ new ConfirmLockLockoutHelper.ConfirmLockUIController() {
+ @Override
+ public void setErrorText(String text) {
+ mMsgView.setText(text);
+ }
+
+ @Override
+ public void refreshUI(boolean isLockedOut) {
+ mLockPatternView.setEnabled(!isLockedOut);
+ mLockPatternView.clearPattern();
+ }
+ });
if (savedInstanceState != null) {
mCheckLockWorker = (CheckLockWorker) getFragmentManager().findFragmentByTag(
@@ -106,6 +116,20 @@
}
@Override
+ public void onResume() {
+ super.onResume();
+
+ mConfirmLockLockoutHelper.onResumeUI();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ mConfirmLockLockoutHelper.onPauseUI();
+ }
+
+ @Override
public void onStop() {
super.onStop();
if (mCheckLockWorker != null) {
@@ -114,6 +138,7 @@
}
private Runnable mClearErrorRunnable = () -> {
+ mLockPatternView.setEnabled(true);
mLockPatternView.clearPattern();
mMsgView.setText("");
};
@@ -152,16 +177,20 @@
}
};
- private void onCheckCompleted(boolean lockMatched) {
+ private void onCheckCompleted(boolean lockMatched, int timeoutMs) {
if (lockMatched) {
mCheckLockListener.onLockVerified(LockscreenCredential.createPattern(mPattern));
} else {
- mLockPatternView.setEnabled(true);
- mMsgView.setText(R.string.lockpattern_pattern_wrong);
+ if (timeoutMs > 0) {
+ mConfirmLockLockoutHelper.onCheckCompletedWithTimeout(timeoutMs);
+ } else {
+ mLockPatternView.setEnabled(true);
+ mMsgView.setText(R.string.lockpattern_pattern_wrong);
- // Set timer to clear wrong pattern
- mLockPatternView.removeCallbacks(mClearErrorRunnable);
- mLockPatternView.postDelayed(mClearErrorRunnable, CLEAR_WRONG_ATTEMPT_TIMEOUT_MS);
+ // Set timer to clear wrong pattern
+ mLockPatternView.removeCallbacks(mClearErrorRunnable);
+ mLockPatternView.postDelayed(mClearErrorRunnable, CLEAR_WRONG_ATTEMPT_TIMEOUT_MS);
+ }
}
}
}
diff --git a/src/com/android/car/settings/security/ConfirmLockPinPasswordFragment.java b/src/com/android/car/settings/security/ConfirmLockPinPasswordFragment.java
index 048d0ef..630e72b 100644
--- a/src/com/android/car/settings/security/ConfirmLockPinPasswordFragment.java
+++ b/src/com/android/car/settings/security/ConfirmLockPinPasswordFragment.java
@@ -35,6 +35,7 @@
import com.android.car.settings.R;
import com.android.car.settings.common.BaseFragment;
import com.android.internal.widget.LockscreenCredential;
+import com.android.internal.widget.TextViewInputDisabler;
/**
* Fragment for confirming existing lock PIN or password. The containing activity must implement
@@ -56,6 +57,11 @@
private boolean mIsPin;
private LockscreenCredential mEnteredPassword;
+ private ConfirmLockLockoutHelper mConfirmLockLockoutHelper;
+
+ private TextViewInputDisabler mPasswordEntryInputDisabler;
+ private InputMethodManager mImm;
+
/**
* Factory method for creating fragment in PIN mode.
*/
@@ -98,12 +104,15 @@
} else {
throw new RuntimeException("The activity must implement CheckLockListener");
}
+
+ mUserId = UserHandle.myUserId();
+ mConfirmLockLockoutHelper = ConfirmLockLockoutHelper.getInstance(requireContext(), mUserId);
+ mImm = requireContext().getSystemService(InputMethodManager.class);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- mUserId = UserHandle.myUserId();
Bundle args = getArguments();
if (args != null) {
mIsPin = args.getBoolean(EXTRA_IS_PIN);
@@ -115,7 +124,24 @@
super.onViewCreated(view, savedInstanceState);
mPasswordField = view.findViewById(R.id.password_entry);
+ mPasswordEntryInputDisabler = new TextViewInputDisabler(mPasswordField);
mMsgView = view.findViewById(R.id.message);
+ mConfirmLockLockoutHelper.setConfirmLockUIController(
+ new ConfirmLockLockoutHelper.ConfirmLockUIController() {
+ @Override
+ public void setErrorText(String text) {
+ mMsgView.setText(text);
+ }
+
+ @Override
+ public void refreshUI(boolean isLockedOut) {
+ if (mIsPin) {
+ updatePinEntry(isLockedOut);
+ } else {
+ updatePasswordEntry(isLockedOut);
+ }
+ }
+ });
if (mIsPin) {
initPinView(view);
@@ -138,6 +164,20 @@
}
@Override
+ public void onResume() {
+ super.onResume();
+
+ mConfirmLockLockoutHelper.onResumeUI();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+
+ mConfirmLockLockoutHelper.onPauseUI();
+ }
+
+ @Override
public void onStop() {
super.onStop();
if (mCheckLockWorker != null) {
@@ -261,14 +301,19 @@
}
@VisibleForTesting
- void onCheckCompleted(boolean lockMatched) {
+ void onCheckCompleted(boolean lockMatched, int timeoutMs) {
if (lockMatched) {
mCheckLockListener.onLockVerified(mEnteredPassword);
} else {
- mMsgView.setText(
- mIsPin ? R.string.lockscreen_wrong_pin : R.string.lockscreen_wrong_password);
- if (mIsPin) {
- mPinPad.setEnabled(true);
+ if (timeoutMs > 0) {
+ mConfirmLockLockoutHelper.onCheckCompletedWithTimeout(timeoutMs);
+ } else {
+ mMsgView.setText(
+ mIsPin ? R.string.lockscreen_wrong_pin
+ : R.string.lockscreen_wrong_password);
+ if (mIsPin) {
+ mPinPad.setEnabled(true);
+ }
}
}
@@ -276,4 +321,27 @@
hideKeyboard();
}
}
+
+ private void updatePasswordEntry(boolean isLockedOut) {
+ mPasswordField.setEnabled(!isLockedOut);
+ mPasswordEntryInputDisabler.setInputEnabled(!isLockedOut);
+ if (isLockedOut) {
+ if (mImm != null) {
+ mImm.hideSoftInputFromWindow(mPasswordField.getWindowToken(), /* flags= */ 0);
+ }
+ } else {
+ mPasswordField.requestFocus();
+ }
+ }
+
+ private void updatePinEntry(boolean isLockedOut) {
+ mPinPad.setEnabled(!isLockedOut);
+ if (isLockedOut) {
+ if (mImm != null) {
+ mImm.hideSoftInputFromWindow(mPinPad.getWindowToken(), /* flags= */ 0);
+ }
+ } else {
+ mPinPad.requestFocus();
+ }
+ }
}
diff --git a/tests/robotests/src/com/android/car/settings/security/ConfirmLockPinPasswordFragmentTest.java b/tests/robotests/src/com/android/car/settings/security/ConfirmLockPinPasswordFragmentTest.java
index b24ca49..3762d9b 100644
--- a/tests/robotests/src/com/android/car/settings/security/ConfirmLockPinPasswordFragmentTest.java
+++ b/tests/robotests/src/com/android/car/settings/security/ConfirmLockPinPasswordFragmentTest.java
@@ -58,7 +58,7 @@
View enterKey = mPinFragment.getView().findViewById(R.id.key_enter);
enterKey.setEnabled(false);
- mPinFragment.onCheckCompleted(false);
+ mPinFragment.onCheckCompleted(false, 0);
assertThat(enterKey.isEnabled()).isTrue();
}
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index f5848f0..33b0d6f 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -15,6 +15,10 @@
"androidx.test.rules",
"androidx.test.ext.junit",
"androidx.test.ext.truth",
+ "mockito-target-minus-junit4",
+ "platform-test-annotations",
+ "truth-prebuilt",
+ "testng",
],
instrumentation_for: "CarSettingsForTesting",
diff --git a/tests/unit/src/com/android/car/settings/security/ConfirmLockLockoutHelperTest.java b/tests/unit/src/com/android/car/settings/security/ConfirmLockLockoutHelperTest.java
new file mode 100644
index 0000000..0ca7390
--- /dev/null
+++ b/tests/unit/src/com/android/car/settings/security/ConfirmLockLockoutHelperTest.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2020 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.car.settings.security;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.CountDownTimer;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.internal.runner.junit4.statement.UiThreadStatement;
+
+import com.android.internal.widget.LockPatternUtils;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+public class ConfirmLockLockoutHelperTest {
+ private static final int TEST_USER = 100;
+
+ private final Context mContext = ApplicationProvider.getApplicationContext();
+ private ConfirmLockLockoutHelper mConfirmLockLockoutHelper;
+
+ @Mock
+ private LockPatternUtils mLockPatternUtils;
+ @Mock
+ private ConfirmLockLockoutHelper.ConfirmLockUIController mUIController;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ mConfirmLockLockoutHelper = new ConfirmLockLockoutHelper(mContext, TEST_USER,
+ mLockPatternUtils);
+ mConfirmLockLockoutHelper.setConfirmLockUIController(mUIController);
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsZero_doesNothing() {
+ runOnCheckCompletedWithTimeout(0);
+
+ verify(mLockPatternUtils, never()).setLockoutAttemptDeadline(TEST_USER, 0);
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsZero_noTimer() {
+ runOnCheckCompletedWithTimeout(0);
+
+ assertThat(mConfirmLockLockoutHelper.getCountDownTimer()).isNull();
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsPositive_setsLockoutDeadline() {
+ runOnCheckCompletedWithTimeout(1000);
+
+ verify(mLockPatternUtils).setLockoutAttemptDeadline(TEST_USER, 1000);
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsPositive_refreshesUILocked() {
+ when(mLockPatternUtils.getLockoutAttemptDeadline(TEST_USER)).thenReturn(1000L);
+ runOnCheckCompletedWithTimeout(1000);
+
+ verify(mUIController).refreshUI(true);
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsPositive_createsTimer() {
+ runOnCheckCompletedWithTimeout(1000);
+
+ assertThat(mConfirmLockLockoutHelper.getCountDownTimer()).isNotNull();
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsPositive_timerTickUpdatesErrorText() {
+ runOnCheckCompletedWithTimeout(1000);
+
+ reset(mUIController);
+ CountDownTimer timer = mConfirmLockLockoutHelper.getCountDownTimer();
+ timer.onTick(1000);
+
+ verify(mUIController).setErrorText(contains("1"));
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsPositive_onFinishUpdatesErrorText() {
+ runOnCheckCompletedWithTimeout(1000);
+
+ reset(mUIController);
+ CountDownTimer timer = mConfirmLockLockoutHelper.getCountDownTimer();
+ timer.onFinish();
+
+ verify(mUIController).setErrorText("");
+ }
+
+ @Test
+ public void onCheckCompletedWithTimeout_timeoutIsPositive_onFinishUpdatesUINotLocked() {
+ runOnCheckCompletedWithTimeout(1000);
+
+ reset(mUIController);
+ CountDownTimer timer = mConfirmLockLockoutHelper.getCountDownTimer();
+ timer.onFinish();
+
+ verify(mUIController).refreshUI(false);
+ }
+
+ private void runOnCheckCompletedWithTimeout(int timeout) {
+ try {
+ // Needs to be called on the UI thread due to the CountDownTimer.
+ UiThreadStatement.runOnUiThread(() -> {
+ mConfirmLockLockoutHelper.onCheckCompletedWithTimeout(timeout);
+ });
+ } catch (Throwable throwable) {
+ throwable.printStackTrace();
+ }
+ }
+}