瀏覽代碼

Merge pull request #12148 from nextcloud/refactor/passcode-activity-convert-to-kt

Convert PasscodeActivity To Kotlin, Material Design 3 Implementation
Andy Scherzinger 1 年之前
父節點
當前提交
47b66d67c5

二進制
app/screenshots/gplay/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_check.png


二進制
app/screenshots/gplay/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_delete.png


二進制
app/screenshots/gplay/debug/com.owncloud.android.ui.activity.PassCodeActivityIT_request.png


+ 0 - 478
app/src/main/java/com/owncloud/android/ui/activity/PassCodeActivity.java

@@ -1,478 +0,0 @@
-/*
- *   ownCloud Android client application
- *
- *   @author Bartek Przybylski
- *   @author masensio
- *   @author David A. Velasco
- *   Copyright (C) 2011 Bartek Przybylski
- *   Copyright (C) 2015 ownCloud Inc.
- *   Copyright (C) 2020 Kwon Yuna <yunaghgh@naver.com>
- *
- *   This program is free software: you can redistribute it and/or modify
- *   it under the terms of the GNU General Public License version 2,
- *   as published by the Free Software Foundation.
- *
- *   This program is distributed in the hope that it will be useful,
- *   but WITHOUT ANY WARRANTY; without even the implied warranty of
- *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- *   GNU General Public License for more details.
- *
- *   You should have received a copy of the GNU General Public License
- *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
- *
- */
-package com.owncloud.android.ui.activity;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.text.Editable;
-import android.text.TextUtils;
-import android.text.TextWatcher;
-import android.view.KeyEvent;
-import android.view.View;
-import android.view.Window;
-import android.view.inputmethod.InputMethodManager;
-import android.widget.EditText;
-
-import com.google.android.material.snackbar.Snackbar;
-import com.nextcloud.client.di.Injectable;
-import com.nextcloud.client.preferences.AppPreferences;
-import com.owncloud.android.R;
-import com.owncloud.android.authentication.PassCodeManager;
-import com.owncloud.android.databinding.PasscodelockBinding;
-import com.owncloud.android.lib.common.utils.Log_OC;
-import com.owncloud.android.ui.components.PassCodeEditText;
-import com.owncloud.android.utils.theme.ViewThemeUtils;
-
-import java.util.Arrays;
-
-import javax.inject.Inject;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.VisibleForTesting;
-import androidx.appcompat.app.AppCompatActivity;
-
-public class PassCodeActivity extends AppCompatActivity implements Injectable {
-
-    private static final String TAG = PassCodeActivity.class.getSimpleName();
-    private static final String KEY_PASSCODE_DIGITS = "PASSCODE_DIGITS";
-    private static final String KEY_CONFIRMING_PASSCODE = "CONFIRMING_PASSCODE";
-
-    public final static String ACTION_REQUEST_WITH_RESULT = "ACTION_REQUEST_WITH_RESULT";
-    public final static String ACTION_CHECK_WITH_RESULT = "ACTION_CHECK_WITH_RESULT";
-    public final static String ACTION_CHECK = "ACTION_CHECK";
-    public final static String KEY_PASSCODE = "KEY_PASSCODE";
-    public final static String KEY_CHECK_RESULT = "KEY_CHECK_RESULT";
-
-    public final static String PREFERENCE_PASSCODE_D = "PrefPinCode";
-    public final static String PREFERENCE_PASSCODE_D1 = "PrefPinCode1";
-    public final static String PREFERENCE_PASSCODE_D2 = "PrefPinCode2";
-    public final static String PREFERENCE_PASSCODE_D3 = "PrefPinCode3";
-    public final static String PREFERENCE_PASSCODE_D4 = "PrefPinCode4";
-
-    @Inject AppPreferences preferences;
-    @Inject PassCodeManager passCodeManager;
-    @Inject ViewThemeUtils viewThemeUtils;
-    private PasscodelockBinding binding;
-    private final PassCodeEditText[] passCodeEditTexts = new PassCodeEditText[4];
-    private String[] passCodeDigits = {"", "", "", ""};
-    private boolean confirmingPassCode;
-    private boolean changed = true; // to control that only one blocks jump
-
-    /**
-     * Initializes the activity.
-     * <p>
-     * An intent with a valid ACTION is expected; if none is found, an {@link IllegalArgumentException} will be thrown.
-     *
-     * @param savedInstanceState Previously saved state - irrelevant in this case
-     */
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        binding = PasscodelockBinding.inflate(getLayoutInflater());
-        setContentView(binding.getRoot());
-
-        viewThemeUtils.platform.colorTextButtons(binding.cancel);
-
-        passCodeEditTexts[0] = binding.txt0;
-        passCodeEditTexts[1] = binding.txt1;
-        passCodeEditTexts[2] = binding.txt2;
-        passCodeEditTexts[3] = binding.txt3;
-
-        for (EditText passCodeEditText : passCodeEditTexts) {
-            viewThemeUtils.platform.colorEditText(passCodeEditText);
-        }
-
-        passCodeEditTexts[0].requestFocus();
-
-        Window window = getWindow();
-        if (window != null) {
-            window.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
-        }
-
-        if (ACTION_CHECK.equals(getIntent().getAction())) {
-            /// this is a pass code request; the user has to input the right value
-            binding.header.setText(R.string.pass_code_enter_pass_code);
-            binding.explanation.setVisibility(View.INVISIBLE);
-            setCancelButtonEnabled(false);      // no option to cancel
-
-            showDelay();
-
-        } else if (ACTION_REQUEST_WITH_RESULT.equals(getIntent().getAction())) {
-            if (savedInstanceState != null) {
-                confirmingPassCode = savedInstanceState.getBoolean(PassCodeActivity.KEY_CONFIRMING_PASSCODE);
-                passCodeDigits = savedInstanceState.getStringArray(PassCodeActivity.KEY_PASSCODE_DIGITS);
-            }
-            if (confirmingPassCode) {
-                // the app was in the passcode confirmation
-                requestPassCodeConfirmation();
-            } else {
-                // pass code preference has just been activated in SettingsActivity;
-                // will receive and confirm pass code value
-                binding.header.setText(R.string.pass_code_configure_your_pass_code);
-
-                binding.explanation.setVisibility(View.VISIBLE);
-            }
-            setCancelButtonEnabled(true);
-
-        } else if (ACTION_CHECK_WITH_RESULT.equals(getIntent().getAction())) {
-            // pass code preference has just been disabled in SettingsActivity;
-            // will confirm user knows pass code, then remove it
-            binding.header.setText(R.string.pass_code_remove_your_pass_code);
-            binding.explanation.setVisibility(View.INVISIBLE);
-            setCancelButtonEnabled(true);
-
-        } else {
-            throw new IllegalArgumentException("A valid ACTION is needed in the Intent passed to " + TAG);
-        }
-
-        setTextListeners();
-    }
-
-    /**
-     * Enables or disables the cancel button to allow the user interrupt the ACTION requested to the activity.
-     *
-     * @param enabled 'True' makes the cancel button available, 'false' hides it.
-     */
-    protected void setCancelButtonEnabled(boolean enabled) {
-        if (enabled) {
-            binding.cancel.setVisibility(View.VISIBLE);
-            binding.cancel.setOnClickListener(v -> finish());
-        } else {
-            binding.cancel.setVisibility(View.INVISIBLE);
-            binding.cancel.setOnClickListener(null);
-        }
-    }
-
-    @VisibleForTesting
-    public PasscodelockBinding getBinding() {
-        return binding;
-    }
-
-    /**
-     * Binds the appropriate listeners to the input boxes receiving each digit of the pass code.
-     */
-    protected void setTextListeners() {
-        for (int i = 0; i < passCodeEditTexts.length; i++) {
-            final PassCodeEditText editText = passCodeEditTexts[i];
-            boolean isLast = (i == 3);
-
-            editText.addTextChangedListener(new PassCodeDigitTextWatcher(i, isLast));
-            if (i > 0) {
-                setOnKeyListener(i);
-            }
-
-            int finalIndex = i;
-            editText.setOnFocusChangeListener((v, hasFocus) -> onPassCodeEditTextFocusChange(finalIndex));
-        }
-    }
-
-    private void onPassCodeEditTextFocusChange(final int passCodeIndex) {
-        for (int i = 0; i < passCodeIndex; i++) {
-            if (TextUtils.isEmpty(passCodeEditTexts[i].getText())) {
-                passCodeEditTexts[i].requestFocus();
-                break;
-            }
-        }
-    }
-
-    private void setOnKeyListener(final int passCodeIndex) {
-        passCodeEditTexts[passCodeIndex].setOnKeyListener((v, keyCode, event) -> {
-            if (keyCode == KeyEvent.KEYCODE_DEL && changed) {
-                passCodeEditTexts[passCodeIndex - 1].requestFocus();
-                if (!confirmingPassCode) {
-                    passCodeDigits[passCodeIndex - 1] = "";
-                }
-                passCodeEditTexts[passCodeIndex - 1].setText("");
-                changed = false;
-
-            } else if (!changed) {
-                changed = true;
-            }
-            return false;
-        });
-    }
-
-    /**
-     * Processes the pass code entered by the user just after the last digit was in.
-     * <p>
-     * Takes into account the action requested to the activity, the currently saved pass code and the previously typed
-     * pass code, if any.
-     */
-    private void processFullPassCode() {
-        if (ACTION_CHECK.equals(getIntent().getAction())) {
-            if (checkPassCode()) {
-                preferences.resetPinWrongAttempts();
-
-                /// pass code accepted in request, user is allowed to access the app
-                passCodeManager.updateLockTimestamp();
-                hideSoftKeyboard();
-                finish();
-
-            } else {
-                preferences.increasePinWrongAttempts();
-
-                showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE);
-            }
-
-        } else if (ACTION_CHECK_WITH_RESULT.equals(getIntent().getAction())) {
-            if (checkPassCode()) {
-                passCodeManager.updateLockTimestamp();
-                Intent resultIntent = new Intent();
-                resultIntent.putExtra(KEY_CHECK_RESULT, true);
-                setResult(RESULT_OK, resultIntent);
-                hideSoftKeyboard();
-                finish();
-            } else {
-                showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE);
-            }
-
-        } else if (ACTION_REQUEST_WITH_RESULT.equals(getIntent().getAction())) {
-            /// enabling pass code
-            if (!confirmingPassCode) {
-                requestPassCodeConfirmation();
-
-            } else if (confirmPassCode()) {
-                /// confirmed: user typed the same pass code twice
-                savePassCodeAndExit();
-
-            } else {
-                showErrorAndRestart(R.string.pass_code_mismatch, R.string.pass_code_configure_your_pass_code, View.VISIBLE);
-            }
-        }
-    }
-
-    private void hideSoftKeyboard() {
-        View focusedView = getCurrentFocus();
-        if (focusedView != null) {
-            InputMethodManager inputMethodManager =
-                (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
-            inputMethodManager.hideSoftInputFromWindow(
-                focusedView.getWindowToken(),
-                0);
-        }
-    }
-
-    private void showErrorAndRestart(int errorMessage, int headerMessage, int explanationVisibility) {
-        Arrays.fill(passCodeDigits, null);
-        Snackbar.make(findViewById(android.R.id.content), getString(errorMessage), Snackbar.LENGTH_LONG).show();
-        binding.header.setText(headerMessage);                          // TODO check if really needed
-        binding.explanation.setVisibility(explanationVisibility); // TODO check if really needed
-        clearBoxes();
-
-        showDelay();
-    }
-
-
-    /**
-     * Ask to the user for retyping the pass code just entered before saving it as the current pass code.
-     */
-    protected void requestPassCodeConfirmation() {
-        clearBoxes();
-        binding.header.setText(R.string.pass_code_reenter_your_pass_code);
-        binding.explanation.setVisibility(View.INVISIBLE);
-        confirmingPassCode = true;
-    }
-
-    /**
-     * Compares pass code entered by the user with the value currently saved in the app.
-     *
-     * @return 'True' if entered pass code equals to the saved one.
-     */
-    protected boolean checkPassCode() {
-        String[] savedPassCodeDigits = preferences.getPassCode();
-
-        boolean result = true;
-        for (int i = 0; i < passCodeDigits.length && result; i++) {
-            result = passCodeDigits[i] != null && passCodeDigits[i].equals(savedPassCodeDigits[i]);
-        }
-        return result;
-    }
-
-    /**
-     * Compares pass code retyped by the user in the input fields with the value entered just before.
-     *
-     * @return 'True' if retyped pass code equals to the entered before.
-     */
-    protected boolean confirmPassCode() {
-        confirmingPassCode = false;
-
-        for (int i = 0; i < passCodeEditTexts.length; i++) {
-            Editable passCodeText = passCodeEditTexts[i].getText();
-            if (passCodeText == null || !passCodeText.toString().equals(passCodeDigits[i])) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    /**
-     * Sets the input fields to empty strings and puts the focus on the first one.
-     */
-    protected void clearBoxes() {
-        for (EditText mPassCodeEditText : passCodeEditTexts) {
-            mPassCodeEditText.setText("");
-        }
-        passCodeEditTexts[0].requestFocus();
-    }
-
-    /**
-     * Overrides click on the BACK arrow to correctly cancel ACTION_ENABLE or ACTION_DISABLE, while preventing than
-     * ACTION_CHECK may be worked around.
-     *
-     * @param keyCode Key code of the key that triggered the down event.
-     * @param event   Event triggered.
-     * @return 'True' when the key event was processed by this method.
-     */
-    @Override
-    public boolean onKeyDown(int keyCode, KeyEvent event) {
-        if (keyCode == KeyEvent.KEYCODE_BACK && event.getRepeatCount() == 0) {
-            if (ACTION_CHECK.equals(getIntent().getAction())) {
-                moveTaskToBack(true);
-                finishAndRemoveTask();
-            } else if (ACTION_REQUEST_WITH_RESULT.equals(getIntent().getAction()) ||
-                ACTION_CHECK_WITH_RESULT.equals(getIntent().getAction())) {
-                finish();
-            }// else, do nothing, but report that the key was consumed to stay alive
-            return true;
-        }
-        return super.onKeyDown(keyCode, event);
-    }
-
-    /**
-     * Saves the pass code input by the user as the current pass code.
-     */
-    protected void savePassCodeAndExit() {
-        Intent resultIntent = new Intent();
-        resultIntent.putExtra(KEY_PASSCODE,
-                              passCodeDigits[0] + passCodeDigits[1] + passCodeDigits[2] + passCodeDigits[3]);
-
-        setResult(RESULT_OK, resultIntent);
-
-        passCodeManager.updateLockTimestamp();
-
-        finish();
-    }
-
-    private void showDelay() {
-        int delay = preferences.pinBruteForceDelay();
-
-        if (delay > 0) {
-            binding.explanation.setText(R.string.brute_force_delay);
-            binding.explanation.setVisibility(View.VISIBLE);
-            binding.txt0.setEnabled(false);
-            binding.txt1.setEnabled(false);
-            binding.txt2.setEnabled(false);
-            binding.txt3.setEnabled(false);
-
-            new Thread(new Runnable() {
-                @Override
-                public void run() {
-                    try {
-                        Thread.sleep(delay * 1000L);
-
-                        runOnUiThread(() -> {
-                            binding.explanation.setVisibility(View.INVISIBLE);
-                            binding.txt0.setEnabled(true);
-                            binding.txt1.setEnabled(true);
-                            binding.txt2.setEnabled(true);
-                            binding.txt3.setEnabled(true);
-                        });
-                    } catch (InterruptedException e) {
-                        Log_OC.e(this, "Could not delay password input prompt");
-                    }
-                }
-            }).start();
-        }
-    }
-
-    @Override
-    public void onSaveInstanceState(@NonNull Bundle outState) {
-        super.onSaveInstanceState(outState);
-        outState.putBoolean(PassCodeActivity.KEY_CONFIRMING_PASSCODE, confirmingPassCode);
-        outState.putStringArray(PassCodeActivity.KEY_PASSCODE_DIGITS, passCodeDigits);
-    }
-
-    private class PassCodeDigitTextWatcher implements TextWatcher {
-
-        private int mIndex = -1;
-        private boolean mLastOne;
-
-        /**
-         * Constructor
-         *
-         * @param index   Position in the pass code of the input field that will be bound to this watcher.
-         * @param lastOne 'True' means that watcher corresponds to the last position of the pass code.
-         */
-        PassCodeDigitTextWatcher(int index, boolean lastOne) {
-            mIndex = index;
-            mLastOne = lastOne;
-
-            if (mIndex < 0) {
-                throw new IllegalArgumentException(
-                    "Invalid index in " + PassCodeDigitTextWatcher.class.getSimpleName() +
-                        " constructor"
-                );
-            }
-        }
-
-        private int next() {
-            return mLastOne ? 0 : mIndex + 1;
-        }
-
-        /**
-         * Performs several actions when the user types a digit in an input field: - saves the input digit to the state
-         * of the activity; this will allow retyping the pass code to confirm it. - moves the focus automatically to the
-         * next field - for the last field, triggers the processing of the full pass code
-         *
-         * @param s Changed text
-         */
-        @Override
-        public void afterTextChanged(Editable s) {
-            if (s.length() > 0) {
-                if (!confirmingPassCode) {
-                    Editable passCodeText = passCodeEditTexts[mIndex].getText();
-
-                    if (passCodeText != null) {
-                        passCodeDigits[mIndex] = passCodeText.toString();
-                    }
-                }
-
-                if (mLastOne) {
-                    processFullPassCode();
-                } else {
-                    passCodeEditTexts[next()].requestFocus();
-                }
-
-            } else {
-                Log_OC.d(TAG, "Text box " + mIndex + " was cleaned");
-            }
-        }
-
-        @Override
-        public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
-
-        @Override
-        public void onTextChanged(CharSequence s, int start, int before, int count) {}
-    }
-
-}

+ 437 - 0
app/src/main/java/com/owncloud/android/ui/activity/PassCodeActivity.kt

@@ -0,0 +1,437 @@
+/*
+ *   ownCloud Android client application
+ *
+ *   @author Bartek Przybylski
+ *   @author masensio
+ *   @author David A. Velasco
+ *   Copyright (C) 2011 Bartek Przybylski
+ *   Copyright (C) 2015 ownCloud Inc.
+ *   Copyright (C) 2020 Kwon Yuna <yunaghgh@naver.com>
+ *
+ *   This program is free software: you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License version 2,
+ *   as published by the Free Software Foundation.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+package com.owncloud.android.ui.activity
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextUtils
+import android.text.TextWatcher
+import android.view.KeyEvent
+import android.view.View
+import android.view.WindowManager
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.snackbar.Snackbar
+import com.nextcloud.android.common.ui.theme.utils.ColorRole
+import com.nextcloud.client.di.Injectable
+import com.nextcloud.client.preferences.AppPreferences
+import com.owncloud.android.R
+import com.owncloud.android.authentication.PassCodeManager
+import com.owncloud.android.databinding.PasscodelockBinding
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.ui.components.PassCodeEditText
+import com.owncloud.android.utils.theme.ViewThemeUtils
+import java.util.Arrays
+import javax.inject.Inject
+
+@Suppress("TooManyFunctions", "MagicNumber")
+class PassCodeActivity : AppCompatActivity(), Injectable {
+
+    companion object {
+        private val TAG = PassCodeActivity::class.java.simpleName
+
+        private const val KEY_PASSCODE_DIGITS = "PASSCODE_DIGITS"
+        private const val KEY_CONFIRMING_PASSCODE = "CONFIRMING_PASSCODE"
+        const val ACTION_REQUEST_WITH_RESULT = "ACTION_REQUEST_WITH_RESULT"
+        const val ACTION_CHECK_WITH_RESULT = "ACTION_CHECK_WITH_RESULT"
+        const val ACTION_CHECK = "ACTION_CHECK"
+        const val KEY_PASSCODE = "KEY_PASSCODE"
+        const val KEY_CHECK_RESULT = "KEY_CHECK_RESULT"
+        const val PREFERENCE_PASSCODE_D = "PrefPinCode"
+        const val PREFERENCE_PASSCODE_D1 = "PrefPinCode1"
+        const val PREFERENCE_PASSCODE_D2 = "PrefPinCode2"
+        const val PREFERENCE_PASSCODE_D3 = "PrefPinCode3"
+        const val PREFERENCE_PASSCODE_D4 = "PrefPinCode4"
+    }
+
+    @JvmField
+    @Inject
+    var preferences: AppPreferences? = null
+
+    @JvmField
+    @Inject
+    var passCodeManager: PassCodeManager? = null
+
+    @JvmField
+    @Inject
+    var viewThemeUtils: ViewThemeUtils? = null
+
+    @get:VisibleForTesting
+    lateinit var binding: PasscodelockBinding
+        private set
+
+    private val passCodeEditTexts = arrayOfNulls<PassCodeEditText>(4)
+    private var passCodeDigits: Array<String?>? = arrayOf("", "", "", "")
+    private var confirmingPassCode = false
+    private var changed = true // to control that only one blocks jump
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        binding = PasscodelockBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        applyTint()
+        setupPasscodeEditTexts()
+        setSoftInputMode()
+        setupUI(savedInstanceState)
+        setTextListeners()
+    }
+
+    private fun applyTint() {
+        viewThemeUtils?.platform?.colorViewBackground(binding.cardViewContent, ColorRole.SURFACE_VARIANT)
+        viewThemeUtils?.material?.colorMaterialButtonPrimaryBorderless(binding.cancel)
+    }
+
+    private fun setupPasscodeEditTexts() {
+        passCodeEditTexts[0] = binding.txt0
+        passCodeEditTexts[1] = binding.txt1
+        passCodeEditTexts[2] = binding.txt2
+        passCodeEditTexts[3] = binding.txt3
+
+        passCodeEditTexts.forEach {
+            it?.let { viewThemeUtils?.platform?.colorEditText(it) }
+        }
+
+        passCodeEditTexts[0]?.requestFocus()
+    }
+
+    private fun setSoftInputMode() {
+        window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
+    }
+
+    private fun setupUI(savedInstanceState: Bundle?) {
+        if (ACTION_CHECK == intent.action) {
+            // / this is a pass code request; the user has to input the right value
+            binding.header.setText(R.string.pass_code_enter_pass_code)
+            binding.explanation.visibility = View.INVISIBLE
+            setCancelButtonEnabled(false) // no option to cancel
+            showDelay()
+        } else if (ACTION_REQUEST_WITH_RESULT == intent.action) {
+            if (savedInstanceState != null) {
+                confirmingPassCode = savedInstanceState.getBoolean(KEY_CONFIRMING_PASSCODE)
+                passCodeDigits = savedInstanceState.getStringArray(KEY_PASSCODE_DIGITS)
+            }
+            if (confirmingPassCode) {
+                // the app was in the passcode confirmation
+                requestPassCodeConfirmation()
+            } else {
+                // pass code preference has just been activated in SettingsActivity;
+                // will receive and confirm pass code value
+                binding.header.setText(R.string.pass_code_configure_your_pass_code)
+                binding.explanation.visibility = View.VISIBLE
+            }
+            setCancelButtonEnabled(true)
+        } else if (ACTION_CHECK_WITH_RESULT == intent.action) {
+            // pass code preference has just been disabled in SettingsActivity;
+            // will confirm user knows pass code, then remove it
+            binding.header.setText(R.string.pass_code_remove_your_pass_code)
+            binding.explanation.visibility = View.INVISIBLE
+            setCancelButtonEnabled(true)
+        } else {
+            throw IllegalArgumentException("A valid ACTION is needed in the Intent passed to $TAG")
+        }
+    }
+
+    private fun setCancelButtonEnabled(enabled: Boolean) {
+        binding.cancel.visibility = if (enabled) {
+            View.VISIBLE
+        } else {
+            View.INVISIBLE
+        }
+        binding.cancel.setOnClickListener {
+            if (enabled) {
+                finish()
+            }
+        }
+    }
+
+    private fun setTextListeners() {
+        for (i in passCodeEditTexts.indices) {
+            val editText = passCodeEditTexts[i]
+            val isLast = (i == 3)
+
+            editText?.addTextChangedListener(PassCodeDigitTextWatcher(i, isLast))
+
+            if (i > 0) {
+                setOnKeyListener(i)
+            }
+
+            editText?.onFocusChangeListener = View.OnFocusChangeListener { _: View?, _: Boolean ->
+                onPassCodeEditTextFocusChange(i)
+            }
+        }
+    }
+
+    private fun onPassCodeEditTextFocusChange(passCodeIndex: Int) {
+        for (i in 0 until passCodeIndex) {
+            if (TextUtils.isEmpty(passCodeEditTexts[i]?.text)) {
+                passCodeEditTexts[i]?.requestFocus()
+                break
+            }
+        }
+    }
+
+    private fun setOnKeyListener(passCodeIndex: Int) {
+        passCodeEditTexts[passCodeIndex]?.setOnKeyListener { _: View?, keyCode: Int, _: KeyEvent? ->
+            if (keyCode == KeyEvent.KEYCODE_DEL && changed) {
+                passCodeEditTexts[passCodeIndex - 1]?.requestFocus()
+
+                if (!confirmingPassCode) {
+                    passCodeDigits?.set(passCodeIndex - 1, "")
+                }
+
+                passCodeEditTexts[passCodeIndex - 1]?.setText("")
+
+                changed = false
+            } else if (!changed) {
+                changed = true
+            }
+            false
+        }
+    }
+
+    /**
+     * Processes the pass code entered by the user just after the last digit was in.
+     *
+     *
+     * Takes into account the action requested to the activity, the currently saved pass code and the previously typed
+     * pass code, if any.
+     */
+    private fun processFullPassCode() {
+        if (ACTION_CHECK == intent.action) {
+            if (checkPassCode()) {
+                preferences?.resetPinWrongAttempts()
+
+                // / pass code accepted in request, user is allowed to access the app
+                passCodeManager?.updateLockTimestamp()
+                hideSoftKeyboard()
+                finish()
+            } else {
+                preferences?.increasePinWrongAttempts()
+                showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE)
+            }
+        } else if (ACTION_CHECK_WITH_RESULT == intent.action) {
+            if (checkPassCode()) {
+                passCodeManager?.updateLockTimestamp()
+
+                val resultIntent = Intent()
+                resultIntent.putExtra(KEY_CHECK_RESULT, true)
+                setResult(RESULT_OK, resultIntent)
+                hideSoftKeyboard()
+                finish()
+            } else {
+                showErrorAndRestart(R.string.pass_code_wrong, R.string.pass_code_enter_pass_code, View.INVISIBLE)
+            }
+        } else if (ACTION_REQUEST_WITH_RESULT == intent.action) {
+            // / enabling pass code
+            if (!confirmingPassCode) {
+                requestPassCodeConfirmation()
+            } else if (confirmPassCode()) {
+                // / confirmed: user typed the same pass code twice
+                savePassCodeAndExit()
+            } else {
+                showErrorAndRestart(
+                    R.string.pass_code_mismatch,
+                    R.string.pass_code_configure_your_pass_code,
+                    View.VISIBLE
+                )
+            }
+        }
+    }
+
+    private fun hideSoftKeyboard() {
+        currentFocus?.let {
+            val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+            inputMethodManager.hideSoftInputFromWindow(
+                it.windowToken,
+                0
+            )
+        }
+    }
+
+    private fun showErrorAndRestart(errorMessage: Int, headerMessage: Int, explanationVisibility: Int) {
+        passCodeDigits?.let { Arrays.fill(it, null) }
+
+        Snackbar.make(findViewById(android.R.id.content), getString(errorMessage), Snackbar.LENGTH_LONG).show()
+        binding.header.setText(headerMessage) // TODO check if really needed
+        binding.explanation.visibility = explanationVisibility // TODO check if really needed
+        clearBoxes()
+        showDelay()
+    }
+
+    /**
+     * Ask to the user for retyping the pass code just entered before saving it as the current pass code.
+     */
+    private fun requestPassCodeConfirmation() {
+        clearBoxes()
+        binding.header.setText(R.string.pass_code_reenter_your_pass_code)
+        binding.explanation.visibility = View.INVISIBLE
+        confirmingPassCode = true
+    }
+
+    private fun checkPassCode(): Boolean {
+        val savedPassCodeDigits = preferences?.passCode
+        return passCodeDigits?.zip(savedPassCodeDigits.orEmpty()) { input, saved ->
+            input != null && input == saved
+        }?.all { it } ?: false
+    }
+
+    private fun confirmPassCode(): Boolean {
+        return passCodeEditTexts.indices.all { i ->
+            passCodeEditTexts[i]?.text.toString() == passCodeDigits!![i]
+        }
+    }
+
+    private fun clearBoxes() {
+        passCodeEditTexts.forEach { it?.text?.clear() }
+        passCodeEditTexts.firstOrNull()?.requestFocus()
+    }
+
+    /**
+     * Overrides click on the BACK arrow to correctly cancel ACTION_ENABLE or ACTION_DISABLE, while preventing than
+     * ACTION_CHECK may be worked around.
+     *
+     * @param keyCode Key code of the key that triggered the down event.
+     * @param event   Event triggered.
+     * @return 'True' when the key event was processed by this method.
+     */
+    override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+        if (keyCode == KeyEvent.KEYCODE_BACK && event.repeatCount == 0) {
+            if (ACTION_CHECK == intent.action) {
+                moveTaskToBack(true)
+                finishAndRemoveTask()
+            } else if (ACTION_REQUEST_WITH_RESULT == intent.action || ACTION_CHECK_WITH_RESULT == intent.action) {
+                finish()
+            } // else, do nothing, but report that the key was consumed to stay alive
+            return true
+        }
+        return super.onKeyDown(keyCode, event)
+    }
+
+    private fun savePassCodeAndExit() {
+        val resultIntent = Intent()
+        resultIntent.putExtra(
+            KEY_PASSCODE,
+            passCodeDigits!![0] + passCodeDigits!![1] + passCodeDigits!![2] + passCodeDigits!![3]
+        )
+        setResult(RESULT_OK, resultIntent)
+        passCodeManager?.updateLockTimestamp()
+        finish()
+    }
+
+    private fun showDelay() {
+        val delay = preferences?.pinBruteForceDelay() ?: 0
+
+        if (delay <= 0) {
+            return
+        }
+
+        binding.explanation.setText(R.string.brute_force_delay)
+        binding.explanation.visibility = View.VISIBLE
+        binding.txt0.isEnabled = false
+        binding.txt1.isEnabled = false
+        binding.txt2.isEnabled = false
+        binding.txt3.isEnabled = false
+
+        Thread(object : Runnable {
+            override fun run() {
+                try {
+                    Thread.sleep(delay * 1000L)
+
+                    runOnUiThread {
+                        binding.explanation.visibility = View.INVISIBLE
+                        binding.txt0.isEnabled = true
+                        binding.txt1.isEnabled = true
+                        binding.txt2.isEnabled = true
+                        binding.txt3.isEnabled = true
+                    }
+                } catch (e: InterruptedException) {
+                    Log_OC.e(this, "Could not delay password input prompt")
+                }
+            }
+        }).start()
+    }
+
+    public override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putBoolean(KEY_CONFIRMING_PASSCODE, confirmingPassCode)
+        outState.putStringArray(KEY_PASSCODE_DIGITS, passCodeDigits)
+    }
+
+    private inner class PassCodeDigitTextWatcher(index: Int, lastOne: Boolean) : TextWatcher {
+        private var mIndex = -1
+        private val mLastOne: Boolean
+
+        init {
+            mIndex = index
+            mLastOne = lastOne
+
+            require(mIndex >= 0) {
+                "Invalid index in " + PassCodeDigitTextWatcher::class.java.simpleName +
+                    " constructor"
+            }
+        }
+
+        private operator fun next(): Int {
+            return if (mLastOne) 0 else mIndex + 1
+        }
+
+        /**
+         * Performs several actions when the user types a digit in an input field: - saves the input digit to the state
+         * of the activity; this will allow retyping the pass code to confirm it. - moves the focus automatically to the
+         * next field - for the last field, triggers the processing of the full pass code
+         *
+         * @param s Changed text
+         */
+        override fun afterTextChanged(s: Editable) {
+            if (s.isNotEmpty()) {
+                if (!confirmingPassCode) {
+                    val passCodeText = passCodeEditTexts[mIndex]?.text
+
+                    if (passCodeText != null) {
+                        passCodeDigits!![mIndex] = passCodeText.toString()
+                    }
+                }
+
+                if (mLastOne) {
+                    processFullPassCode()
+                } else {
+                    passCodeEditTexts[next()]?.requestFocus()
+                }
+            } else {
+                Log_OC.d(TAG, "Text box $mIndex was cleaned")
+            }
+        }
+
+        @Suppress("EmptyFunctionBlock")
+        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
+        }
+
+        @Suppress("EmptyFunctionBlock")
+        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
+        }
+    }
+}

