From 610e897b0b4eb74e61e63ca8fee03054acc0c28d Mon Sep 17 00:00:00 2001 From: inthewaves Date: Sun, 26 Jan 2025 03:33:37 -0800 Subject: [PATCH 1/2] implement PIN/passphrase generation UI Passphrases have been implemented using EFF's Long Wordlist of 6^5 words. In terms of entropy, an 8-word passphrase would have a search space of (6^5)^8 ~= 2^103. This wordlist can be easily changed; just make sure to update the various constants in the DicewareWordList class Test: Manual Test: atest -c SettingsUnitTests:com.android.settings.password.generate (after building with m and running emulator) Test: SetupWizard2 --- AndroidManifest.xml | 7 + assets/eff_large_wordlist.txt | 7776 +++++++++++++++++ res/drawable/ic_shuffle.xml | 11 + res/layout/generate_lock_password_confirm.xml | 84 + .../generate_lock_password_container.xml | 18 + .../generate_lock_password_show_generated.xml | 39 + ...lock_password_show_generated_list_item.xml | 34 + res/values/strings_ext.xml | 66 + res/xml/screen_lock_creation_choice.xml | 42 + res/xml/screen_lock_generation_params.xml | 19 + .../settings/password/ChooseLockGeneric.java | 9 + .../settings/password/ChooseLockPassword.java | 18 +- .../password/SaveAndFinishWorker.java | 2 +- .../password/SetupChooseLockPassword.java | 8 +- .../BaseLockPasswordGenerationFragment.kt | 51 + ...ockPasswordGenerationPreferenceFragment.kt | 78 + .../ConfirmGeneratedLockPassFragment.kt | 478 + .../password/generate/DicewareWordList.kt | 145 + .../password/generate/FlowExtensions.kt | 59 + .../generate/GenerateLockPasswordActivity.kt | 146 + .../generate/GenerateLockPasswordViewModel.kt | 707 ++ .../GeneratedOrManualLockPasswordFragment.kt | 345 + .../password/generate/GeneratedPassword.kt | 118 + .../LockPasswordGenerationParamsFragment.kt | 116 + .../password/generate/PassGenParams.kt | 35 + .../password/generate/PassGenStage.kt | 27 + .../password/generate/PasswordComplexity.kt | 30 + .../ShowGeneratedLockPassOptionsFragment.kt | 181 + .../widget/LabeledSeekBarPreference.java | 15 + .../password/generate/DicewareWordListTest.kt | 64 + .../generate/GenerateLockPasswordTest.kt | 523 ++ .../generate/PasswordComplexityTest.kt | 198 + 32 files changed, 11437 insertions(+), 12 deletions(-) create mode 100644 assets/eff_large_wordlist.txt create mode 100644 res/drawable/ic_shuffle.xml create mode 100644 res/layout/generate_lock_password_confirm.xml create mode 100644 res/layout/generate_lock_password_container.xml create mode 100644 res/layout/generate_lock_password_show_generated.xml create mode 100644 res/layout/generate_lock_password_show_generated_list_item.xml create mode 100644 res/xml/screen_lock_creation_choice.xml create mode 100644 res/xml/screen_lock_generation_params.xml create mode 100644 src/com/android/settings/password/generate/BaseLockPasswordGenerationFragment.kt create mode 100644 src/com/android/settings/password/generate/BaseLockPasswordGenerationPreferenceFragment.kt create mode 100644 src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt create mode 100644 src/com/android/settings/password/generate/DicewareWordList.kt create mode 100644 src/com/android/settings/password/generate/FlowExtensions.kt create mode 100644 src/com/android/settings/password/generate/GenerateLockPasswordActivity.kt create mode 100644 src/com/android/settings/password/generate/GenerateLockPasswordViewModel.kt create mode 100644 src/com/android/settings/password/generate/GeneratedOrManualLockPasswordFragment.kt create mode 100644 src/com/android/settings/password/generate/GeneratedPassword.kt create mode 100644 src/com/android/settings/password/generate/LockPasswordGenerationParamsFragment.kt create mode 100644 src/com/android/settings/password/generate/PassGenParams.kt create mode 100644 src/com/android/settings/password/generate/PassGenStage.kt create mode 100644 src/com/android/settings/password/generate/PasswordComplexity.kt create mode 100644 src/com/android/settings/password/generate/ShowGeneratedLockPassOptionsFragment.kt create mode 100644 tests/unit/src/com/android/settings/password/generate/DicewareWordListTest.kt create mode 100644 tests/unit/src/com/android/settings/password/generate/GenerateLockPasswordTest.kt create mode 100644 tests/unit/src/com/android/settings/password/generate/PasswordComplexityTest.kt diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 62213a35f83..a71221030cb 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -2977,6 +2977,13 @@ android:enableOnBackInvokedCallback="false" android:exported="false" /> + + + + + diff --git a/res/layout/generate_lock_password_confirm.xml b/res/layout/generate_lock_password_confirm.xml new file mode 100644 index 00000000000..5ae252ed1e0 --- /dev/null +++ b/res/layout/generate_lock_password_confirm.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/generate_lock_password_container.xml b/res/layout/generate_lock_password_container.xml new file mode 100644 index 00000000000..8a78d7fb067 --- /dev/null +++ b/res/layout/generate_lock_password_container.xml @@ -0,0 +1,18 @@ + + + + diff --git a/res/layout/generate_lock_password_show_generated.xml b/res/layout/generate_lock_password_show_generated.xml new file mode 100644 index 00000000000..e28cb82091d --- /dev/null +++ b/res/layout/generate_lock_password_show_generated.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/res/layout/generate_lock_password_show_generated_list_item.xml b/res/layout/generate_lock_password_show_generated_list_item.xml new file mode 100644 index 00000000000..525e79d73ee --- /dev/null +++ b/res/layout/generate_lock_password_show_generated_list_item.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/res/values/strings_ext.xml b/res/values/strings_ext.xml index 0def59629e3..3043dbd8589 100644 --- a/res/values/strings_ext.xml +++ b/res/values/strings_ext.xml @@ -220,4 +220,70 @@ Duress PIN works the same way duress password does." Not blocked Show apps that used the Play Integrity API + A 4-8 word diceware passphrase provides strong protection for your device. + A 6-8 digit PIN provides strong protection for your device via the secure element; however, it will not withstand a secure element compromise. If this is unacceptable, use a long diceware passphrase instead. + Can select a different screen lock type (passphrases / passwords) + Can select a different screen lock type (PIN) + Recommended + Other + Generate PIN + %1$d-%2$d digits, randomly generated + Use your own PIN + Generate diceware passphrase + %1$d-%2$d words, randomly chosen from wordlist + Use your own password + Disabled due to device password requirements + The secure element (Titan M chip) helps protect against brute-force attacks against disk encryption through hardware-based delays. This makes even randomly generated 6-digit PINs highly secure. + The secure element (Titan M chip) helps protect against brute-force attacks against disk encryption through hardware-based delays. + Learn more secure element and disk encryption + + Secure element + +"The secure element (Titan M on Pixels) is a tamper-resistant discrete security chip, and it helps increases the time and cost of breaking encryption significantly via hardware-based delays for key derivation. + +It is extremely unlikely that the chip's security has been bypassed; the firmware has low attack surface, and tamper resistance makes physical tampering difficult. + +However, if you cannot depend on these hardware-based security features (e.g. you have adversaries that have substantial resources and can gain physical possession of your device), use a strong passphrase for encryption such as a random diceware passphrase of 7 words or more. Consider also turning on auto reboot to keep data at rest when the device is unattended. + +Disk encryption + +Disk encryption keys are randomly generated with a high quality CSPRNG (cryptographically secure pseudorandom number generator) and stored encrypted with a key encryption key. Key encryption keys are derived at runtime and are never stored anywhere. + +The system derives a password token from your screen lock using scrypt, and the token is used as the main input for key derivation. + +The system also stores a high entropy random value (Weaver token) on the secure element and uses it as another input for key derivation. The Weaver token is stored alongside a Weaver key derived by the system from the password token. + +In order to retrieve the Weaver token, the secure element requires the correct Weaver key. A secure internal timer is used to implement hardware-based delays for each attempt at key derivation. It quickly ramps up to 1-day delays before the next attempt." + + Generate PIN + Multiple PINs will be randomly generated, and you will be asked to choose one for your screen lock. + Generate diceware passphrase + Multiple passphrases will be randomly generated, and you will be asked to choose one for your screen lock. + PIN length + %1$d digits + Passphrase length + %1$d words + Next + Generate new + Confirm + Warning: Some passphrases over %1$s words can exceed the max password length of %2$s and won\'t be included in generation, which results in a loss of entropy. + + Error generating passphrases: %1$s + Device password requirements too strict (%1$s) + Select a PIN + %1$d PINs have been generated. Select one to use as your screen lock. You should memorize your selection. + Select passphrase + %1$d passphrases have been generated. Select one to use as your screen lock. You should memorize your passphrase or write it down in a safe place.. + + Confirm your PIN + This PIN will be used as your screen lock. Make sure you remember this PIN. + Confirm your passphrase + This passphrase will be used as your screen lock. Make sure you remember or securely write down this passphrase. + Re-enter your PIN + Enter your PIN again to ensure you\'ve memorized it for your screen lock. + Enter your PIN one more time to confirm it as your screen lock. + Re-enter your passphrase + Enter your passphrase again to ensure you\'ve memorized it. + Enter your passphrase one more time to confirm it as your screen lock. + Passphrases don\'t match diff --git a/res/xml/screen_lock_creation_choice.xml b/res/xml/screen_lock_creation_choice.xml new file mode 100644 index 00000000000..e6526ec4f16 --- /dev/null +++ b/res/xml/screen_lock_creation_choice.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/res/xml/screen_lock_generation_params.xml b/res/xml/screen_lock_generation_params.xml new file mode 100644 index 00000000000..f4486bb87c0 --- /dev/null +++ b/res/xml/screen_lock_generation_params.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/com/android/settings/password/ChooseLockGeneric.java b/src/com/android/settings/password/ChooseLockGeneric.java index eddf2d99f9c..a0ffa513bf0 100644 --- a/src/com/android/settings/password/ChooseLockGeneric.java +++ b/src/com/android/settings/password/ChooseLockGeneric.java @@ -87,6 +87,7 @@ import com.android.settings.biometrics.IdentityCheckBiometricErrorDialog; import com.android.settings.core.SubSettingLauncher; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; +import com.android.settings.password.generate.GenerateLockPasswordActivity; import com.android.settings.safetycenter.LockScreenSafetySource; import com.android.settings.search.SearchFeatureProvider; import com.android.settingslib.RestrictedPreference; @@ -908,6 +909,14 @@ private Intent getIntentForUnlockMethod(int quality) { intent = getLockManagedPasswordIntent(mUserPassword); } else if (quality >= DevicePolicyManager.PASSWORD_QUALITY_NUMERIC) { intent = getLockPasswordIntent(quality); + // Take the extras meant for the original ChooseLockPassword activity + // and forward it. Will be used to detect if it's for password or PIN, and will also + // be forwarded to original lock password activity if user wants to use their own + // password. + final Intent generateLockPassIntent = + new Intent(getContext(), GenerateLockPasswordActivity.class); + generateLockPassIntent.putExtras(intent); + intent = generateLockPassIntent; } else if (quality == DevicePolicyManager.PASSWORD_QUALITY_SOMETHING) { intent = getLockPatternIntent(); } diff --git a/src/com/android/settings/password/ChooseLockPassword.java b/src/com/android/settings/password/ChooseLockPassword.java index d504a53d3b1..683b18b2cab 100644 --- a/src/com/android/settings/password/ChooseLockPassword.java +++ b/src/com/android/settings/password/ChooseLockPassword.java @@ -110,8 +110,8 @@ public class ChooseLockPassword extends SettingsActivity { private static final String TAG = "ChooseLockPassword"; - static final String EXTRA_KEY_MIN_METRICS = "min_metrics"; - static final String EXTRA_KEY_MIN_COMPLEXITY = "min_complexity"; + public static final String EXTRA_KEY_MIN_METRICS = "min_metrics"; + public static final String EXTRA_KEY_MIN_COMPLEXITY = "min_complexity"; @Override public Intent getIntent() { @@ -277,11 +277,11 @@ public static class ChooseLockPasswordFragment extends InstrumentedFragment private TextChangedHandler mTextChangedHandler; private static final int CONFIRM_EXISTING_REQUEST = 58; - static final int RESULT_FINISHED = RESULT_FIRST_USER; + public static final int RESULT_FINISHED = RESULT_FIRST_USER; private boolean mIsErrorTooShort = true; /** Used to store the profile type for which pin/password is being set */ - protected enum ProfileType { + public enum ProfileType { None, Managed, Private, @@ -292,7 +292,7 @@ protected enum ProfileType { /** * Keep track internally of where the user is in choosing a pattern. */ - protected enum Stage { + public enum Stage { Introduction( R.string.lockpassword_choose_your_password_header, // password @@ -491,7 +491,7 @@ public void onCreate(Bundle savedInstanceState) { } // Only take this argument into account if it belongs to the current profile. mUserId = Utils.getUserIdFromBundle(getActivity(), intent.getExtras()); - mProfileType = getProfileType(); + mProfileType = getProfileType(getContext(), mUserId); mForFingerprint = intent.getBooleanExtra( ChooseLockSettingsHelper.EXTRA_KEY_FOR_FINGERPRINT, false); mForFace = intent.getBooleanExtra(ChooseLockSettingsHelper.EXTRA_KEY_FOR_FACE, false); @@ -1060,7 +1060,7 @@ private void setAutoPinConfirmOption(boolean enabled, int length) { } } - private boolean isAutoPinConfirmPossible(int currentPinLength) { + public static boolean isAutoPinConfirmPossible(int currentPinLength) { return currentPinLength >= MIN_AUTO_PIN_REQUIREMENT_LENGTH; } @@ -1192,8 +1192,8 @@ public void handleMessage(Message msg) { } } - private ProfileType getProfileType() { - UserManager userManager = getContext().createContextAsUser(UserHandle.of(mUserId), + public static ProfileType getProfileType(Context context, int userId) { + UserManager userManager = context.createContextAsUser(UserHandle.of(userId), /*flags=*/0).getSystemService(UserManager.class); if (userManager.isManagedProfile()) { return ProfileType.Managed; diff --git a/src/com/android/settings/password/SaveAndFinishWorker.java b/src/com/android/settings/password/SaveAndFinishWorker.java index 5033eaccfe0..b91f917b46b 100644 --- a/src/com/android/settings/password/SaveAndFinishWorker.java +++ b/src/com/android/settings/password/SaveAndFinishWorker.java @@ -224,7 +224,7 @@ protected void onPostExecute(Pair resultData) { } } - interface Listener { + public interface Listener { void onChosenLockSaveFinished(boolean wasSecureBefore, Intent resultData); } } diff --git a/src/com/android/settings/password/SetupChooseLockPassword.java b/src/com/android/settings/password/SetupChooseLockPassword.java index f7bf014976a..fe70d298ca1 100644 --- a/src/com/android/settings/password/SetupChooseLockPassword.java +++ b/src/com/android/settings/password/SetupChooseLockPassword.java @@ -34,6 +34,7 @@ import com.android.settings.R; import com.android.settings.SetupRedactionInterstitial; import com.android.settings.password.ChooseLockTypeDialogFragment.OnLockTypeSelectedListener; +import com.android.settings.password.generate.GenerateLockPasswordActivity; import com.google.android.setupcompat.util.WizardManagerHelper; @@ -51,7 +52,10 @@ public class SetupChooseLockPassword extends ChooseLockPassword { public static Intent modifyIntentForSetup( Context context, Intent chooseLockPasswordIntent) { - chooseLockPasswordIntent.setClass(context, SetupChooseLockPassword.class); + // GrapheneOS change: This new activity for password generation will handle SetupWizard. + // Usually this setClass result isn't used anyway, since a new intent is created in + // ChooseLockGeneric and the extras are passed into there. + chooseLockPasswordIntent.setClass(context, GenerateLockPasswordActivity.class); chooseLockPasswordIntent.putExtra(EXTRA_PREFS_SHOW_BUTTON_BAR, false); return chooseLockPasswordIntent; } @@ -75,7 +79,7 @@ protected void onCreate(Bundle savedInstance) { public static class SetupChooseLockPasswordFragment extends ChooseLockPasswordFragment implements OnLockTypeSelectedListener { - private static final String TAG_SKIP_SCREEN_LOCK_DIALOG = "skip_screen_lock_dialog"; + public static final String TAG_SKIP_SCREEN_LOCK_DIALOG = "skip_screen_lock_dialog"; @Nullable private Button mOptionsButton; diff --git a/src/com/android/settings/password/generate/BaseLockPasswordGenerationFragment.kt b/src/com/android/settings/password/generate/BaseLockPasswordGenerationFragment.kt new file mode 100644 index 00000000000..5621337edf9 --- /dev/null +++ b/src/com/android/settings/password/generate/BaseLockPasswordGenerationFragment.kt @@ -0,0 +1,51 @@ +package com.android.settings.password.generate + +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.annotation.LayoutRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels + +private const val TAG = "BaseLockPasswordGenerationFragment" + +abstract class BaseLockPasswordGenerationFragment( + @LayoutRes private val resId: Int, + val shouldGcOnDestroy: Boolean = true +) : Fragment() { + protected val viewModel: GenerateLockPasswordViewModel by activityViewModels() + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (activity !is GenerateLockPasswordActivity) { + throw SecurityException("Fragment contained in wrong activity") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(resId, container, false) + } + + @CallSuper + override fun onDestroy() { + super.onDestroy() + + if (shouldGcOnDestroy) { + Log.d(TAG, "onDestroy garbage collection") + // Force a garbage collection immediately to remove remnant of user password shards + // from memory. + System.gc() + System.runFinalization() + System.gc() + } + } +} diff --git a/src/com/android/settings/password/generate/BaseLockPasswordGenerationPreferenceFragment.kt b/src/com/android/settings/password/generate/BaseLockPasswordGenerationPreferenceFragment.kt new file mode 100644 index 00000000000..e9fc96419b2 --- /dev/null +++ b/src/com/android/settings/password/generate/BaseLockPasswordGenerationPreferenceFragment.kt @@ -0,0 +1,78 @@ +package com.android.settings.password.generate + +import android.app.settings.SettingsEnums +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.annotation.XmlRes +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.RecyclerView +import com.android.settings.SettingsPreferenceFragment +import com.google.android.setupdesign.GlifPreferenceLayout + +private const val TAG = "BaseLockPasswordGenerationPreferenceFragment" + +/** + * Because of the SetupWizard theme set in GenerateLockPasswordActivity, every PreferenceFragment + * will be inflated to be a [GlifPreferenceLayout] + * + * The documentation for tells us: Fragments using this layout _must_ delegate + * [onCreateRecyclerView] to the implementation in this class: + * {@link #onCreateRecyclerView(android.view.LayoutInflater, android.view.ViewGroup, + * android.os.Bundle)} + * + * Don't do what I did and try to use a FragmentContainerView inside of a GlifLayout to hold a + * PreferenceFragment. + * - GlifLayouts have the two-pane view in landscape orientation, which effectively + * halves the horizontal screen space + * - [GlifPreferenceLayout] is a subclass of GlifLayouts. + * - Therefore, nesting a [GlifPreferenceLayout] inside of a GlifLayout will result in the + * PreferenceLayout being 1/2 * 1/2 = 1/4 of the width! + */ +abstract class BaseLockPasswordGenerationPreferenceFragment( + @XmlRes private val prefResId: Int, + val shouldGcOnDestroy: Boolean = false +) : SettingsPreferenceFragment() { + protected val viewModel: GenerateLockPasswordViewModel by activityViewModels() + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (activity !is GenerateLockPasswordActivity) { + throw SecurityException("Fragment contained in wrong activity") + } + + addPreferencesFromResource(prefResId) + } + + override fun getMetricsCategory(): Int = SettingsEnums.CHOOSE_LOCK_PASSWORD + + override fun onCreateRecyclerView( + inflater: LayoutInflater, + parent: ViewGroup, + savedInstanceState: Bundle? + ): RecyclerView { + // do this so the header can actually be setup in portrait + val layout = parent as GlifPreferenceLayout + return layout.onCreateRecyclerView(inflater, parent, savedInstanceState).apply { + // do this so that the preferences don't fade when you change them + itemAnimator = null + } + } + + @CallSuper + override fun onDestroy() { + super.onDestroy() + + if (shouldGcOnDestroy) { + Log.d(TAG, "onDestroy garbage collection") + // Force a garbage collection immediately to remove remnant of user password shards + // from memory. + System.gc() + System.runFinalization() + System.gc() + } + } +} diff --git a/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt b/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt new file mode 100644 index 00000000000..fce75b02c6b --- /dev/null +++ b/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt @@ -0,0 +1,478 @@ +package com.android.settings.password.generate + +import android.content.Intent +import android.graphics.Insets +import android.graphics.Typeface +import android.os.Bundle +import android.os.UserHandle +import android.text.Editable +import android.text.InputType +import android.text.Selection +import android.text.Spannable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.CheckBox +import android.widget.ImeAwareEditText +import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.internal.widget.LockPatternUtils +import com.android.internal.widget.LockscreenCredential +import com.android.internal.widget.TextViewInputDisabler +import com.android.settings.R +import com.android.settings.SetupRedactionInterstitial +import com.android.settings.Utils +import com.android.settings.notification.RedactionInterstitial +import com.android.settings.password.ChooseLockPassword.ChooseLockPasswordFragment +import com.android.settings.password.ChooseLockSettingsHelper +import com.android.settings.password.ConfirmDeviceCredentialUtils +import com.android.settings.password.PasswordRequirementAdapter +import com.android.settings.password.SaveAndFinishWorker +import com.google.android.setupcompat.template.FooterBarMixin +import com.google.android.setupcompat.template.FooterButton +import com.google.android.setupcompat.util.WizardManagerHelper +import com.google.android.setupdesign.GlifLayout +import kotlinx.coroutines.flow.filterNotNull + +private const val TAG = "ConfirmGeneratedLockPassFragment" +private const val KEY_LAST_KNOWN_STAGE = "last_known_stage_number" +private const val FRAGMENT_TAG_SAVE_AND_FINISH = "save_and_finish_worker"; + +private const val DISPLAY_PIN_SIZE_SP = 24f +private const val DISPLAY_PASSPHRASE_SIZE_SP = 18f + +class ConfirmGeneratedLockPassFragment : BaseLockPasswordGenerationFragment( + R.layout.generate_lock_password_confirm +), SaveAndFinishWorker.Listener { + private var passwordRestrictionView: RecyclerView? = null + + private var passwordRequirementAdapter: PasswordRequirementAdapter? = null + + private var saveAndFinishWorker: SaveAndFinishWorker? = null + + private lateinit var mLockPatternUtils: LockPatternUtils + + var lastKnownStage: PassGenStage.Confirmation? = null + + var mRequestGatekeeperPassword = false + var mRequestWriteRepairModePassword = false + var mUnificationProfileId = 0 + var mReturnCredentials = false + var mUserId = 0 + var mCurrentCredential: LockscreenCredential? = null + + private var passwordEntry: ImeAwareEditText? = null + + private var passwordEntryInputDisabler: TextViewInputDisabler? = null + + private var layout: GlifLayout? = null + + override fun onChosenLockSaveFinished(wasSecureBefore: Boolean, resultData: Intent?) { + activity!!.setResult(ChooseLockPasswordFragment.RESULT_FINISHED, resultData) + + /* + if (mChosenPassword != null) { + mChosenPassword.zeroize() + } + */ + if (mCurrentCredential != null) { + mCurrentCredential?.zeroize() + } + /* + if (mFirstPassword != null) { + mFirstPassword.zeroize() + } + */ + // zeroizing is handled in cleanup + viewModel.cleanUp() + + passwordEntry?.setText("") + + if (!wasSecureBefore) { + if (WizardManagerHelper.isAnySetupWizard(activity!!.intent)) { + // Setup wizard's redaction interstitial is deferred to optional step. Enable that + // optional step if the lock screen was set up. + SetupRedactionInterstitial.setEnabled(context, true) + } else { + startActivity(RedactionInterstitial.createStartIntent(activity, mUserId)) + } + } + + layout?.announceForAccessibility(getString(R.string.accessibility_setup_password_complete)) + + activity!!.finish() + } + + // see com/android/settings/password/ChooseLockPassword.java + // using existing SaveAndFinishWorker implementation to save the password + private fun startSaveAndFinish(chosenPassword: LockscreenCredential, autoPinConfirm: Boolean) { + if (saveAndFinishWorker != null) { + Log.w(TAG, "startSaveAndFinish with an existing SaveAndFinishWorker.") + return + } + + ConfirmDeviceCredentialUtils.hideImeImmediately( + requireActivity().getWindow().getDecorView() + ) + + // mPasswordEntryInputDisabler.setInputEnabled(false) + saveAndFinishWorker = SaveAndFinishWorker().also { worker -> + worker + .setListener(this) + .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword) + .setRequestWriteRepairModePassword(mRequestWriteRepairModePassword) + .setReturnCredentials(mReturnCredentials) + + parentFragmentManager.beginTransaction() + .add(worker, FRAGMENT_TAG_SAVE_AND_FINISH) + .commit() + parentFragmentManager.executePendingTransactions() + + val intent = requireActivity().intent + if (mUnificationProfileId != UserHandle.USER_NULL) { + intent + .getParcelableExtra( + ChooseLockSettingsHelper.EXTRA_KEY_UNIFICATION_PROFILE_CREDENTIAL + )?.use { profileCredential -> + worker.setProfileToUnify(mUnificationProfileId, profileCredential) + } + } + + // update the setting before triggering the password save workflow, + // so that pinLength information is stored accordingly when setting is turned on. + mLockPatternUtils.setAutoPinConfirm(autoPinConfirm, mUserId) + + worker.start( + mLockPatternUtils, + chosenPassword, mCurrentCredential, mUserId + ) + } + } + + private fun isForPassphrase(): Boolean { + return viewModel.passType.value == GenerateLockPasswordViewModel.PassType.Passphrase + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + lastKnownStage?.stageNumber?.let { outState.putInt(KEY_LAST_KNOWN_STAGE, it) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + mLockPatternUtils = LockPatternUtils(requireActivity()) + + val intent = requireActivity().intent + + lastKnownStage = savedInstanceState?.getInt(KEY_LAST_KNOWN_STAGE, -1)?.let { + PassGenStage.Confirmation.fromStageNumber(it) + } + mRequestGatekeeperPassword = intent.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, false + ) + mRequestWriteRepairModePassword = intent.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW, false + ) + mUnificationProfileId = intent.getIntExtra( + ChooseLockSettingsHelper.EXTRA_KEY_UNIFICATION_PROFILE_ID, UserHandle.USER_NULL + ) + mReturnCredentials = intent.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_RETURN_CREDENTIALS, false + ) + + // Only take this argument into account if it belongs to the current profile. + mUserId = Utils.getUserIdFromBundle(activity, intent.extras) + mCurrentCredential = intent.getParcelableExtra(ChooseLockSettingsHelper.EXTRA_KEY_PASSWORD) + } + + override fun onResume() { + super.onResume() + saveAndFinishWorker?.let { worker -> worker.setListener(this) } + passwordEntry?.apply { + requestFocus() + // scheduleShowSoftInput() + } + } + + override fun onPause() { + saveAndFinishWorker?.let { worker -> worker.setListener(null) } + super.onPause() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState != null) { + saveAndFinishWorker = parentFragmentManager + .findFragmentByTag(FRAGMENT_TAG_SAVE_AND_FINISH) as? SaveAndFinishWorker + } + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.saveRequest) { request -> + passwordEntryInputDisabler?.setInputEnabled( + request == GenerateLockPasswordViewModel.SaveRequest.Inactive + ) + } + + lifecycleScope.launchAndCollect(viewModel.saveRequest) { request -> + if (request is GenerateLockPasswordViewModel.SaveRequest.Requested) { + startSaveAndFinish(request.credential, request.autoPinConfirm) + } + } + + layout = view as GlifLayout + + val message = view.findViewById(R.id.sud_layout_description) + message.visibility = View.VISIBLE + + val headerLayout = view.findViewById( + com.google.android.setupdesign.R.id.sud_layout_header + ) + setupPasswordRequirementsView(headerLayout) + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.confirmError) { error -> + if (error != null) { + (passwordEntry?.text as? Spannable)?.let { editable -> + Selection.setSelection(editable, 0, editable.length) + } + } + + val errors = buildList { + when (error) { + GenerateLockPasswordViewModel.ConfirmError.DOESNT_MATCH -> { + add(getString( + if (isForPassphrase()) { + R.string.lock_screen_generate_confirm_passphrases_dont_match + } else { + R.string.lockpassword_confirm_pins_dont_match + } + )) + } + GenerateLockPasswordViewModel.ConfirmError.TOO_SHORT -> {} + null -> {} + } + }.toTypedArray() + + passwordRequirementAdapter?.setRequirements(errors, false) + } + + // Make the password container consume the optical insets so the edit text is aligned + // with the sides of the parent visually. + val container = view.findViewById(R.id.password_container) + container.opticalInsets = Insets.NONE + + passwordEntry = view.findViewById(R.id.password_entry) + passwordEntryInputDisabler = TextViewInputDisabler(passwordEntry!!); + + passwordEntry?.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + viewModel.setInputLength(s?.length ?: 0) + } + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + }) + passwordEntry?.setOnEditorActionListener { _, actionId, _ -> + // Check if this was the result of hitting the enter or "done" key + if ( + actionId == EditorInfo.IME_NULL || + actionId == EditorInfo.IME_ACTION_DONE || + actionId == EditorInfo.IME_ACTION_NEXT + ) { + viewModel.primaryButtonClicked(passwordEntry?.text) + true + } else { + false + } + } + + val footerBarMixin = layout!!.getMixin(FooterBarMixin::class.java) + footerBarMixin.primaryButton = + FooterButton.Builder(requireContext()) + .setText(R.string.lock_screen_generate_options_next_button) + .setListener { viewModel.primaryButtonClicked(passwordEntry?.text) } + .setButtonType(FooterButton.ButtonType.NEXT) + .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Primary) + .build() + footerBarMixin.secondaryButton = + FooterButton.Builder(requireContext()) + .setText(R.string.lockpassword_clear_label) + .setListener { passwordEntry?.setText("") } + .setButtonType(FooterButton.ButtonType.CLEAR) + .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Secondary) + .build() + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.passType) { passType -> + layout?.icon = activity?.getDrawable( + when (passType) { + GenerateLockPasswordViewModel.PassType.Pin -> R.drawable.ic_lock_pin + GenerateLockPasswordViewModel.PassType.Passphrase -> R.drawable.ic_password + null -> null + } ?: return@repeatCollectOnLifecycle + ) + } + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.isPrimaryButtonEnabled) { on -> + footerBarMixin.primaryButton?.isEnabled = on + } + + val autoPinConfirmText = view.findViewById(R.id.auto_pin_confirm_security_message) + val autoPinConfirmCheck = view.findViewById(R.id.auto_pin_confirm_enabler) + autoPinConfirmCheck.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.isAutoPinConfirm) { autoPinConfirm -> + autoPinConfirmCheck.isChecked = autoPinConfirm + } + autoPinConfirmCheck.setOnCheckedChangeListener { _, isChecked -> + viewModel.setAutoPinConfirm(isChecked) + } + + val showPasswordText = view.findViewById(R.id.show_generated_text) + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.stage.filterNotNull()) { stage -> + val newStage = lastKnownStage != stage + if (newStage) { + passwordEntry?.setText("") + } + if (stage !is PassGenStage.Confirmation) return@repeatCollectOnLifecycle + lastKnownStage = stage + + if ( + !isForPassphrase() && + stage is PassGenStage.Confirmation.ConfirmWithVisible && + // see SetupChooseLockPassword; it's disabled in there + !WizardManagerHelper.isAnySetupWizard(activity?.intent) + ) { + autoPinConfirmText.visibility = View.VISIBLE + autoPinConfirmCheck.visibility = View.VISIBLE + } else { + autoPinConfirmText.visibility = View.GONE + autoPinConfirmCheck.visibility = View.GONE + } + + footerBarMixin.primaryButton.text = if (stage == PassGenStage.Confirmation.ConfirmLast) { + getString(R.string.lock_screen_generate_options_confirm_button) + } else { + getString(R.string.lock_screen_generate_options_next_button) + } + + showPasswordText.apply { + if (stage == PassGenStage.Confirmation.ConfirmWithVisible) { + visibility = View.VISIBLE + } else { + visibility = View.GONE + text = "" + } + } + + when (stage) { + PassGenStage.Confirmation.ConfirmWithVisible -> { + if (isForPassphrase()) { + layout?.setHeaderText(R.string.lock_screen_generate_confirm_title_passphrase) + message.setText(R.string.lock_screen_generate_confirm_desc_passphrase) + } else { + layout?.setHeaderText(R.string.lock_screen_generate_confirm_title_pin) + message.setText(R.string.lock_screen_generate_confirm_desc_pin) + } + } + PassGenStage.Confirmation.ConfirmLast -> { + if (isForPassphrase()) { + layout?.setHeaderText(R.string.lock_screen_generate_confirm_again_title_passphrase) + message.setText(R.string.lock_screen_generate_confirm_last_desc_passphrase) + } else { + layout?.setHeaderText(R.string.lock_screen_generate_confirm_again_title_pin) + message.setText(R.string.lock_screen_generate_confirm_last_desc_pin) + } + } + PassGenStage.Confirmation.ConfirmWithoutVisible -> { + if (isForPassphrase()) { + layout?.setHeaderText(R.string.lock_screen_generate_confirm_again_title_passphrase) + message.setText(R.string.lock_screen_generate_confirm_again_desc_passphrase) + } else { + layout?.setHeaderText(R.string.lock_screen_generate_confirm_again_title_pin) + message.setText(R.string.lock_screen_generate_confirm_again_desc_pin) + } + } + } + if (newStage) { + layout?.let { it.announceForAccessibility(it.headerText) } + } + } + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.selectedPassword) { selection -> + val currentStage = viewModel.stage.value + if (selection == null || currentStage != PassGenStage.Confirmation.ConfirmWithVisible) { + showPasswordText.text = "" + return@repeatCollectOnLifecycle + } + when (val password = viewModel.getPassword(selection)) { + is GeneratedPassphrase -> { + showPasswordText.text = password.passphrase + showPasswordText.textSize = DISPLAY_PASSPHRASE_SIZE_SP + } + is GeneratedPin -> { + showPasswordText.text = password.pin + showPasswordText.textSize = DISPLAY_PIN_SIZE_SP + } + null -> { + showPasswordText.text = "" + showPasswordText.textSize = DISPLAY_PIN_SIZE_SP + } + } + } + + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.passType.filterNotNull() + ) { type -> + passwordEntry?.inputType = when (type) { + GenerateLockPasswordViewModel.PassType.Passphrase -> + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + GenerateLockPasswordViewModel.PassType.Pin -> + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD + } + // Can't set via XML since setInputType resets the fontFamily to null + passwordEntry?.setTypeface( + Typeface.create( + requireContext().getString( + com.android.internal.R.string.config_headlineFontFamily + ), + Typeface.NORMAL + ) + ) + } + } + + // from com/android/settings/password/ChooseLockPassword.java + private fun setupPasswordRequirementsView(view: ViewGroup?) { + view ?: return + createHintMessageView(view) + passwordRestrictionView?.setLayoutManager(LinearLayoutManager(activity)) + passwordRequirementAdapter = PasswordRequirementAdapter(activity) + passwordRestrictionView?.setAdapter(passwordRequirementAdapter) + view.addView(passwordRestrictionView) + } + + // from com/android/settings/password/ChooseLockPassword.java + private fun createHintMessageView(view: ViewGroup) { + if (passwordRestrictionView != null) { + return + } + + val sucTitleView = view.findViewById(R.id.suc_layout_title) + val titleLayoutParams = + sucTitleView.layoutParams as ViewGroup.MarginLayoutParams + passwordRestrictionView = RecyclerView(requireContext()).apply { + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + lp.setMargins( + titleLayoutParams.leftMargin, resources.getDimensionPixelSize( + R.dimen.password_requirement_view_margin_top + ), titleLayoutParams.leftMargin, 0 + ) + setLayoutParams(lp) + } + } +} diff --git a/src/com/android/settings/password/generate/DicewareWordList.kt b/src/com/android/settings/password/generate/DicewareWordList.kt new file mode 100644 index 00000000000..a3ac183f8aa --- /dev/null +++ b/src/com/android/settings/password/generate/DicewareWordList.kt @@ -0,0 +1,145 @@ +package com.android.settings.password.generate + +import android.app.admin.PasswordMetrics +import android.content.Context +import androidx.annotation.Keep +import androidx.annotation.OpenForTesting +import com.android.settings.password.generate.DicewareWordList.LoadException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import libcore.util.HexEncoding +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.security.DigestInputStream +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.TreeMap +import kotlin.jvm.Throws +import kotlinx.coroutines.CoroutineDispatcher + +// https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt +// remove numbers: +// sed -r -i 's/^[0-9]{5}\t(.*)$/\1/g' eff_large_wordlist.txt +const val WORDLIST_ASSET_FILENAME = "eff_large_wordlist.txt" +// a digest to verify contents and sanity check any changes +// sha256sum eff_large_wordlist.txt +private const val WORDLIST_DIGEST = "6d557f0693958fb5e650b68b5bee585eb82cf4da32965505c789e924743bc522" +private const val WORDLIST_NUM_WORDS = 7776 + +// In-memory storage of wordlist for passphrase generation +class DicewareWordList private constructor( + private val words: Array +) { + fun getRandomWord(random: SecureRandom): String { + // if wordlist size is a power of 2, could just use SecureRandom#next directly + val index = random.nextInt(WORDLIST_NUM_WORDS) + return words[index] + } + + @OpenForTesting + @Keep + fun wordList() = words.asList() + + companion object { + // these values need to be updated if the wordlist is changed, as they will let us + // access these properties without needing to construct and calculate it from the wordlist + const val MAX_SEQUENCE_LENGTH = 4 + val WORD_LENGTH_FREQUENCIES by lazy { + mapOf( + 3 to 82, + 4 to 467, + 5 to 928, + 6 to 1372, + 7 to 1591, + 8 to 1779, + 9 to 1557 + ) + } + val MAX_WORD_LENGTH by lazy { + WORD_LENGTH_FREQUENCIES.keys.asSequence().max() + } + + suspend fun loadWords( + context: Context, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ): DicewareWordList { + return loadWordsInner( + context.applicationContext.assets.open(WORDLIST_ASSET_FILENAME), + ioDispatcher + ) + } + + // DCL is disabled for system apps, so can't use Mockito. Expose a method to allow the + // InputStream to be chosen + @OpenForTesting + @Throws(LoadException::class) + suspend fun loadWordsInner( + consumedStream: InputStream, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO + ): DicewareWordList = withContext(ioDispatcher) { + val checkDupesSet = HashSet() + val frequencies = TreeMap() + var maxSequenceLength = 0 + val msgDigest = MessageDigest.getInstance("SHA-256") + val words: Array = try { + val fileStream = DigestInputStream(consumedStream, msgDigest) + BufferedReader(InputStreamReader(fileStream)).use { reader -> + Array(WORDLIST_NUM_WORDS) { index -> + ensureActive() + + val line: String = reader.readLine() + ?: throw LoadException( + "expected $WORDLIST_NUM_WORDS words; actual number $index" + ) + + line.trim() + .onEach { c -> + validate(c.isLetter() || c == '-') { + "found word with non-letters [$line]" + } + } + .also { + val frequencyForLength = frequencies.getOrDefault(it.length, 0) + frequencies[it.length] = frequencyForLength + 1 + maxSequenceLength = maxOf( + maxSequenceLength, + PasswordMetrics.maxLengthSequence(it.encodeToByteArray()) + ) + checkDupesSet.add(it) + } + }.also { + validate(reader.readLine() == null) { "expected EOF but more text found" } + } + } + } catch (e: IOException) { + throw LoadException("failed to load/read words", e) + } + val digest = HexEncoding.encodeToString(msgDigest.digest(), false) + validate(digest == WORDLIST_DIGEST) { "sha256 digest of wordlist mismatch" } + validate(checkDupesSet.size == WORDLIST_NUM_WORDS) { "duplicate words detected" } + val updatableErrors = buildList { + if (frequencies != WORD_LENGTH_FREQUENCIES) { + add("word frequencies: expected $WORD_LENGTH_FREQUENCIES, got $frequencies") + } + if (maxSequenceLength != MAX_SEQUENCE_LENGTH) { + add("maxSequenceLength: expected $MAX_SEQUENCE_LENGTH, got $maxSequenceLength") + } + } + validate(updatableErrors.isEmpty()) { updatableErrors.joinToString(separator = "; ") } + + DicewareWordList(words) + } + } + + class LoadException(msg: String, cause: Throwable? = null) : Exception(msg, cause) +} + +private inline fun validate(value: Boolean, lazyMessage: () -> Any) { + if (!value) { + val message = lazyMessage() + throw LoadException(message.toString()) + } +} diff --git a/src/com/android/settings/password/generate/FlowExtensions.kt b/src/com/android/settings/password/generate/FlowExtensions.kt new file mode 100644 index 00000000000..b1cc37253e8 --- /dev/null +++ b/src/com/android/settings/password/generate/FlowExtensions.kt @@ -0,0 +1,59 @@ +package com.android.settings.password.generate + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.launch + +fun CoroutineScope.launchAndCollect(flow: Flow, collector: FlowCollector) { + launch { + flow.collect(collector) + } +} + +// Lifecycle-aware flow collection for updating UI code +fun LifecycleOwner.repeatCollectOnLifecycle( + flow: Flow, + state: Lifecycle.State = Lifecycle.State.STARTED, + collector: FlowCollector +) { + lifecycleScope.launch { + // Cancel flow collection when lifecycle state is below the given state param + // so that the UI doesn't try to update if app goes into the background and Fragment goes + // to the STOPPED state. Not an issue right now, since there's no background source of data, + // (besides async PIN/passphrase generation, which shouldn't take long) + repeatOnLifecycle(state) { + flow.collect(collector) + } + } +} + +/** see [com.android.systemui.util.kotlin.combine] */ +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { + args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7 + ) + } +} diff --git a/src/com/android/settings/password/generate/GenerateLockPasswordActivity.kt b/src/com/android/settings/password/generate/GenerateLockPasswordActivity.kt new file mode 100644 index 00000000000..f9ea736dd66 --- /dev/null +++ b/src/com/android/settings/password/generate/GenerateLockPasswordActivity.kt @@ -0,0 +1,146 @@ +package com.android.settings.password.generate + +import android.app.admin.DevicePolicyManager +import android.app.admin.PasswordMetrics +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.android.internal.widget.LockPatternUtils +import com.android.settings.R +import com.android.settings.SettingsActivity +import com.android.settings.SetupWizardUtils +import com.android.settings.password.ChooseLockPassword +import com.google.android.setupdesign.util.ThemeHelper +import kotlinx.coroutines.flow.filterNotNull + +class GenerateLockPasswordActivity : SettingsActivity() { + + private val viewModel: GenerateLockPasswordViewModel by viewModels( + factoryProducer = { GenerateLockPasswordViewModel.Factory } + ) + + override fun getIntent(): Intent { + val intent = Intent(super.getIntent()) + intent.putExtra(EXTRA_SHOW_FRAGMENT, GenerateLockPasswordHostFragment::class.java.name) + return intent + } + + override fun isValidFragment(fragmentName: String?): Boolean { + return GenerateLockPasswordHostFragment::class.java.name == fragmentName + } + + override fun isToolbarEnabled(): Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(SetupWizardUtils.getTheme(this, intent)) + ThemeHelper.trySetDynamicColor(this) + super.onCreate(savedInstanceState) + findViewById(R.id.content_parent).fitsSystemWindows = false + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + val callback: OnBackPressedCallback = object : OnBackPressedCallback(true ) { + override fun handleOnBackPressed() { + viewModel.onBackPressed() + } + } + onBackPressedDispatcher.addCallback(callback) + + val passwordType = intent.getIntExtra( + LockPatternUtils.PASSWORD_TYPE_KEY, DevicePolicyManager.PASSWORD_QUALITY_NUMERIC + ) + val isAlphaMode = DevicePolicyManager.PASSWORD_QUALITY_ALPHABETIC == passwordType || + DevicePolicyManager.PASSWORD_QUALITY_ALPHANUMERIC == passwordType || + DevicePolicyManager.PASSWORD_QUALITY_COMPLEX == passwordType + + // following how ChooseLockPassword obtains these parameters + val complexity = intent.getIntExtra( + ChooseLockPassword.EXTRA_KEY_MIN_COMPLEXITY, + DevicePolicyManager.PASSWORD_COMPLEXITY_NONE + ) + val minMetrics: PasswordMetrics = intent.getParcelableExtra( + ChooseLockPassword.EXTRA_KEY_MIN_METRICS + ) ?: PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_NONE) + + viewModel.setup(isAlphaMode, minMetrics, complexity) + } + + override fun onDestroy() { + super.onDestroy() + + // Force a garbage collection immediately to remove remnant of user password shards + // from memory. + System.gc() + System.runFinalization() + System.gc() + } + + class GenerateLockPasswordHostFragment : Fragment() { + private val viewModel: GenerateLockPasswordViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (activity !is GenerateLockPasswordActivity) { + throw SecurityException("Fragment contained in wrong activity") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.generate_lock_password_container, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + lifecycleScope.launchAndCollect(viewModel.stage.filterNotNull()) { newStage -> + val existingFragment = childFragmentManager.findFragmentByTag( + newStage.fragmentTag + ) + if (existingFragment != null) { + return@launchAndCollect + } + + val newFragment: Fragment = when (newStage) { + is PassGenStage.ChooseGeneratedOrManual -> GeneratedOrManualLockPasswordFragment() + is PassGenStage.ChooseParams -> LockPasswordGenerationParamsFragment() + is PassGenStage.ShowMultiple -> ShowGeneratedLockPassOptionsFragment() + is PassGenStage.Confirmation -> ConfirmGeneratedLockPassFragment() + PassGenStage.Quit -> { + requireActivity().finish() + return@launchAndCollect + } + } + + val enter = if (newStage.isBackwards) { + com.google.android.setupdesign.R.anim.sud_slide_back_in + } else { + com.google.android.setupdesign.R.anim.sud_slide_next_in + } + val exit = if (newStage.isBackwards) { + com.google.android.setupdesign.R.anim.sud_slide_back_out + } else { + com.google.android.setupdesign.R.anim.sud_slide_next_out + } + + childFragmentManager + .beginTransaction() + .setCustomAnimations(enter, exit) + .replace(R.id.fragment_container_view, newFragment, newStage.fragmentTag) + .setReorderingAllowed(true) + .commit() + } + } + } +} diff --git a/src/com/android/settings/password/generate/GenerateLockPasswordViewModel.kt b/src/com/android/settings/password/generate/GenerateLockPasswordViewModel.kt new file mode 100644 index 00000000000..cc0f61e1a44 --- /dev/null +++ b/src/com/android/settings/password/generate/GenerateLockPasswordViewModel.kt @@ -0,0 +1,707 @@ +package com.android.settings.password.generate + +import android.app.Application +import android.app.admin.DevicePolicyManager +import android.app.admin.PasswordMetrics +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.android.internal.widget.LockPatternUtils +import com.android.internal.widget.LockscreenCredential +import com.android.internal.widget.PasswordValidationError +import com.android.settings.R +import com.android.settings.SettingsApplication +import com.android.settings.password.ChooseLockPassword.ChooseLockPasswordFragment +import java.io.Closeable +import java.security.MessageDigest +import java.security.SecureRandom +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val TAG = "GenerateLockPassVM" +private const val NUM_GENERATED_PINS_TO_SHOW = 3 +private const val NUM_GENERATED_PASSPHRASES_TO_SHOW = 3 +const val PRIMARY_BUTTON_DELAY_MILLIS = 100L + +// will be multiplied by the number of passwords to show to determine max number of generation +// retries +private const val MAX_RETRIES_MULTIPLIER = 10 + +// run unit tests with a device plugged / emulator running and after building with m: +// +// atest SettingsUnitTests:GenerateLockPasswordTest +// +// all tests can be run with +// +// atest -c SettingsUnitTests:com.android.settings.password.generate +// +// Test in SetupWizard by unsetting password and using +// +// adb shell pm enable app.grapheneos.setupwizard +// adb shell settings put secure user_setup_complete 0 +// adb shell am start -a android.intent.action.MAIN -n app.grapheneos.setupwizard/app.grapheneos.setupwizard.view.activity.WelcomeActivity +// +class GenerateLockPasswordViewModel( + private val application: Application, + private val backgroundDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher +) : ViewModel() { + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val settingsApplication = this[APPLICATION_KEY] as SettingsApplication + GenerateLockPasswordViewModel( + settingsApplication, + Dispatchers.Default, + Dispatchers.IO + ) + } + } + } + + private val random = SecureRandom() + + override fun onCleared() { + super.onCleared() + cleanUp() + } + + fun cleanUp() { + _selectedPassword.value?.close() + + (_saveRequest.value as? SaveRequest.Requested)?.credential?.zeroize() + + // nothing else to do about an array full of Strings, since we need the Strings to show + // to the user in the UI the choices. Just call the garbage collector in Fragment / + // Activity's onDestroy + val state = (_generatedPasswords.value as? GenerateState.Loaded)?.list as? ArrayList + state?.clear() + } + + private val _genParams = MutableStateFlow(null) + val genParams: StateFlow = _genParams.asStateFlow() + + val isAutoPinConfirm: StateFlow = _genParams.map { params -> + when (params) { + is PinGenParams -> ChooseLockPasswordFragment.isAutoPinConfirmPossible(params.digits) && + params.autoPinConfirm + else -> false + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val dicewareWordList: Deferred> by lazy { + viewModelScope.async(start = CoroutineStart.LAZY) { + Log.d(TAG, "loading diceware words") + try { + Result.success(DicewareWordList.loadWords(application, ioDispatcher)) + } catch (e: DicewareWordList.LoadException) { + Log.e(TAG, "failed to load diceware words", e) + Result.failure(e) + } + } + } + + enum class PassType { + Pin, + // For the first screen, this will be also the Password type if the user goes on to just + // use their own password + Passphrase + } + + val passType: StateFlow = genParams + .map { + when (it) { + is DicewarePassphraseGenParams -> PassType.Passphrase + is PinGenParams -> PassType.Pin + null -> null + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _stage = MutableStateFlow(PassGenStage.ChooseGeneratedOrManual(false)) + // force stage to be null for observers until the right passtype has been processed + val stage: StateFlow = + combine(passType, _stage) { passType, curStage -> + if (passType == null) null else curStage + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + var generationCount = 0L + private set + + sealed class GenerateState { + data object NotLoaded : GenerateState() + data class Error(val errorMessage: String) : GenerateState() + data class Loaded(val list: List) : GenerateState() + + fun listOrNull() = when (this) { + is Loaded -> list + else -> null + } + + val size: Int get() = when (this) { + is Loaded -> list.size + else -> 0 + } + } + + private val _generatedPasswords = MutableStateFlow(GenerateState.NotLoaded) + val generatedPasswords: StateFlow = _generatedPasswords.asStateFlow() + + fun getGeneratedPasswordIdForRecyclerView(position: Int): Long { + val base: Int = _generatedPasswords.value.size + .takeIf { it > 0 } + ?: minOf(NUM_GENERATED_PINS_TO_SHOW, NUM_GENERATED_PASSPHRASES_TO_SHOW) + // The generated passwords list will never be updated partially (unless we support editing + // passphrases). The contents will only change if an entire new set of passwords is + // generated. Suffices to just use the generation count to get unique IDs for recyclerview + return generationCount * base + position + } + + /** + * This is either an index for the list in [_generatedPasswords], or a stored index with + * the LockscreenCredential. The intent of this is to keep only one copy of a + * [LockscreenCredential] (until saving where we copy the credential) so that we don't have + * to create various copies and worry about zeroizing them. + */ + sealed class Selection : Closeable { + abstract val index: Int + data class IndexOnly(override val index: Int) : Selection() { + override fun close() {} + } + class ForConfirmation( + override val index: Int, + // Don't zeroize until Activity closes or replacing the Selection object with IndexOnly + // or null + val credential: LockscreenCredential + ) : Selection() { + override fun close() { + credential.zeroize() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as ForConfirmation + if (index != other.index) return false + // constant-time comparison (is this necessary, since LockscreenCredential just uses + // Array.equals?) + return MessageDigest.isEqual(credential.credential, other.credential.credential) + } + } + } + private val _selectedPassword = MutableStateFlow(null) + val selectedPassword = _selectedPassword.asStateFlow() + + /** Lets the UI access the password text string */ + fun getPassword(selection: Selection): GeneratedPassword? { + return _generatedPasswords.value.listOrNull()?.getOrNull(selection.index) + } + + fun setSelectedPassword(newIndex: Int) { + _selectedPassword.update { current -> + if (newIndex == current?.index && current !is Selection.ForConfirmation) return + val validIndices = _generatedPasswords.value.listOrNull()?.indices ?: return + if (newIndex in validIndices) { + setAutoPinConfirm(false) + current?.close() + Selection.IndexOnly(index = newIndex) + } else { + current + } + } + } + + /** Idempotent function to set new length */ + fun setNewLength(newLength: Int) { + _genParams.update { currentOpts -> + currentOpts ?: return + if (newLength !in currentOpts.minSize..currentOpts.maxSize) { + return + } + + when (currentOpts) { + is DicewarePassphraseGenParams -> { + if (currentOpts.words == newLength) return + currentOpts.copy(words = newLength) + } + is PinGenParams -> { + if (currentOpts.digits == newLength) return + currentOpts.copy(digits = newLength) + } + }.also { regenerateOnNav = true } + } + } + + fun setAutoPinConfirm(enabled: Boolean) { + _genParams.update { currentOpts -> + when (currentOpts) { + is PinGenParams -> { + if (currentOpts.autoPinConfirm == enabled) return + + currentOpts.copy(autoPinConfirm = enabled) + } + else -> return + } + } + } + + private val minPasswordMetrics = MutableStateFlow( + PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_NONE) + ) + + var minPasswordComplexity: PasswordComplexity = PasswordComplexity.MEDIUM + private set + + fun setup(isAlphabeticalMode: Boolean, metrics: PasswordMetrics?, complexity: Int) { + if (passType.value != null) { + return + } + _genParams.update { existingParams -> + if (existingParams != null) { + return + } + + minPasswordMetrics.value = metrics ?: if (isAlphabeticalMode) { + PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_PASSWORD) + } else { + PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_PIN) + } + minPasswordComplexity = PasswordComplexity.fromLevel( + complexity, + minLevel = if (isAlphabeticalMode) { + // Use a default low complexity level for passphrases, because >= MEDIUM + // complexity does not allow sequences that are more than 3 (i.e. + // PasswordMetrics.MAX_ALLOWED_SEQUENCE). This would result in some words being + // excluded from the wordlist and affect entropy, e.g. the word "overstuff" has + // sequence of length 4 because of "rstu" + PasswordComplexity.LOW + } else { + // Note: This will avoid sequences in PINs (like 1234, 1111, etc.) by default. + // Reduces PIN generation entropy, but PINs already have really low entropy and + // are backed by secure element throttling. + PasswordComplexity.MEDIUM + } + ) + + if (isAlphabeticalMode) { + // We're not supporting extra device admin options like adding extra symbols, + // uppercase, etc. + DicewarePassphraseGenParams( + words = DicewarePassphraseGenParams.MIN_WORDS, + numberToGenerate = NUM_GENERATED_PASSPHRASES_TO_SHOW, + minSize = DicewarePassphraseGenParams.MIN_WORDS, + maxSize = DicewarePassphraseGenParams.MAX_WORDS + ) + } else { + val minDigits = maxOf( + minPasswordMetrics.value.length, // defaults to 0 for unmanaged users + minPasswordComplexity.pinLength, // defaults to 4 digits for unmanaged (MEDIUM) + PinGenParams.DEFAULT_MIN_DIGITS + ) + // If, for some reason, a device admin wants PINs that are greater than + // our default MAX digits, obey it for now + val maxDigits = maxOf(minDigits, PinGenParams.DEFAULT_MAX_DIGITS) + + PinGenParams( + digits = minDigits, + numberToGenerate = NUM_GENERATED_PINS_TO_SHOW, + minSize = minDigits, + maxSize = maxDigits + ) + } + } + } + + data class PassphraseLenWarning(val fullNumberOfWords: Int, val maxPasswordLength: Int) + + /** + * Indicates to the user if the max word count can generated passphrases that exceed + * [DevicePolicyManager.MAX_PASSWORD_LENGTH] + * + * Not expected to be reached with 4-8 words with [DevicePolicyManager.MAX_PASSWORD_LENGTH] of + * 128. However, if max words is increased in the future for whatever reason (13-word + * passphrases of all length-9 words can start running into this), it can cause loss of + * passphrase entropy, since certain passphrases will exceed + * [DevicePolicyManager.MAX_PASSWORD_LENGTH]. + * + * e.g. EFF wordlist with b = 6^5 words can generate b^13 ~ 2^168 possible passphrases of + * length 13. If the max password length is 128, these passphrases would have string length + * 9 * 13 + numSpaces = 129 (numSpaces = 12), exceeding the max password length. There are 1557 + * words of length 9 in the EFF wordlist, so 1557^13 ~ 2^138 such possible passphrases would + * be excluded. This is only 1557^13 / b^13 ~ 0.00000008316% of all possible 13-word passphrases + * from this wordlist, but it still reduces the search space. + */ + val passphraseMaxWordsEntropyWarning: Flow = _genParams.map { params -> + if (params !is DicewarePassphraseGenParams) return@map null + val safeMaxSize = (params.maxSize downTo params.minSize) + .firstOrNull { + GeneratedPassphrase.calculateMaxPossibleStringLength( + it, + // Passphrase words are just selected using SecureRandom.nextInt on index; + // words can technically be repeated but very unlikely. + allowResampling = true + ) <= DevicePolicyManager.MAX_PASSWORD_LENGTH + } + ?: 0 + if (safeMaxSize == params.maxSize) return@map null + + PassphraseLenWarning(safeMaxSize, DevicePolicyManager.MAX_PASSWORD_LENGTH) + } + + private val _isGenerating = MutableStateFlow(false) + val isGenerating: StateFlow = _isGenerating + + /** + * We do not support the specific [DevicePolicyManager] password requirements, and we opt + * to disable generation flow entirely if password requirements are too strict + */ + val areMinMetricsRestrictive = + combine(minPasswordMetrics, _genParams) { minMetrics, genParam -> + if (genParam !is DicewarePassphraseGenParams) return@combine false + + val minimumNumberOfSpaces = genParam.minWords - 1 + + // All the metrics: letters, upperCase, lowerCase, numeric, symbols, nonLetter, + // nonNumeric, seqLength + // + // Note that using these requirements via DevicePolicyManager is deprecated, and + // MDMs are not common on GrapheneOS anyway. + minMetrics.upperCase > 0 || minMetrics.numeric > 0 + || minMetrics.symbols > minimumNumberOfSpaces + || minMetrics.nonLetter > minimumNumberOfSpaces + // We don't want certain words to get excluded as that would reduce passphrase + // entropy. Note: Currently bypassing sequence length errors + // || minMetrics.seqLength < DicewareWordList.MAX_SEQUENCE_LENGTH + } + + /** + * Keeps track of user confirmation input length so that we avoid storing references to their + * input + */ + private val _currentInputLength = MutableStateFlow(0) + + fun setInputLength(length: Int) { + _currentInputLength.value = length + } + + enum class ConfirmError { + DOESNT_MATCH, TOO_SHORT + } + + private val _confirmError = MutableStateFlow(null) + val confirmError: StateFlow = _confirmError.asStateFlow() + + sealed class SaveRequest { + data object Inactive : SaveRequest() + data class Requested( + val credential: LockscreenCredential, + val autoPinConfirm: Boolean + ) : SaveRequest() + } + + // Used to request the fragment to launch the AOSP save worker from ChooseLockPassword + private val _saveRequest = MutableStateFlow(SaveRequest.Inactive) + val saveRequest: StateFlow = _saveRequest.asStateFlow() + + private fun isInputLengthValid(currentLength: Int): Boolean { + return currentLength >= LockPatternUtils.MIN_LOCK_PASSWORD_SIZE + } + + private val _isPrimaryButtonProcessing = MutableStateFlow(false) + + val isPrimaryButtonEnabled: StateFlow = + combine( + stage, _selectedPassword, _isGenerating, _currentInputLength, _saveRequest, + _isPrimaryButtonProcessing, areMinMetricsRestrictive + ) { stage, selectedPass, isGenerating, inputLength, saveRequest, isPrimaryBtnProcessing, + areMetricsRestrictive -> + if (isPrimaryBtnProcessing) { + return@combine false + } + + when (stage) { + is PassGenStage.ChooseGeneratedOrManual -> { + // prevent user from generating passphrase if DPM requirements too strict, since + // we're not accommodating them + !areMetricsRestrictive + } + is PassGenStage.ChooseParams -> true + is PassGenStage.ShowMultiple -> !isGenerating && selectedPass != null + is PassGenStage.Confirmation -> + isInputLengthValid(inputLength) && saveRequest == SaveRequest.Inactive + PassGenStage.Quit -> false + null -> false + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun onBackPressed() { + _stage.update { curStage -> + when (curStage) { + PassGenStage.Quit -> curStage + is PassGenStage.ChooseGeneratedOrManual -> PassGenStage.Quit + is PassGenStage.ChooseParams -> PassGenStage.ChooseGeneratedOrManual(isBackwards = true) + is PassGenStage.ShowMultiple -> { + regenerateOnNav = false + PassGenStage.ChooseParams(isBackwards = true) + } + is PassGenStage.Confirmation -> { + _selectedPassword.update { selected -> + if (selected is Selection.ForConfirmation) { + selected.close() + Selection.IndexOnly(selected.index) + } else { + selected + } + } + PassGenStage.ShowMultiple(isBackwards = true) + } + } + } + } + + fun primaryButtonClicked(inputPassword: CharSequence? = null) { + if (!isPrimaryButtonEnabled.value) { + return + } + // atomic update + _isPrimaryButtonProcessing.update { isProcessing -> + if (isProcessing) return + true + } + try { + advanceStage(inputPassword) + } finally { + viewModelScope.launch { + delay(PRIMARY_BUTTON_DELAY_MILLIS) + _isPrimaryButtonProcessing.update { false } + } + } + } + + private var regenerateOnNav = false + + private fun advanceStage(inputPassword: CharSequence?) { + _stage.update { curStage -> + when (curStage) { + is PassGenStage.ChooseGeneratedOrManual -> { + if (passType.value == PassType.Passphrase) { + dicewareWordList.start() + } + PassGenStage.ChooseParams(isBackwards = false) + } + is PassGenStage.ChooseParams -> { + generationRequestChannel.trySend(GenerationRequest(regenerateOnNav)) + + PassGenStage.ShowMultiple(isBackwards = false) + } + is PassGenStage.ShowMultiple -> { + _selectedPassword.update { selected -> + selected ?: return + val password = _generatedPasswords.value.listOrNull() + ?.getOrNull(selected.index) + ?: return + Selection.ForConfirmation(selected.index, password.toLockscreenCredential()) + } + + PassGenStage.Confirmation.ConfirmWithVisible + } + is PassGenStage.Confirmation -> { + _confirmError.update { null } + if (inputPassword == null || !isInputLengthValid(inputPassword.length)) { + _confirmError.update { ConfirmError.TOO_SHORT } + return + } + val selectionCredential = + (_selectedPassword.value as? Selection.ForConfirmation)?.credential + ?: return + + when (passType.value) { + PassType.Pin -> LockscreenCredential.createPin(inputPassword) + PassType.Passphrase -> LockscreenCredential.createPassword(inputPassword) + null -> return + }.use { inputCredential -> + // again, LockscreenCredential does not do this and just uses Array.equals + if ( + !MessageDigest.isEqual( + inputCredential.credential, selectionCredential.credential + ) + ) { + _confirmError.update { ConfirmError.DOESNT_MATCH } + return + } + } + + _confirmError.update { null } + _currentInputLength.update { 0 } + when (curStage) { + PassGenStage.Confirmation.ConfirmWithVisible -> { + PassGenStage.Confirmation.ConfirmWithoutVisible + } + PassGenStage.Confirmation.ConfirmWithoutVisible -> { + PassGenStage.Confirmation.ConfirmLast + } + PassGenStage.Confirmation.ConfirmLast -> { + val autoPinConfirm = isAutoPinConfirm.value + _saveRequest.update { + SaveRequest.Requested( + // copy so that if we zeroize the original, + // it's still accessible to the save worker + selectionCredential.duplicate(), + autoPinConfirm + ) + } + curStage + } + } + } + PassGenStage.Quit -> curStage + } + } + } + + @JvmInline + value class GenerationRequest(val forceRegenerate: Boolean) + + // A Channel to handle async requests for generating passwords to deduplicate requests + // and process requests serially + private val generationRequestChannel = Channel(capacity = Channel.RENDEZVOUS) + + fun generateNewPasswords() { + generationRequestChannel.trySend(GenerationRequest(forceRegenerate = true)) + } + + init { + viewModelScope.launch(backgroundDispatcher) { + generationRequestChannel.consumeEach { request -> + val isAlreadyGenerated = _generatedPasswords.value is GenerateState.Loaded + if (isAlreadyGenerated && !request.forceRegenerate) { + return@consumeEach + } + _isGenerating.update { true } + _selectedPassword.update { oldSelection -> + oldSelection?.close() + null + } + try { + generatePasswords() + if (isAlreadyGenerated) { + delay(100L) + } + } finally { + _isGenerating.update { false } + } + } + } + } + + private suspend fun generatePasswords(): Unit = withContext(backgroundDispatcher) { + generationCount++ + + val params = _genParams.value ?: return@withContext + val maxRetries = params.numberToGenerate * MAX_RETRIES_MULTIPLIER + + val newPasswords = ArrayList(params.numberToGenerate) + val allErrors = hashSetOf() + // The retries variable is meant to catch policy errors, not one-off edge cases + var retries = 0 + + _generatedPasswords.update { GenerateState.NotLoaded } + while (newPasswords.size < params.numberToGenerate && isActive) { + val generated: GeneratedPassword = when (params) { + is DicewarePassphraseGenParams -> { + val result = dicewareWordList.await() + val generator = result.getOrNull() + if (generator == null) { + _generatedPasswords.update { + GenerateState.Error(result.exceptionOrNull()?.message ?: "") + } + return@withContext + } + + GeneratedPassphrase.generate(random, generator, params.words) + } + is PinGenParams -> { + GeneratedPin.generate(random, params.digits) + } + } + + // Only show generated passwords that would be considered by Android to satisfy + // any policy requirements. Note that DPMs password requirements are deprecated, and the + // intended behavior is to disable this generation flow entirely if requirements are too + // strict. + // + // Note that this will include avoiding arithmetic sequences in PINs. This can reduce + // the entropy of a generated PIN, but entropy isn't really a concern when talking about + // PINs anyway due to the secure element. + // + // For passphrases, there were checks earlier to prevent the user from choosing + // generation if device policy requires them to have uppercase, extra symbols, etc. + // but if there were any issues, they can still be caught here. + val errors: List = generated + .toLockscreenCredential() + .use { cred -> + PasswordMetrics.validateCredential( + minPasswordMetrics.value, + minPasswordComplexity.complexityValue, + cred + ) + } + + // Bypass sequence errors. This is a rather arbitrary requirement when it comes to + // generated passphrases. The alternative is to edit the wordlist to ensure all words + // have a max length sequence that is at most PasswordMetrics.MAX_ALLOWED_SEQUENCE, + // which is currently 3 + val shouldBypassError = params is DicewarePassphraseGenParams && + errors.size == 1 && + errors.first().errorCode == PasswordValidationError.CONTAINS_SEQUENCE + + if (errors.isEmpty() || shouldBypassError) { + newPasswords.add(generated) + } else { + // PasswordValidationError will only say the reason without storing the + // password, though it might indicate length required + Log.d(TAG, "failed to generate: $errors") + allErrors.addAll(errors.asSequence().map { it.toString() }) + if (retries >= maxRetries) { + val msg = application + .getString( + R.string.lock_screen_generate_show_generated_error_device_requirements_too_strict_s, + // use kotlin's toString + allErrors.toList().toString() + ) + + _generatedPasswords.update { GenerateState.Error(msg) } + return@withContext + } else { + retries++ + } + } + } + + Log.d(TAG, "generated ${newPasswords.size} passwords successfully") + + regenerateOnNav = false + _generatedPasswords.update { GenerateState.Loaded(newPasswords) } + } +} diff --git a/src/com/android/settings/password/generate/GeneratedOrManualLockPasswordFragment.kt b/src/com/android/settings/password/generate/GeneratedOrManualLockPasswordFragment.kt new file mode 100644 index 00000000000..0fbaf8e68ce --- /dev/null +++ b/src/com/android/settings/password/generate/GeneratedOrManualLockPasswordFragment.kt @@ -0,0 +1,345 @@ +package com.android.settings.password.generate + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.preference.Preference +import com.android.internal.widget.LockPatternUtils +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.password.ChooseLockGeneric +import com.android.settings.password.ChooseLockGenericController +import com.android.settings.password.ChooseLockPassword +import com.android.settings.password.ChooseLockPassword.ChooseLockPasswordFragment +import com.android.settings.password.ChooseLockSettingsHelper +import com.android.settings.password.ChooseLockTypeDialogFragment +import com.android.settings.password.ConfirmDeviceCredentialUtils +import com.android.settings.password.ScreenLockType +import com.android.settings.password.SetupChooseLockPassword +import com.android.settings.password.SetupChooseLockPassword.SetupChooseLockPasswordFragment +import com.android.settings.password.SetupSkipDialog +import com.android.settingslib.widget.FooterPreference +import com.google.android.setupcompat.template.FooterBarMixin +import com.google.android.setupcompat.template.FooterButton +import com.google.android.setupcompat.util.WizardManagerHelper +import com.google.android.setupdesign.GlifPreferenceLayout +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull + +private const val KEY_CHOOSE_SCREEN_LOCK = "choose_screen_lock" +private const val KEY_USE_GENERATED_CREDENTIAL = "use_generated_credential" +private const val KEY_USE_OWN_CREDENTIAL = "use_own_credential" +private const val KEY_FOOTER = "footer_screen_lock_creation_choice" + +class GeneratedOrManualLockPasswordFragment : BaseLockPasswordGenerationPreferenceFragment( + prefResId = R.xml.screen_lock_creation_choice, + shouldGcOnDestroy = false, +), ChooseLockTypeDialogFragment.OnLockTypeSelectedListener { + var mUserId = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mUserId = Utils.getUserIdFromBundle(activity, intent.extras) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val layout = view as GlifPreferenceLayout + + setupForOptionsAndPossibleSetupWizardSkip(layout) + + val intent = activity!!.intent + val optionsPref = findPreference(KEY_CHOOSE_SCREEN_LOCK) + val footer = findPreference(KEY_FOOTER)!! + val useGenerated = findPreference(KEY_USE_GENERATED_CREDENTIAL)!! + val useOwn = findPreference(KEY_USE_OWN_CREDENTIAL)!! + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.isPrimaryButtonEnabled) { on -> + useGenerated.isEnabled = on + } + + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.passType.filterNotNull() + .combine(viewModel.areMinMetricsRestrictive) { type, restrict -> type to restrict } + ) { (passType, minMetricsRestrictive) -> + layout.apply { + when (passType) { + GenerateLockPasswordViewModel.PassType.Pin -> { + icon = activity!!.getDrawable(R.drawable.ic_lock_pin) + activity!!.setTitle(R.string.unlock_set_unlock_pin_title) + setHeaderText(R.string.unlock_set_unlock_pin_title) + } + GenerateLockPasswordViewModel.PassType.Passphrase -> { + icon = activity!!.getDrawable(R.drawable.ic_password) + activity!!.setTitle(R.string.unlock_set_unlock_password_title) + setHeaderText(R.string.unlock_set_unlock_password_title) + } + } + } + + val isAlphaMode = passType == GenerateLockPasswordViewModel.PassType.Passphrase + + // visibility is set elsewhere + if (optionsPref?.isVisible == true) { + optionsPref.summary = getString( + if (isAlphaMode) { + R.string.lock_screen_choice_generate_pref_screen_lock_options_password_summary + } else { + R.string.lock_screen_choice_generate_pref_screen_lock_options_pin_summary + } + ) + } + + val topIntroBuilder = StringBuilder() + addHintIfNeeded(intent, topIntroBuilder, isAlphaMode) + + if (isAlphaMode) { + setupPrefIntroAndButtons( + topIntroBuilder = topIntroBuilder, + footer = footer, + useGenerated = useGenerated, + useOwn = useOwn, + introInfoTextId = R.string.lock_screen_generate_passphrase_info, + footerTextId = R.string.lock_screen_generate_choice_footer_password, + useGeneratedDrawableId = R.drawable.ic_shuffle, + useGeneratedTitleTextId = R.string.lock_screen_choice_generate_pref_passphrase_title, + useGeneratedSummaryTextId = R.string.lock_screen_choice_generate_pref_passphrase_summary_d_to_d_words, + minMetricsTooRestrictive = minMetricsRestrictive, + minSize = DicewarePassphraseGenParams.MIN_WORDS, + maxSize = DicewarePassphraseGenParams.MAX_WORDS, + useOwnDrawableId = R.drawable.ic_settings_keyboards, + useOwnTitleId = R.string.lock_screen_choice_manual_pref_passphrase + ) + } else { + setupPrefIntroAndButtons( + topIntroBuilder = topIntroBuilder, + footer = footer, + useGenerated = useGenerated, + useOwn = useOwn, + introInfoTextId = R.string.lock_screen_generate_pin_info, + footerTextId = R.string.lock_screen_generate_choice_footer_pin, + useGeneratedDrawableId = R.drawable.ic_shuffle, + useGeneratedTitleTextId = R.string.lock_screen_choice_generate_pref_pin_title, + useGeneratedSummaryTextId = R.string.lock_screen_choice_generate_pref_pin_summary_d_to_d_digits, + minMetricsTooRestrictive = minMetricsRestrictive, + minSize = PinGenParams.DEFAULT_MIN_DIGITS, + maxSize = PinGenParams.DEFAULT_MAX_DIGITS, + useOwnDrawableId = R.drawable.ic_lock_pin, + useOwnTitleId = R.string.lock_screen_choice_manual_pref_pin + ) + } + + layout.descriptionText = topIntroBuilder.toString() + + footer.setLearnMoreText( + getString(R.string.lock_screen_generate_choice_learn_more_link) + ) + footer.setLearnMoreAction { _ -> + SecureElementInfoDialog().show(childFragmentManager, "secure-element-dialog") + } + } + } + + // From SetupChooseLockPassword + private fun setupForOptionsAndPossibleSetupWizardSkip(layout: GlifPreferenceLayout) { + val chooseLockGenericController = ChooseLockGenericController.Builder(activity, mUserId) + .setHideInsecureScreenLockTypes(true) + .build() + val anyOptionsShown = chooseLockGenericController.visibleAndEnabledScreenLockTypes.size > 0 + val showOptionsButton = activity!!.intent.getBooleanExtra( + ChooseLockGeneric.ChooseLockGenericFragment.EXTRA_SHOW_OPTIONS_BUTTON, false + ) + val optionsPref = findPreference(KEY_CHOOSE_SCREEN_LOCK) + optionsPref?.isVisible = showOptionsButton && anyOptionsShown + + if (WizardManagerHelper.isAnySetupWizard(intent)) { + val footerBarMixin = layout.getMixin(FooterBarMixin::class.java) + footerBarMixin.secondaryButton = + FooterButton.Builder(requireContext()) + .setText(R.string.skip_label) + .setListener { onSkipClicked() } + .setButtonType(FooterButton.ButtonType.CLEAR) + .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Secondary) + .build() + } + } + + // From SetupChooseLockPassword + private fun onSkipClicked() { + val intent = activity!!.intent + val frpSupported = intent + .getBooleanExtra(SetupSkipDialog.EXTRA_FRP_SUPPORTED, false) + val forFingerprint = intent + .getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_FOR_FINGERPRINT, + false + ) + val forFace = intent + .getBooleanExtra(ChooseLockSettingsHelper.EXTRA_KEY_FOR_FACE, false) + val forBiometrics = intent + .getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_FOR_BIOMETRICS, + false + ) + val isAlphaMode = viewModel.passType.value == GenerateLockPasswordViewModel.PassType.Passphrase + val dialog = SetupSkipDialog.newInstance( + if (isAlphaMode) LockPatternUtils.CREDENTIAL_TYPE_PASSWORD else LockPatternUtils.CREDENTIAL_TYPE_PIN, + frpSupported, + forFingerprint, + forFace, + forBiometrics, + WizardManagerHelper.isAnySetupWizard(intent) + ) + + ConfirmDeviceCredentialUtils.hideImeImmediately( + activity!!.window.decorView + ) + + dialog.show(childFragmentManager) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + KEY_CHOOSE_SCREEN_LOCK -> { + ChooseLockTypeDialogFragment.newInstance(mUserId) + .show( + childFragmentManager, + SetupChooseLockPasswordFragment.TAG_SKIP_SCREEN_LOCK_DIALOG + ) + true + } + KEY_USE_GENERATED_CREDENTIAL -> { + viewModel.primaryButtonClicked() + true + } + KEY_USE_OWN_CREDENTIAL -> { + // Launch the original PIN/password input activity + val intent = ChooseLockPassword.IntentBuilder(context).build() + if (WizardManagerHelper.isAnySetupWizard(activity?.intent)) { + // SetupChooseLockPassword will show Skip and Screen lock options buttons, + // but the Screen lock options button will take users back to this generate + // password flow + intent.setClass(context!!, SetupChooseLockPassword::class.java) + } + // Allow ChooseLockPassword to get the original extras + intent.putExtras(activity!!.intent) + // ChooseLockPassword was the original activity and has its own result codes that it + // wants to send back to ChooseLockGeneric (SetupChooseLockGeneric for SetupWizard) + intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) + + activity!!.startActivity(intent) + activity!!.finish() + true + } + else -> false + } + } + + // from SetupLockPassword + override fun onLockTypeSelected(lock: ScreenLockType?) { + val isAlpha = viewModel.passType.value == GenerateLockPasswordViewModel.PassType.Passphrase + val currentLockType = if (isAlpha) ScreenLockType.PASSWORD else ScreenLockType.PIN + if (lock == currentLockType) { + return + } + // While we could dynamically set the lock type using the viewmodel, easier to just follow + // how it's done in SetupLockPassword. This will ensure the intent's data is updated for + // the new lock type as well for better consistency. + startChooseLockActivity(lock, activity) + } + + private fun setupPrefIntroAndButtons( + topIntroBuilder: StringBuilder, + footer: FooterPreference, + useGenerated: Preference, + useOwn: Preference, + @StringRes introInfoTextId: Int, + @StringRes footerTextId: Int, + @DrawableRes useGeneratedDrawableId: Int, + @StringRes useGeneratedTitleTextId: Int, + @StringRes useGeneratedSummaryTextId: Int, + minMetricsTooRestrictive: Boolean, + minSize: Int, + maxSize: Int, + @DrawableRes useOwnDrawableId: Int, + @StringRes useOwnTitleId: Int, + ) { + topIntroBuilder.append(getString(introInfoTextId)) + footer.setTitle(footerTextId) + + useGenerated.setIcon(useGeneratedDrawableId) + useGenerated.setTitle(useGeneratedTitleTextId) + useGenerated.summary = if (!minMetricsTooRestrictive) { + getString(useGeneratedSummaryTextId, minSize, maxSize) + } else { + getString(R.string.lock_screen_choice_disabled_due_to_device_policy_summary) + } + + useOwn.setIcon(useOwnDrawableId) + useOwn.setTitle(useOwnTitleId) + } + + private fun addHintIfNeeded( + intent: Intent, + topIntroBuilder: StringBuilder, + isAlphaMode: Boolean + ) { + val stageType = if ( + intent.getBooleanExtra(ChooseLockSettingsHelper.EXTRA_KEY_FOR_FINGERPRINT, false) + ) { + ChooseLockPasswordFragment.Stage.TYPE_FINGERPRINT + } else if ( + intent.getBooleanExtra(ChooseLockSettingsHelper.EXTRA_KEY_FOR_FACE, false) + ) { + ChooseLockPasswordFragment.Stage.TYPE_FACE + } else if ( + intent.getBooleanExtra(ChooseLockSettingsHelper.EXTRA_KEY_FOR_BIOMETRICS, false) + ) { + ChooseLockPasswordFragment.Stage.TYPE_BIOMETRIC + } else { + ChooseLockPasswordFragment.Stage.TYPE_NONE + } + + val profileType = ChooseLockPasswordFragment.getProfileType( + context, mUserId + ) + val hint = ChooseLockPasswordFragment.Stage.Introduction.getHint( + context, isAlphaMode, stageType, profileType + ) + + // if not under a context like a profile or setting up biometrics, the hint will be + // redundant + val defaultPinHint = + getString(ChooseLockPasswordFragment.Stage.Introduction.numericHint) + val defaultPasswordHint = + getString(ChooseLockPasswordFragment.Stage.Introduction.alphaHint) + if (defaultPinHint != hint && defaultPasswordHint != hint) { + topIntroBuilder.append(hint) + topIntroBuilder.append("\n\n") + } + } + + class SecureElementInfoDialog : DialogFragment() { + override fun show(manager: FragmentManager, tag: String?) { + if (manager.findFragmentByTag(tag) == null) { + // Prevent opening multiple dialogs if tapped on button quickly + super.show(manager, tag) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return AlertDialog.Builder(activity!!) + .setTitle(R.string.lock_screen_generate_learn_more_dialog_title) + .setMessage(R.string.lock_screen_generate_learn_more_dialog_body) + .setPositiveButton(android.R.string.ok) { _, _ -> dismiss() } + .create() + } + } +} diff --git a/src/com/android/settings/password/generate/GeneratedPassword.kt b/src/com/android/settings/password/generate/GeneratedPassword.kt new file mode 100644 index 00000000000..21b562bbc5a --- /dev/null +++ b/src/com/android/settings/password/generate/GeneratedPassword.kt @@ -0,0 +1,118 @@ +package com.android.settings.password.generate + +import com.android.internal.widget.LockscreenCredential +import java.security.SecureRandom + +// Generated passwords are stored as a String, since they have to be shown to the user in the UI +// as options +sealed class GeneratedPassword { + abstract fun toLockscreenCredential(): LockscreenCredential +} + +class GeneratedPin(val pin: String) : GeneratedPassword() { + override fun toLockscreenCredential(): LockscreenCredential { + return LockscreenCredential.createPin(pin) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as GeneratedPin + return areStringsEqualConstantTime(pin, other.pin) + } + + companion object { + fun generate(random: SecureRandom, length: Int): GeneratedPin { + require(length > 0) { "invalid length $length" } + // Although we could use random.nextInt(10^length) and do formatted .toString() on that, + // the bound parameter for the nextInt method is only an integer, so lengths longer than + // floor(log10(Integer.MAX_VALUE)) = 9 digits would overflow. This is not an issue for + // 6-8 digit pins + val digits = CharArray(length) { '0' + random.nextInt(10) } + return GeneratedPin(String(digits)) + } + } +} + +class GeneratedPassphrase(val passphrase: String) : GeneratedPassword() { + override fun toLockscreenCredential(): LockscreenCredential { + return LockscreenCredential.createPassword(passphrase) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as GeneratedPassphrase + return areStringsEqualConstantTime(passphrase, other.passphrase) + } + + companion object { + fun generate( + random: SecureRandom, + dicewareWordList: DicewareWordList, + numWords: Int + ): GeneratedPassphrase { + require(numWords > 0) { "invalid numWords $numWords" } + val passphrase = generateSequence { dicewareWordList.getRandomWord(random) } + .take(numWords) + .joinToString(separator = " ") + return GeneratedPassphrase(passphrase) + } + + fun calculateMaxPossibleStringLength(numWords: Int, allowResampling: Boolean = true): Int { + require(numWords >= 0) { "invalid numWords $numWords" } + if (numWords == 0) return 0 + + val numSpaces = numWords - 1 + if (allowResampling) { + return DicewareWordList.MAX_WORD_LENGTH * numWords + numSpaces + } + + var currentLettersCount = 0 + var wordsRemaining = numWords + for (currentWordLength in DicewareWordList.MAX_WORD_LENGTH downTo 0) { + val availableWordsForLength = DicewareWordList.WORD_LENGTH_FREQUENCIES.getOrDefault(currentWordLength, 0) + if (wordsRemaining <= availableWordsForLength) { + currentLettersCount += wordsRemaining * currentWordLength + break + } else { + wordsRemaining -= availableWordsForLength + currentLettersCount += availableWordsForLength * currentWordLength + } + } + return currentLettersCount + numSpaces + } + } +} + +// Code from libcore/ojluni/src/main/java/java/security/MessageDigest.java#isEqual, adapted to +// Strings to avoid having to create byte array copies. Maybe this isn't necessary, since +// LockscreenCredential just uses Array.equals, and there might be TextView code using normal +// string equality anyway, since these will be in the UI +// +// Original impl note: All bytes in {@code digesta} are examined to determine equality. +// The calculation time depends only on the length of {@code digesta}. +// It does not depend on the length of {@code digestb} or the contents +// of {@code digesta} and {@code digestb}. +fun areStringsEqualConstantTime(digesta: String?, digestb: String?): Boolean { + if (digesta === digestb) return true + if (digesta == null || digestb == null) return false + + val lenA = digesta.length + val lenB = digestb.length + + if (lenB == 0) { + return lenA == 0 + } + + var result = 0 + result = result or lenA - lenB + + // time-constant comparison + for (i in 0 until lenA) { + // If i >= lenB, indexB is 0; otherwise, i. + val indexB = ((i - lenB) ushr 31) * i + result = result or (digesta[i].code xor digestb[indexB].code) + } + return result == 0 +} diff --git a/src/com/android/settings/password/generate/LockPasswordGenerationParamsFragment.kt b/src/com/android/settings/password/generate/LockPasswordGenerationParamsFragment.kt new file mode 100644 index 00000000000..5ae10b6525b --- /dev/null +++ b/src/com/android/settings/password/generate/LockPasswordGenerationParamsFragment.kt @@ -0,0 +1,116 @@ +package com.android.settings.password.generate + +import com.android.settings.R +import android.os.Bundle +import android.view.View +import android.widget.SeekBar +import com.android.settings.widget.LabeledSeekBarPreference +import com.android.settingslib.widget.FooterPreference +import com.google.android.setupcompat.template.FooterBarMixin +import com.google.android.setupcompat.template.FooterButton +import com.google.android.setupdesign.GlifPreferenceLayout +import kotlinx.coroutines.flow.filterNotNull + +private const val PREF_GEN_LENGTH_KEY = "generation_length" +private const val PREF_WARNING_FOOTER_KEY = "warning_footer" + +class LockPasswordGenerationParamsFragment : BaseLockPasswordGenerationPreferenceFragment( + prefResId = R.xml.screen_lock_generation_params, + shouldGcOnDestroy = false +) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val glifLayout = view as GlifPreferenceLayout + + val onNextButtonClick = View.OnClickListener { viewModel.primaryButtonClicked() } + val footerBarMixin = glifLayout.getMixin(FooterBarMixin::class.java) + footerBarMixin.primaryButton = + FooterButton.Builder(requireContext()) + .setText(R.string.lock_screen_generate_options_next_button) + .setListener(onNextButtonClick) + .setButtonType(FooterButton.ButtonType.NEXT) + .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Primary) + .build() + + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.passType.filterNotNull() + ) { passType -> + glifLayout.apply { + when (passType) { + GenerateLockPasswordViewModel.PassType.Pin -> { + icon = activity!!.getDrawable(R.drawable.ic_lock_pin) + setHeaderText(R.string.lock_screen_generate_options_title_pin) + setDescriptionText(R.string.lock_screen_generate_options_desc_pin) + } + + GenerateLockPasswordViewModel.PassType.Passphrase -> { + icon = activity!!.getDrawable(R.drawable.ic_password) + setHeaderText(R.string.lock_screen_generate_options_title_passphrase) + setDescriptionText(R.string.lock_screen_generate_options_desc_passphrase) + } + } + } + } + + val footer = findPreference(PREF_WARNING_FOOTER_KEY)!! + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.passphraseMaxWordsEntropyWarning + ) { warning -> + if (warning != null) { + footer.title = getString( + R.string.lock_screen_generate_options_warning_footer_lose_entropy_d_to_d, + warning.fullNumberOfWords, warning.maxPasswordLength + ) + footer.isVisible = true + } else { + footer.isVisible = false + } + } + + val seekbar = findPreference(PREF_GEN_LENGTH_KEY)!! + seekbar.setContinuousUpdates(true) + seekbar.setTriggerUserChangeOnIconPress(true) + seekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + viewModel.setNewLength(progress) + } + } + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + }) + + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.genParams.filterNotNull() + ) { genOpts -> + seekbar.apply { + // Post to the recycler view to avoid IllegalStateException: Cannot call this + // method while RecyclerView is computing a layout or scrolling. Might not be + // needed now that we check fromUser in the OnSeekBarChangeListener + glifLayout.recyclerView.post { + min = genOpts.minSize + max = genOpts.maxSize + when (genOpts) { + is DicewarePassphraseGenParams -> { + progress = genOpts.words + title = getString(R.string.lock_screen_generate_options_length_slider_title_passphrase) + summary = getString( + R.string.lock_screen_generate_options_length_slider_summary_passphrase, + genOpts.words + ) + } + is PinGenParams -> { + progress = genOpts.digits + title = getString(R.string.lock_screen_generate_options_length_slider_title_pin) + summary = getString( + R.string.lock_screen_generate_options_length_slider_summary_pin, + genOpts.digits + ) + } + } + } + } + } + } +} diff --git a/src/com/android/settings/password/generate/PassGenParams.kt b/src/com/android/settings/password/generate/PassGenParams.kt new file mode 100644 index 00000000000..d0e01dfb354 --- /dev/null +++ b/src/com/android/settings/password/generate/PassGenParams.kt @@ -0,0 +1,35 @@ +package com.android.settings.password.generate + +sealed class PassGenParams { + abstract val minSize: Int + abstract val maxSize: Int + abstract val numberToGenerate: Int +} + +data class PinGenParams( + val digits: Int, + override val numberToGenerate: Int, + override val minSize: Int, + override val maxSize: Int, + val autoPinConfirm: Boolean = false, +): PassGenParams() { + companion object { + const val DEFAULT_MIN_DIGITS = 6 + const val DEFAULT_MAX_DIGITS = 8 + } +} + +data class DicewarePassphraseGenParams( + val words: Int, + override val numberToGenerate: Int, + override val minSize: Int, + override val maxSize: Int, +): PassGenParams() { + + val minWords: Int get() = minSize + + companion object { + const val MIN_WORDS = 4 + const val MAX_WORDS = 8 + } +} diff --git a/src/com/android/settings/password/generate/PassGenStage.kt b/src/com/android/settings/password/generate/PassGenStage.kt new file mode 100644 index 00000000000..3301a56cc1d --- /dev/null +++ b/src/com/android/settings/password/generate/PassGenStage.kt @@ -0,0 +1,27 @@ +package com.android.settings.password.generate + +sealed class PassGenStage(val fragmentTag: String) { + abstract val isBackwards: Boolean + data class ChooseGeneratedOrManual(override val isBackwards: Boolean) : PassGenStage("choose-gen-or-manual") + data class ChooseParams(override val isBackwards: Boolean) : PassGenStage("choose-params") + data class ShowMultiple(override val isBackwards: Boolean) : PassGenStage("show-multiple") + sealed class Confirmation(val stageNumber: Int) : PassGenStage("confirm") { + data object ConfirmWithVisible : Confirmation(1) + data object ConfirmWithoutVisible : Confirmation(2) + data object ConfirmLast : Confirmation(3) + + override val isBackwards: Boolean = false + + companion object { + fun fromStageNumber(stageNumber: Int): Confirmation? = when (stageNumber) { + 1 -> ConfirmWithVisible + 2 -> ConfirmWithoutVisible + 3 -> ConfirmLast + else -> null + } + } + } + data object Quit : PassGenStage("exit") { + override val isBackwards: Boolean = true + } +} diff --git a/src/com/android/settings/password/generate/PasswordComplexity.kt b/src/com/android/settings/password/generate/PasswordComplexity.kt new file mode 100644 index 00000000000..6a24c59a663 --- /dev/null +++ b/src/com/android/settings/password/generate/PasswordComplexity.kt @@ -0,0 +1,30 @@ +package com.android.settings.password.generate + +import android.app.admin.DevicePolicyManager +import androidx.annotation.Keep + +// Class just to consolidate various AOSP password complexity details. Unit test against platform +// implementation with atest SettingsUnitTests:PasswordStrengthTest +enum class PasswordComplexity( + val complexityValue: Int, + // get these from the DevicePolicyManager documentation + val pinLength: Int, + @get:Keep + val alphaNumericLength: Int +) { + NONE(DevicePolicyManager.PASSWORD_COMPLEXITY_NONE, 0, 0), + LOW(DevicePolicyManager.PASSWORD_COMPLEXITY_LOW, 0, 0), + MEDIUM(DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM, 4, 4), + HIGH(DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH, 8, 6); + + companion object { + fun fromLevel(level: Int, minLevel: PasswordComplexity) = + when (level) { + DevicePolicyManager.PASSWORD_COMPLEXITY_NONE -> NONE + DevicePolicyManager.PASSWORD_COMPLEXITY_LOW -> LOW + DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM -> MEDIUM + DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH -> HIGH + else -> minLevel + }.coerceIn(minimumValue = minLevel, maximumValue = entries.last()) + } +} diff --git a/src/com/android/settings/password/generate/ShowGeneratedLockPassOptionsFragment.kt b/src/com/android/settings/password/generate/ShowGeneratedLockPassOptionsFragment.kt new file mode 100644 index 00000000000..b8188d2b8de --- /dev/null +++ b/src/com/android/settings/password/generate/ShowGeneratedLockPassOptionsFragment.kt @@ -0,0 +1,181 @@ +package com.android.settings.password.generate + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.RadioButton +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.android.settings.R +import com.google.android.setupcompat.template.FooterBarMixin +import com.google.android.setupcompat.template.FooterButton +import com.google.android.setupdesign.GlifLayout +import kotlinx.coroutines.flow.filterNotNull + +private const val TAG = "ShowGeneratedLockPassOptionsFragment" + +class ShowGeneratedLockPassOptionsFragment : BaseLockPasswordGenerationFragment( + R.layout.generate_lock_password_show_generated +) { + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val layout = view as GlifLayout + + val progressBar = view.findViewById(R.id.progress_bar) + val errorMessage = view.findViewById(R.id.error_message) + val recyclerView = view.findViewById(R.id.pass_list) + recyclerView.layoutManager = LinearLayoutManager(view.context) + recyclerView.adapter = PassOptionsRecyclerViewAdapter(viewModel) + // not a lot of items shown anyway + recyclerView.isNestedScrollingEnabled = false + recyclerView.setHasFixedSize(false) + + val footerBarMixin = layout.getMixin(FooterBarMixin::class.java) + footerBarMixin.primaryButton = + FooterButton.Builder(requireContext()) + .setText(R.string.lock_screen_generate_options_next_button) + .setListener { viewModel.primaryButtonClicked() } + .setButtonType(FooterButton.ButtonType.NEXT) + .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Primary) + .build() + footerBarMixin.secondaryButton = + FooterButton.Builder(requireContext()) + .setText(R.string.lock_screen_generate_options_regenerate_button) + .setListener { viewModel.generateNewPasswords() } + .setButtonType(FooterButton.ButtonType.CLEAR) + .setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Secondary) + .build() + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.isGenerating) { isGenerating -> + footerBarMixin.secondaryButton?.isEnabled = !isGenerating + } + + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.isPrimaryButtonEnabled + ) { isEnabled -> + footerBarMixin.primaryButton?.isEnabled = isEnabled + } + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.selectedPassword) { _ -> + // notifying individual items causes a fade effect, and there are not that + // many items anyway + recyclerView.adapter?.notifyDataSetChanged() + } + + viewLifecycleOwner.repeatCollectOnLifecycle(viewModel.generatedPasswords) { passes -> + when (passes) { + is GenerateLockPasswordViewModel.GenerateState.Error -> { + progressBar.visibility = View.GONE + errorMessage.visibility = View.VISIBLE + errorMessage.text = getString( + R.string.lock_screen_generate_show_generated_error_message_s, + passes.errorMessage + ) + } + is GenerateLockPasswordViewModel.GenerateState.Loaded -> { + progressBar.visibility = View.GONE + errorMessage.visibility = View.GONE + errorMessage.text = "" + } + GenerateLockPasswordViewModel.GenerateState.NotLoaded -> { + progressBar.visibility = View.VISIBLE + errorMessage.visibility = View.GONE + errorMessage.text = "" + } + } + + recyclerView.adapter?.notifyDataSetChanged() + } + + viewLifecycleOwner.repeatCollectOnLifecycle( + viewModel.genParams.filterNotNull() + ) { genOptions -> + layout.apply { + when (genOptions) { + is PinGenParams -> { + icon = requireActivity().getDrawable(R.drawable.ic_lock_pin) + setHeaderText(R.string.lock_screen_generate_show_generated_title_pin) + setDescriptionText( + getString(R.string.lock_screen_generate_show_generated_desc_pin_d_pins_been_gen, genOptions.numberToGenerate) + ) + } + is DicewarePassphraseGenParams -> { + icon = requireActivity().getDrawable(R.drawable.ic_password) + setHeaderText(R.string.lock_screen_generate_show_generated_title_passphrase) + setDescriptionText( + getString(R.string.lock_screen_generate_show_generated_desc_passphrase_d_passphrases_been_gen, genOptions.numberToGenerate) + ) + } + } + } + } + } +} + +class PassOptionsRecyclerViewAdapter( + val viewModel: GenerateLockPasswordViewModel +) : RecyclerView.Adapter() { + + init { + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long = + viewModel.getGeneratedPasswordIdForRecyclerView(position) + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val radioButton: RadioButton = itemView.findViewById(R.id.list_radio_button) + val text: TextView = itemView.findViewById(R.id.list_text) + + fun cleanUp() { + text.text = "" + text.setOnClickListener(null) + radioButton.setOnCheckedChangeListener(null) + } + } + + override fun onViewRecycled(holder: ViewHolder) { + super.onViewRecycled(holder) + holder.cleanUp() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view: View = inflater.inflate( + R.layout.generate_lock_password_show_generated_list_item, + parent, + false + ) + return ViewHolder(view) + } + + override fun getItemCount(): Int = viewModel.generatedPasswords.value.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val generatedPassword = viewModel.generatedPasswords.value.listOrNull()?.getOrNull(position) + val isSelected = viewModel.selectedPassword.value?.index == position + holder.apply { + radioButton.setOnCheckedChangeListener(null) + radioButton.setChecked(isSelected) + val listener = View.OnClickListener { radioButton.toggle() } + text.setOnClickListener(listener) + radioButton.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + viewModel.setSelectedPassword(position) + } + } + text.text = when (generatedPassword) { + is GeneratedPassphrase -> generatedPassword.passphrase + is GeneratedPin -> generatedPassword.pin + null -> "" + } + } + } +} diff --git a/src/com/android/settings/widget/LabeledSeekBarPreference.java b/src/com/android/settings/widget/LabeledSeekBarPreference.java index 6300bd3b318..aab44ace3d1 100644 --- a/src/com/android/settings/widget/LabeledSeekBarPreference.java +++ b/src/com/android/settings/widget/LabeledSeekBarPreference.java @@ -63,6 +63,9 @@ public class LabeledSeekBarPreference extends SeekBarPreference { private OnPreferenceChangeListener mStopListener; private SeekBar.OnSeekBarChangeListener mSeekBarChangeListener; + // GrapheneOS: false by default to preserve semantics elsewhere in Settings app + private boolean mTriggerUserChangeOnIconPress = false; + private SeekBar mSeekBar; public LabeledSeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr, @@ -196,6 +199,10 @@ public void setOnSeekBarChangeListener(SeekBar.OnSeekBarChangeListener seekBarCh mSeekBarChangeListener = seekBarChangeListener; } + public void setTriggerUserChangeOnIconPress(boolean triggerUserChangeOnIconPress) { + mTriggerUserChangeOnIconPress = triggerUserChangeOnIconPress; + } + private void updateIconStartIfNeeded(ViewGroup iconFrame, ImageView iconStart, SeekBar seekBar) { if (mIconStartId == 0) { @@ -216,6 +223,10 @@ private void updateIconStartIfNeeded(ViewGroup iconFrame, ImageView iconStart, final int progress = getProgress(); if (progress > 0) { setProgress(progress - 1); + if (mTriggerUserChangeOnIconPress && getProgress() == progress - 1 && + mSeekBarChangeListener != null) { + mSeekBarChangeListener.onProgressChanged(seekBar, progress - 1, true); + } } }); @@ -242,6 +253,10 @@ private void updateIconEndIfNeeded(ViewGroup iconFrame, ImageView iconEnd, SeekB final int progress = getProgress(); if (progress < getMax()) { setProgress(progress + 1); + if (mTriggerUserChangeOnIconPress && getProgress() == progress + 1 && + mSeekBarChangeListener != null) { + mSeekBarChangeListener.onProgressChanged(seekBar, progress + 1, true); + } } }); diff --git a/tests/unit/src/com/android/settings/password/generate/DicewareWordListTest.kt b/tests/unit/src/com/android/settings/password/generate/DicewareWordListTest.kt new file mode 100644 index 00000000000..f1d3a4cd27a --- /dev/null +++ b/tests/unit/src/com/android/settings/password/generate/DicewareWordListTest.kt @@ -0,0 +1,64 @@ +package com.android.settings.password.generate + +import android.app.admin.DevicePolicyManager +import android.app.admin.PasswordMetrics +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.security.SecureRandom +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +// atest -c SettingsUnitTests:DicewareWordListTest +@RunWith(AndroidJUnit4::class) +class DicewareWordListTest { + lateinit var mContext: Context + + @Before + fun setUp() { + mContext = ApplicationProvider.getApplicationContext() + } + + @Test + fun testBadWordList() { + val badStream = """ + abc + these + words + are + bad + """.trimIndent().toByteArray().inputStream() + + + runBlocking { + try { + DicewareWordList.loadWordsInner(badStream, Dispatchers.IO) + throw IllegalStateException("expected wordlist construction failure") + } catch (_: DicewareWordList.LoadException) {} + } + } + + @Test + fun testWordList(): Unit = runBlocking { + val random = SecureRandom(byteArrayOf(1,2,3)) + + val wordlist = DicewareWordList.loadWords(mContext, Dispatchers.IO) + + val word = wordlist.getRandomWord(random) + + val maxWords = DicewarePassphraseGenParams.MAX_WORDS + val numSpaces = maxWords - 1 + val maxPassphraseLength = DicewareWordList.MAX_WORD_LENGTH * maxWords + numSpaces + assertThat(maxPassphraseLength).isAtMost(DevicePolicyManager.MAX_PASSWORD_LENGTH) + + val longWords = wordlist.wordList() + .filter { + PasswordMetrics.maxLengthSequence(it.encodeToByteArray()) > PasswordMetrics.MAX_ALLOWED_SEQUENCE + } + assertThat(longWords).isEqualTo(listOf("overstuff")) + } +} diff --git a/tests/unit/src/com/android/settings/password/generate/GenerateLockPasswordTest.kt b/tests/unit/src/com/android/settings/password/generate/GenerateLockPasswordTest.kt new file mode 100644 index 00000000000..b1490c1cddb --- /dev/null +++ b/tests/unit/src/com/android/settings/password/generate/GenerateLockPasswordTest.kt @@ -0,0 +1,523 @@ +package com.android.settings.password.generate + +import android.app.Application +import android.app.admin.DevicePolicyManager +import android.app.admin.PasswordMetrics +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.internal.widget.LockPatternUtils +import com.android.internal.widget.LockscreenCredential +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GenerateLockPasswordTest { + + private lateinit var mContext: Context + + private val defaultMinMetrics = PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_NONE) + + private val defaultMinComplexity = DevicePolicyManager.PASSWORD_COMPLEXITY_LOW + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mContext = ApplicationProvider.getApplicationContext() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testSetupPinLengthsByComplexity(): Unit = testScope.runTest { + GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher).let { viewModel -> + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, DevicePolicyManager.PASSWORD_COMPLEXITY_LOW) + advanceUntilIdle() + + val params = requireNotNull(viewModel.genParams.value) + assertThat(viewModel.minPasswordComplexity).isEqualTo(PasswordComplexity.MEDIUM) + assertThat(params.minSize).isEqualTo(PinGenParams.DEFAULT_MIN_DIGITS) + assertThat(params.maxSize).isEqualTo(PinGenParams.DEFAULT_MAX_DIGITS) + } + + GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher).let { viewModel -> + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM) + advanceUntilIdle() + val params = requireNotNull(viewModel.genParams.value) + assertThat(viewModel.minPasswordComplexity).isEqualTo(PasswordComplexity.MEDIUM) + assertThat(params.minSize).isEqualTo(PinGenParams.DEFAULT_MIN_DIGITS) + assertThat(params.maxSize).isEqualTo(PinGenParams.DEFAULT_MAX_DIGITS) + } + + GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher).let { viewModel -> + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH) + advanceUntilIdle() + val params = requireNotNull(viewModel.genParams.value) + assertThat(viewModel.minPasswordComplexity).isEqualTo(PasswordComplexity.HIGH) + assertThat(params.minSize).isEqualTo(PasswordComplexity.HIGH.pinLength) + assertThat(params.maxSize).isEqualTo(PasswordComplexity.HIGH.pinLength) + } + } + + @Test + fun testSetupAndGeneratePinLengthsByMetrics(): Unit = testScope.runTest { + GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher).let { viewModel -> + val metric = PasswordMetrics( + /* credType = */ LockPatternUtils.CREDENTIAL_TYPE_PIN, + /* length = */ 10, + /* letters = */ 0, + /* upperCase = */ 0, + /* lowerCase = */ 0, + /* numeric = */ 0, + /* symbols = */ 0, + /* nonLetter = */ 0, + /* nonNumeric = */ 0, + /* seqLength = */ PasswordMetrics.MAX_ALLOWED_SEQUENCE + ) + viewModel.setup(isAlphabeticalMode = false, metric, defaultMinComplexity) + advanceUntilIdle() + val params = requireNotNull(viewModel.genParams.value) + assertThat(viewModel.minPasswordComplexity).isEqualTo(PasswordComplexity.MEDIUM) + require(params is PinGenParams) { "expected PinGenParams, but got $params" } + assertThat(params.digits).isEqualTo(10) + assertThat(params.minSize).isEqualTo(10) + assertThat(params.maxSize).isEqualTo(10) + + advanceAndAssertToViewOptionsStage(viewModel) + + val generatedPins = viewModel.generatedPasswords.value + require(generatedPins is GenerateLockPasswordViewModel.GenerateState.Loaded) { + "expected generation, but got state ${viewModel.generatedPasswords.value}" + } + + assertThat( + generatedPins.list.all { (it as GeneratedPin).pin.length == 10 } + ).isTrue() + } + } + + @Test + fun testSetupFailureWhenMetricsTooRestrictive() : Unit = testScope.runTest { + val viewModel = GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher) + val restrictiveMetric = PasswordMetrics( + /* credType = */ LockPatternUtils.CREDENTIAL_TYPE_PASSWORD, + /* length = */ 10, + /* letters = */ 8, + /* upperCase = */ 2, + /* lowerCase = */ 0, + /* numeric = */ 0, + /* symbols = */ 6, + /* nonLetter = */ 5, + /* nonNumeric = */ 0, + /* seqLength = */ Integer.MAX_VALUE + ) + viewModel.setup(isAlphabeticalMode = true, restrictiveMetric, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.isPrimaryButtonEnabled.value).isFalse() + assertThat(viewModel.areMinMetricsRestrictive.first()).isTrue() + } + + @Test + fun testSetupIdempotence(): Unit = testScope.runTest { + GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher).let { viewModel -> + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.passType.value) + .isEqualTo(GenerateLockPasswordViewModel.PassType.Pin) + viewModel.setup(isAlphabeticalMode = true, defaultMinMetrics, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.passType.value) + .isEqualTo(GenerateLockPasswordViewModel.PassType.Pin) + } + + GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher).let { viewModel -> + viewModel.setup(isAlphabeticalMode = true, defaultMinMetrics, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.passType.value) + .isEqualTo(GenerateLockPasswordViewModel.PassType.Passphrase) + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.passType.value) + .isEqualTo(GenerateLockPasswordViewModel.PassType.Passphrase) + } + } + + @Test + fun testTappingQuicklyOnGenerateNewButtonIsLimited(): Unit = testScope.runTest { + val viewModel = GenerateLockPasswordViewModel(mContext as Application, testDispatcher, testDispatcher) + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, defaultMinComplexity) + advanceAndAssertToViewOptionsStage(viewModel) + + val generatedPins = viewModel.generatedPasswords.value + require(generatedPins is GenerateLockPasswordViewModel.GenerateState.Loaded) { + "expected generation, but got $generatedPins" + } + + assertThat(viewModel.generationCount).isEqualTo(1) + repeat(100) { + launch { viewModel.generateNewPasswords() } + } + advanceUntilIdle() + + val generatedPinsAgain = viewModel.generatedPasswords.value + assertThat(generatedPinsAgain).isNotEqualTo(generatedPins) + assertThat(viewModel.generationCount).isEqualTo(2) + assertThat(generatedPinsAgain).isNotNull() + assertThat(generatedPins).isNotEqualTo(generatedPinsAgain) + assertThat(viewModel.selectedPassword.value).isNull() + } + + @Test + fun testPassphrase() : Unit = testScope.runTest { + val viewModel = GenerateLockPasswordViewModel( + mContext as Application, + testDispatcher, + testDispatcher + ) + viewModel.setup(isAlphabeticalMode = true, defaultMinMetrics, defaultMinComplexity) + advanceAndAssertToViewOptionsStage(viewModel) + advanceUntilIdle() + val generatedPassphrases = viewModel.generatedPasswords.value + require(generatedPassphrases is GenerateLockPasswordViewModel.GenerateState.Loaded) + + assertThat(viewModel.generationCount).isEqualTo(1) + assertThat(generatedPassphrases.size).isGreaterThan(0) + assertThat(generatedPassphrases.size).isEqualTo(generatedPassphrases.list.size) + assertThat(generatedPassphrases.list.all { it is GeneratedPassphrase }).isTrue() + } + + @Test + fun testPassphraseWithOkayMetrics() : Unit = testScope.runTest { + val viewModel = GenerateLockPasswordViewModel( + mContext as Application, + testDispatcher, + testDispatcher + ) + // should be okay because spaces are symbols and nonLetter + val okayMetric = PasswordMetrics( + /* credType = */ LockPatternUtils.CREDENTIAL_TYPE_PASSWORD, + /* length = */ 10, + /* letters = */ 10, + /* upperCase = */ 0, + /* lowerCase = */ 0, + /* numeric = */ 0, + /* symbols = */ 2, + /* nonLetter = */ 2, + /* nonNumeric = */ 2, + /* seqLength = */ Integer.MAX_VALUE + ) + viewModel.setup(isAlphabeticalMode = true, okayMetric, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.areMinMetricsRestrictive.first()).isFalse() + + advanceAndAssertToViewOptionsStage(viewModel) + advanceUntilIdle() + + val generatedPassphrases = viewModel.generatedPasswords.value + require(generatedPassphrases is GenerateLockPasswordViewModel.GenerateState.Loaded) { + "expected generation, but got state $generatedPassphrases" + } + + assertThat(viewModel.generationCount).isEqualTo(1) + assertThat(generatedPassphrases.size).isGreaterThan(0) + assertThat(generatedPassphrases.list.all { it is GeneratedPassphrase }).isTrue() + } + + @Test + fun testSaveEdgeCaseGoingBackOnSave() = testScope.runTest { + val viewModel = GenerateLockPasswordViewModel( + application = mContext as Application, + backgroundDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + ) + viewModel.setup(isAlphabeticalMode = false, defaultMinMetrics, defaultMinComplexity) + advanceUntilIdle() + advanceAndAssertToViewOptionsStage(viewModel) + viewModel.setSelectedPassword(0) + advanceUntilIdle() + + viewModel.primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmWithVisible) + val selection = requireNotNull(viewModel.selectedPassword.value) + val selected = viewModel.getPassword(selection) as GeneratedPin + viewModel.setInputLength(selected.pin.length) + advanceUntilIdle() + + viewModel.primaryButtonClicked(selected.pin) + assertThat(viewModel.isPrimaryButtonEnabled.value).isTrue() + advanceUntilIdle() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmWithoutVisible) + viewModel.setInputLength(selected.pin.length) + advanceUntilIdle() + + viewModel.primaryButtonClicked(selected.pin) + advanceUntilIdle() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmLast) + viewModel.setInputLength(selected.pin.length) + advanceUntilIdle() + assertThat(viewModel.saveRequest.value).isEqualTo(GenerateLockPasswordViewModel.SaveRequest.Inactive) + + viewModel.primaryButtonClicked(selected.pin) + advanceUntilIdle() + val saveRequest = viewModel.saveRequest.value + require(saveRequest is GenerateLockPasswordViewModel.SaveRequest.Requested) + val credentialFromSaveRequest = saveRequest.credential + val credentialFromSelection = (viewModel.selectedPassword.value + as GenerateLockPasswordViewModel.Selection.ForConfirmation).credential + assertThat(credentialFromSaveRequest.credential).isNotNull() + assertThat(credentialFromSelection.credential).isNotNull() + assertThat(credentialFromSaveRequest).isEqualTo(credentialFromSelection) + assertThat(credentialFromSaveRequest).isNotSameInstanceAs(credentialFromSelection) + + viewModel.onBackPressed() + advanceUntilIdle() + assertThat(credentialFromSaveRequest.credential).isNotNull() + assertCredentialZeroized(credentialFromSelection) + } + + @Test + fun testFullRunOfAllTypesAndSize() = testScope.runTest { + // In JUnit4, parametrized tests aren't easy to run + val wordList: Set = + DicewareWordList.loadWords(mContext, testDispatcher).wordList().toSet() + + for (passType in GenerateLockPasswordViewModel.PassType.entries) { + val autoPinSettings: Array + val (minSize, maxSize) = when (passType) { + GenerateLockPasswordViewModel.PassType.Pin -> { + autoPinSettings = arrayOf(false, true) + PinGenParams.DEFAULT_MIN_DIGITS to PinGenParams.DEFAULT_MAX_DIGITS + } + GenerateLockPasswordViewModel.PassType.Passphrase -> { + autoPinSettings = arrayOf(false) + DicewarePassphraseGenParams.MIN_WORDS to DicewarePassphraseGenParams.MAX_WORDS + } + } + + + for (isSetAutoPin in autoPinSettings) { + for (size in minSize..maxSize) { + try { + doFullRun(wordList, passType, size, isSetAutoPin) + } catch (e: Exception) { + throw AssertionError( + "doFullRun failed with $passType, size $size, isSetAutoPin $isSetAutoPin", e + ) + } + } + } + } + } + + private fun TestScope.doFullRun( + wordList: Set, + passType: GenerateLockPasswordViewModel.PassType, + passwordSize: Int, + isSetAutoPin: Boolean, + ) { + val viewModel = GenerateLockPasswordViewModel( + application = mContext as Application, + backgroundDispatcher = testDispatcher, + ioDispatcher = testDispatcher, + ) + val isPassphrase = passType == GenerateLockPasswordViewModel.PassType.Passphrase + + viewModel.setup(isAlphabeticalMode = isPassphrase, defaultMinMetrics, defaultMinComplexity) + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ChooseGeneratedOrManual::class.java) + + viewModel.primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ChooseParams::class.java) + + viewModel.setNewLength(passwordSize) + advanceUntilIdle() + + viewModel.primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ShowMultiple::class.java) + + var previousList: List? = null + + repeat(50) { iteration -> + if (previousList != null) { + viewModel.generateNewPasswords() + advanceUntilIdle() + } + + val generated = viewModel.generatedPasswords.value + require(generated is GenerateLockPasswordViewModel.GenerateState.Loaded) { + "expected generation, but got state $generated" + } + + assertThat(viewModel.generationCount).isEqualTo(iteration + 1) + assertThat(generated).isNotNull() + if (previousList != null) { + assertThat(previousList).isNotEqualTo(generated.list) + } + previousList = generated.listOrNull() + assertThat(viewModel.isPrimaryButtonEnabled.value).isFalse() + assertThat(viewModel.selectedPassword.value).isNull() + if (isPassphrase) { + for (genPassphrase in generated.list) { + check(genPassphrase is GeneratedPassphrase) { "expected passphrase" } + val wordCount = genPassphrase.passphrase + .splitToSequence(' ') + .count() + assertThat(wordCount).isEqualTo(passwordSize) + + for (word in genPassphrase.passphrase.splitToSequence(' ')) { + assertThat(wordList).contains(word) + } + } + } else { + assertThat(generated.list.all { it is GeneratedPin }).isTrue() + assertThat(generated.list.all { (it as GeneratedPin).pin.length == passwordSize }).isTrue() + } + + for (i in generated.list.indices) { + val selected = generated.list[i] + viewModel.setSelectedPassword(i) + advanceUntilIdle() + val selection = requireNotNull(viewModel.selectedPassword.value) + assertThat(selection).isInstanceOf(GenerateLockPasswordViewModel.Selection.IndexOnly::class.java) + assertThat(selection.index).isEqualTo(i) + assertThat(viewModel.getPassword(selection)).isEqualTo(selected) + assertThat(viewModel.isPrimaryButtonEnabled.value).isTrue() + } + } + + viewModel.generateNewPasswords() + advanceUntilIdle() + + val finalGenerated = viewModel.generatedPasswords.value + require(finalGenerated is GenerateLockPasswordViewModel.GenerateState.Loaded) { + "expected generation, but got state $finalGenerated" + } + assertThat(finalGenerated).isNotNull() + assertThat(viewModel.selectedPassword.value).isNull() + + viewModel.primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ShowMultiple::class.java) + viewModel.setSelectedPassword(0) + advanceUntilIdle() + + val actualSelection = viewModel.getPassword( + requireNotNull(viewModel.selectedPassword.value) + ) + requireNotNull(actualSelection) + + viewModel.primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmWithVisible) + + assertThat(viewModel.isAutoPinConfirm.value).isFalse() + if (isSetAutoPin) { + viewModel.setAutoPinConfirm(true) + advanceUntilIdle() + assertThat(viewModel.isAutoPinConfirm.value).isTrue() + } + + assertThat(viewModel.confirmError.value).isNull() + assertThat(viewModel.isPrimaryButtonEnabled.value).isFalse() + val actualInput = when (actualSelection) { + is GeneratedPassphrase -> actualSelection.passphrase + is GeneratedPin -> actualSelection.pin + else -> error("unreachable") + } + val wrongInput = actualInput + "1" + viewModel.setInputLength(wrongInput.length) + advanceUntilIdle() + assertThat(viewModel.isPrimaryButtonEnabled.value).isTrue() + viewModel.primaryButtonClicked(wrongInput) + advanceUntilIdle() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmWithVisible) + assertThat(viewModel.confirmError.value).isEqualTo(GenerateLockPasswordViewModel.ConfirmError.DOESNT_MATCH) + assertThat(viewModel.isPrimaryButtonEnabled.value).isTrue() + + viewModel.setInputLength(actualInput.length) + advanceUntilIdle() + viewModel.primaryButtonClicked(actualInput) + advanceUntilIdle() + assertThat(viewModel.confirmError.value).isNull() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmWithoutVisible) + assertThat(viewModel.isPrimaryButtonEnabled.value).isFalse() + + viewModel.setInputLength(actualInput.length) + advanceUntilIdle() + assertThat(viewModel.isPrimaryButtonEnabled.value).isTrue() + viewModel.primaryButtonClicked(actualInput) + advanceUntilIdle() + + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmLast) + assertThat(viewModel.saveRequest.value).isEqualTo(GenerateLockPasswordViewModel.SaveRequest.Inactive) + assertThat(viewModel.isPrimaryButtonEnabled.value).isFalse() + + viewModel.setInputLength(actualInput.length) + advanceUntilIdle() + assertThat(viewModel.isPrimaryButtonEnabled.value).isTrue() + viewModel.primaryButtonClicked(actualInput) + advanceUntilIdle() + assertThat(viewModel.stage.value).isEqualTo(PassGenStage.Confirmation.ConfirmLast) + val request = viewModel.saveRequest.value + require(request is GenerateLockPasswordViewModel.SaveRequest.Requested) { + "save not requested" + } + + assertThat(request.credential.credential).isNotNull() + val selection = viewModel.selectedPassword.value + as GenerateLockPasswordViewModel.Selection.ForConfirmation + // copy should have been created + assertThat(selection.credential).isNotSameInstanceAs(request.credential.credential) + assertThat(viewModel.isAutoPinConfirm.value).isEqualTo(request.autoPinConfirm) + if (isSetAutoPin) { + assertThat(request.autoPinConfirm).isTrue() + } else { + assertThat(request.autoPinConfirm).isFalse() + } + + viewModel.cleanUp() + assertCredentialZeroized(selection.credential) + assertCredentialZeroized(request.credential) + assertThat(viewModel.generatedPasswords.value.listOrNull()).isEmpty() + } +} + +private fun assertCredentialZeroized(credential: LockscreenCredential) { + val exception = assertThrows(IllegalStateException::class.java) { credential.credential } + assertThat(exception.message).isEqualTo("Credential is already zeroized") +} + +private fun TestScope.advanceAndAssertToViewOptionsStage( + viewModel: GenerateLockPasswordViewModel +): Unit = with(viewModel) { + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ChooseGeneratedOrManual::class.java) + primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ChooseParams::class.java) + primaryButtonClicked() + advanceUntilIdle() + assertThat(viewModel.stage.value).isInstanceOf(PassGenStage.ShowMultiple::class.java) +} \ No newline at end of file diff --git a/tests/unit/src/com/android/settings/password/generate/PasswordComplexityTest.kt b/tests/unit/src/com/android/settings/password/generate/PasswordComplexityTest.kt new file mode 100644 index 00000000000..42ca78dfddb --- /dev/null +++ b/tests/unit/src/com/android/settings/password/generate/PasswordComplexityTest.kt @@ -0,0 +1,198 @@ +package com.android.settings.password.generate + +import android.app.admin.DevicePolicyManager +import android.app.admin.PasswordMetrics +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.internal.widget.LockPatternUtils +import com.android.internal.widget.LockscreenCredential +import com.android.internal.widget.PasswordValidationError +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PasswordComplexityTest { + + private lateinit var mContext: Context + + private val passwordMetric = PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_PASSWORD) + private val pinMetric = PasswordMetrics(LockPatternUtils.CREDENTIAL_TYPE_PIN) + + @Before + fun setUp() { + mContext = ApplicationProvider.getApplicationContext() + } + + @Test + fun testPasswordOrdering() { + assertThat(PasswordComplexity.MEDIUM).isLessThan(PasswordComplexity.HIGH) + } + + @Test + fun testFromLevel() { + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_NONE, + minLevel = PasswordComplexity.HIGH + ) + ).isEqualTo(PasswordComplexity.HIGH) + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_LOW, + minLevel = PasswordComplexity.HIGH + ) + ).isEqualTo(PasswordComplexity.HIGH) + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM, + minLevel = PasswordComplexity.HIGH + ) + ).isEqualTo(PasswordComplexity.HIGH) + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH, + minLevel = PasswordComplexity.HIGH + ) + ).isEqualTo(PasswordComplexity.HIGH) + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_HIGH, + minLevel = PasswordComplexity.MEDIUM + ) + ).isEqualTo(PasswordComplexity.HIGH) + + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_MEDIUM, + minLevel = PasswordComplexity.MEDIUM + ) + ).isEqualTo(PasswordComplexity.MEDIUM) + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_LOW, + minLevel = PasswordComplexity.MEDIUM + ) + ).isEqualTo(PasswordComplexity.MEDIUM) + assertThat( + PasswordComplexity.fromLevel( + DevicePolicyManager.PASSWORD_COMPLEXITY_NONE, + minLevel = PasswordComplexity.NONE + ) + ).isEqualTo(PasswordComplexity.NONE) + } + + enum class ExpectError { + PASS, TOO_SHORT, CONTAINS_SEQUENCE + } + + private fun validatePassword( + minMetrics: PasswordMetrics, + strength: PasswordComplexity, + password: LockscreenCredential, + expectError: ExpectError, + ) { + PasswordMetrics.validateCredential(minMetrics, strength.complexityValue, password) + .let { errors -> + when (expectError) { + ExpectError.TOO_SHORT -> { + val error = PasswordValidationError( + PasswordValidationError.TOO_SHORT, + if (password.isPin) strength.pinLength else strength.alphaNumericLength + ) + // PasswordValidationError doesn't have equals method and not a data class + assertThat(errors.map { it.toString() }).isEqualTo(listOf(error.toString())) + } + ExpectError.CONTAINS_SEQUENCE -> { + val error = PasswordValidationError( + PasswordValidationError.CONTAINS_SEQUENCE, + 0 + ) + assertThat(errors.map { it.toString() }).isEqualTo(listOf(error.toString())) + } + ExpectError.PASS -> assertThat(errors).isEmpty() + } + } + } + + @Test + fun testPinLengths() { + val tooShort = LockscreenCredential.createPin("163") + assertThat(tooShort.size()).isEqualTo(3) + assertThat(tooShort.size()).isLessThan(PasswordComplexity.MEDIUM.pinLength) + assertThat(tooShort.size()).isLessThan(PasswordComplexity.HIGH.pinLength) + validatePassword(pinMetric, PasswordComplexity.MEDIUM, tooShort, ExpectError.TOO_SHORT) + validatePassword(pinMetric, PasswordComplexity.HIGH, tooShort, ExpectError.TOO_SHORT) + + val short = LockscreenCredential.createPin("1631") + assertThat(short.size()).isEqualTo(4) + assertThat(short.size()).isEqualTo(PasswordComplexity.MEDIUM.pinLength) + assertThat(short.size()).isLessThan(PasswordComplexity.HIGH.pinLength) + validatePassword(pinMetric, PasswordComplexity.MEDIUM, short, ExpectError.PASS) + validatePassword(pinMetric, PasswordComplexity.HIGH, short, ExpectError.TOO_SHORT) + + val almostLong = LockscreenCredential.createPin("1631163") + assertThat(almostLong.size()).isGreaterThan(PasswordComplexity.MEDIUM.pinLength) + assertThat(almostLong.size()).isLessThan(PasswordComplexity.HIGH.pinLength) + validatePassword(pinMetric, PasswordComplexity.MEDIUM, almostLong, ExpectError.PASS) + validatePassword(pinMetric, PasswordComplexity.HIGH, almostLong, ExpectError.TOO_SHORT) + + val long = LockscreenCredential.createPin("16311631") + assertThat(long.size()).isGreaterThan(PasswordComplexity.MEDIUM.pinLength) + assertThat(long.size()).isEqualTo(PasswordComplexity.HIGH.pinLength) + validatePassword(pinMetric, PasswordComplexity.MEDIUM, long, ExpectError.PASS) + validatePassword(pinMetric, PasswordComplexity.HIGH, long, ExpectError.PASS) + } + + @Test + fun testPasswordLengths() { + val tooShort = LockscreenCredential.createPassword("and") + assertThat(tooShort.size()).isEqualTo(3) + assertThat(tooShort.size()).isLessThan(PasswordComplexity.MEDIUM.alphaNumericLength) + assertThat(tooShort.size()).isLessThan(PasswordComplexity.HIGH.alphaNumericLength) + validatePassword(passwordMetric, PasswordComplexity.MEDIUM, tooShort, ExpectError.TOO_SHORT) + validatePassword(passwordMetric, PasswordComplexity.HIGH, tooShort, ExpectError.TOO_SHORT) + + val short = LockscreenCredential.createPassword("andr") + assertThat(short.size()).isEqualTo(4) + assertThat(short.size()).isEqualTo(PasswordComplexity.MEDIUM.alphaNumericLength) + assertThat(short.size()).isLessThan(PasswordComplexity.HIGH.alphaNumericLength) + validatePassword(passwordMetric, PasswordComplexity.MEDIUM, short, ExpectError.PASS) + validatePassword(passwordMetric, PasswordComplexity.HIGH, short, ExpectError.TOO_SHORT) + + val almostLong = LockscreenCredential.createPassword("andro") + assertThat(almostLong.size()).isGreaterThan(PasswordComplexity.MEDIUM.alphaNumericLength) + assertThat(almostLong.size()).isLessThan(PasswordComplexity.HIGH.alphaNumericLength) + validatePassword(passwordMetric, PasswordComplexity.MEDIUM, almostLong, ExpectError.PASS) + validatePassword(passwordMetric, PasswordComplexity.HIGH, almostLong, ExpectError.TOO_SHORT) + + val long = LockscreenCredential.createPassword("androi") + assertThat(long.size()).isGreaterThan(PasswordComplexity.MEDIUM.alphaNumericLength) + assertThat(long.size()).isEqualTo(PasswordComplexity.HIGH.alphaNumericLength) + validatePassword(passwordMetric, PasswordComplexity.MEDIUM, long, ExpectError.PASS) + validatePassword(passwordMetric, PasswordComplexity.HIGH, long, ExpectError.PASS) + + val longer = LockscreenCredential.createPassword("android_") + assertThat(longer.size()).isGreaterThan(PasswordComplexity.MEDIUM.alphaNumericLength) + assertThat(longer.size()).isGreaterThan(PasswordComplexity.HIGH.alphaNumericLength) + validatePassword(passwordMetric, PasswordComplexity.MEDIUM, longer, ExpectError.PASS) + validatePassword(passwordMetric, PasswordComplexity.HIGH, longer, ExpectError.PASS) + } + + @Test + fun testPasswordSequence() { + val hasSequence = LockscreenCredential.createPassword("abcdefg") + validatePassword(passwordMetric, PasswordComplexity.LOW, hasSequence, ExpectError.PASS) + validatePassword(passwordMetric, PasswordComplexity.MEDIUM, hasSequence, ExpectError.CONTAINS_SEQUENCE) + validatePassword(passwordMetric, PasswordComplexity.HIGH, hasSequence, ExpectError.CONTAINS_SEQUENCE) + } + + @Test + fun testLongSequence() { + val cred = LockscreenCredential.createPassword("overstuff") + val metric = PasswordMetrics.computeForCredential(cred) + assertThat(metric.seqLength).isEqualTo(4) + } +} From cf957c92180eaa54e6296c31d22433416e76b911 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Mon, 27 Jan 2025 22:31:57 -0800 Subject: [PATCH 2/2] show locked notification content settings in SetupWizard --- .../password/SetupChooseLockPassword.java | 9 +++++++-- .../generate/ConfirmGeneratedLockPassFragment.kt | 16 +++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/com/android/settings/password/SetupChooseLockPassword.java b/src/com/android/settings/password/SetupChooseLockPassword.java index fe70d298ca1..0c543e5536e 100644 --- a/src/com/android/settings/password/SetupChooseLockPassword.java +++ b/src/com/android/settings/password/SetupChooseLockPassword.java @@ -33,6 +33,7 @@ import com.android.settings.R; import com.android.settings.SetupRedactionInterstitial; +import com.android.settings.notification.RedactionInterstitial; import com.android.settings.password.ChooseLockTypeDialogFragment.OnLockTypeSelectedListener; import com.android.settings.password.generate.GenerateLockPasswordActivity; @@ -146,8 +147,12 @@ protected void onSkipOrClearButtonClick(View view) { protected Intent getRedactionInterstitialIntent(Context context) { // Setup wizard's redaction interstitial is deferred to optional step. Enable that // optional step if the lock screen was set up. - SetupRedactionInterstitial.setEnabled(context, true); - return null; + // SetupRedactionInterstitial.setEnabled(context, true); + // return null; + + // GrapheneOS: Currently just showing lock screen notification settings since + // SetupWizard2 doesn't handle this explictly right now + return RedactionInterstitial.createStartIntent(context, mUserId); } @Override diff --git a/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt b/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt index fce75b02c6b..09bec3663b9 100644 --- a/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt +++ b/src/com/android/settings/password/generate/ConfirmGeneratedLockPassFragment.kt @@ -94,13 +94,19 @@ class ConfirmGeneratedLockPassFragment : BaseLockPasswordGenerationFragment( passwordEntry?.setText("") if (!wasSecureBefore) { - if (WizardManagerHelper.isAnySetupWizard(activity!!.intent)) { + //if (WizardManagerHelper.isAnySetupWizard(activity!!.intent)) { // Setup wizard's redaction interstitial is deferred to optional step. Enable that // optional step if the lock screen was set up. - SetupRedactionInterstitial.setEnabled(context, true) - } else { - startActivity(RedactionInterstitial.createStartIntent(activity, mUserId)) - } + // SetupRedactionInterstitial.setEnabled(context, true) + //} else { + // Always show lock screen notification RedactionInterstitial, even if in SetupWizard. + // Current SetupWizard2 doesn't call the optional component + // + // If we use the Settings app's LOCK_SCREEN_REDACTION intent inside of SetupWizard, can + // uncomment this check (and consider changing the intent in the first fragment to + // launch the SetupWizardChooseLock or changing ChooseLockPassword to check this directly) + startActivity(RedactionInterstitial.createStartIntent(activity, mUserId)) + // } } layout?.announceForAccessibility(getString(R.string.accessibility_setup_password_complete))