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();
+        }
+    }
+}