+ 14 - 12
app/src/main/res/layout/passcodelock.xml

@@ -17,36 +17,36 @@
   You should have received a copy of the GNU General Public License
   along with this program.  If not, see <http://www.gnu.org/licenses/>.
 -->
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:gravity="center_horizontal">
 
-    <androidx.cardview.widget.CardView xmlns:card_view="http://schemas.android.com/apk/res-auto"
-        android:id="@+id/card_view"
+    <com.google.android.material.card.MaterialCardView
+        xmlns:card_view="http://schemas.android.com/apk/res-auto"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_gravity="center"
         android:layout_margin="@dimen/standard_double_margin"
-        card_view:cardCornerRadius="4dp"
+        card_view:strokeWidth="0dp"
+        card_view:cardCornerRadius="16dp"
         card_view:cardElevation="@dimen/dialog_elevation">
 
         <LinearLayout
+            android:id="@+id/card_view_content"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:orientation="vertical"
-            android:paddingStart="@dimen/standard_padding"
-            android:paddingTop="@dimen/standard_padding"
-            android:paddingEnd="@dimen/standard_padding"
-            android:paddingBottom="@dimen/standard_half_padding">
+            android:padding="@dimen/standard_padding">
 
             <LinearLayout
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:orientation="vertical">
 
-                <TextView
+                <com.google.android.material.textview.MaterialTextView
                     android:id="@+id/header"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
@@ -56,7 +56,7 @@
                     android:textSize="@dimen/two_line_primary_text_size"
                     android:textStyle="bold" />
 
-                <TextView
+                <com.google.android.material.textview.MaterialTextView
                     android:id="@+id/explanation"
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
@@ -96,12 +96,14 @@
 
             <com.google.android.material.button.MaterialButton
                 android:id="@+id/cancel"
-                style="@style/Button.Borderless"
+                style="@style/Widget.Material3.Button.TextButton"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_gravity="end"
                 android:text="@string/common_cancel" />
+
         </LinearLayout>
 
-    </androidx.cardview.widget.CardView>
+    </com.google.android.material.card.MaterialCardView>
+
 </ScrollView>