diff --git a/app/build.gradle b/app/build.gradle index c027c8454..4e676fdd3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -405,6 +405,7 @@ dependencies { debugImplementation Deps.leakcanary implementation Deps.androidx_legacy + implementation Deps.androidx_biometric implementation Deps.androidx_paging implementation Deps.androidx_preference implementation Deps.androidx_fragment diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa1eac80a..773599dd9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt index be3a2735a..fdf949931 100644 --- a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginSiteInfoFragment.kt @@ -9,6 +9,7 @@ import android.text.InputType import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.fragment_saved_login_site_info.* import org.mozilla.fenix.R @@ -24,6 +25,12 @@ class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_i ).savedLoginItem } + override fun onPause() { + // If we pause this fragment, we want to pop users back to reauth + findNavController().popBackStack(R.id.loginsFragment, false) + super.onPause() + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) diff --git a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt index ade3b4b2f..2ab7c7ab8 100644 --- a/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/logins/SavedLoginsFragment.kt @@ -63,6 +63,12 @@ class SavedLoginsFragment : Fragment() { } } + override fun onPause() { + // If we pause this fragment, we want to pop users back to reauth + findNavController().popBackStack(R.id.loginsFragment, false) + super.onPause() + } + private fun itemClicked(item: SavedLoginsItem) { val directions = SavedLoginsFragmentDirections.actionSavedLoginsFragmentToSavedLoginSiteInfoFragment(item) diff --git a/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt index 5e44aedfd..28a815765 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt @@ -4,11 +4,25 @@ package org.mozilla.fenix.settings +import android.annotation.TargetApi +import android.app.Activity.RESULT_OK +import android.app.KeyguardManager +import android.content.Context.KEYGUARD_SERVICE +import android.content.DialogInterface +import android.content.Intent +import android.os.Build +import android.os.Build.VERSION_CODES.M import android.os.Bundle +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount @@ -17,6 +31,8 @@ import mozilla.components.service.fxa.manager.SyncEnginesStorage import org.mozilla.fenix.R import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.settings +import java.util.concurrent.Executors @Suppress("TooManyFunctions") class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { @@ -31,7 +47,11 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { val savedLoginsKey = getPreferenceKey(R.string.pref_key_saved_logins) findPreference(savedLoginsKey)?.setOnPreferenceClickListener { - navigateToLoginsSettingsFragment() + if (Build.VERSION.SDK_INT >= M && isHardwareAvailable && hasBiometricEnrolled) { + showBiometricPrompt() + } else { + verifyPinOrShowSetupWarning() + } true } @@ -54,6 +74,31 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { override fun onAuthenticationProblems() = updateSyncPreferenceNeedsReauth() + val isHardwareAvailable: Boolean by lazy { + if (Build.VERSION.SDK_INT >= M) { + context?.let { + val bm = BiometricManager.from(it) + val canAuthenticate = bm.canAuthenticate() + !(canAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE || + canAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE) + } ?: false + } else { + false + } + } + + val hasBiometricEnrolled: Boolean by lazy { + if (Build.VERSION.SDK_INT >= M) { + context?.let { + val bm = BiometricManager.from(it) + val canAuthenticate = bm.canAuthenticate() + (canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) + } ?: false + } else { + false + } + } + private fun updateSyncPreferenceStatus() { val syncLogins = getPreferenceKey(R.string.pref_key_password_sync_logins) findPreference(syncLogins)?.apply { @@ -92,7 +137,93 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { } } - private fun navigateToLoginsSettingsFragment() { + @TargetApi(M) + private fun showBiometricPrompt() { + val biometricPromptCallback = object : BiometricPrompt.AuthenticationCallback() { + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + // Authentication Error + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + lifecycleScope.launch(Main) { + navigateToSavedLoginsFragment() + } + } + + override fun onAuthenticationFailed() { + // Authenticated Failed + } + } + + val executor = Executors.newSingleThreadExecutor() + + val biometricPrompt = BiometricPrompt(this, executor, biometricPromptCallback) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.logins_biometric_prompt_message)) + .setDeviceCredentialAllowed(true) + .build() + + biometricPrompt.authenticate(promptInfo) + } + + private fun verifyPinOrShowSetupWarning() { + val manager = activity?.getSystemService(KEYGUARD_SERVICE) as KeyguardManager + if (manager.isKeyguardSecure) { + showPinVerification() + } else { + if (context?.settings()?.shouldShowSecurityPinWarning == true) { + showPinDialogWarning() + } else { + navigateToSavedLoginsFragment() + } + } + } + + private fun showPinDialogWarning() { + context?.let { + AlertDialog.Builder(it).apply { + setTitle(getString(R.string.logins_warning_dialog_title)) + setMessage( + getString(R.string.logins_warning_dialog_message) + ) + + setNegativeButton(getString(R.string.logins_warning_dialog_later)) { _: DialogInterface, _ -> + navigateToSavedLoginsFragment() + } + + setPositiveButton(getString(R.string.logins_warning_dialog_set_up_now)) { it: DialogInterface, _ -> + it.dismiss() + val intent = Intent( + android.provider.Settings.ACTION_SECURITY_SETTINGS + ) + startActivity(intent) + } + create() + }.show() + it.settings().incrementShowLoginsSecureWarningCount() + } + } + + private fun showPinVerification() { + val manager = activity?.getSystemService(KEYGUARD_SERVICE) as KeyguardManager + val intent = manager.createConfirmDeviceCredentialIntent( + getString(R.string.logins_biometric_prompt_message_pin), + getString(R.string.logins_biometric_prompt_message) + ) + startActivityForResult(intent, PIN_REQUEST) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == PIN_REQUEST && resultCode == RESULT_OK) { + navigateToSavedLoginsFragment() + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + private fun navigateToSavedLoginsFragment() { val directions = LoginsFragmentDirections.actionLoginsFragmentToSavedLoginsFragment() findNavController().navigate(directions) } @@ -111,4 +242,8 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { val directions = LoginsFragmentDirections.actionLoginsFragmentToTurnOnSyncFragment() findNavController().navigate(directions) } + + companion object { + const val PIN_REQUEST = 303 + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt index 41cae992e..981278b3a 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/account/AccountSettingsFragment.kt @@ -4,10 +4,16 @@ package org.mozilla.fenix.settings.account +import android.app.KeyguardManager +import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.os.Bundle +import android.provider.Settings import android.text.InputFilter import android.text.format.DateUtils import android.view.View +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController @@ -36,6 +42,7 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.settings @SuppressWarnings("TooManyFunctions", "LargeClass") class AccountSettingsFragment : PreferenceFragmentCompat() { @@ -180,14 +187,27 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { val loginsNameKey = getPreferenceKey(R.string.pref_key_sync_logins) findPreference(loginsNameKey)?.apply { setOnPreferenceChangeListener { _, newValue -> - SyncEnginesStorage(context).setStatus(SyncEngine.Passwords, newValue as Boolean) - @Suppress("DeferredResultUnused") - context.components.backgroundServices.accountManager.syncNowAsync(SyncReason.EngineChange) + val manager = + activity?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (manager.isKeyguardSecure || + newValue == false || + !context.settings().shouldShowSecurityPinWarningSync + ) { + SyncEnginesStorage(context).setStatus(SyncEngine.Passwords, newValue as Boolean) + @Suppress("DeferredResultUnused") + context.components.backgroundServices.accountManager.syncNowAsync(SyncReason.EngineChange) + } else { + showPinDialogWarning(newValue as Boolean) + } true } } - deviceConstellation?.registerDeviceObserver(deviceConstellationObserver, owner = this, autoPause = true) + deviceConstellation?.registerDeviceObserver( + deviceConstellationObserver, + owner = this, + autoPause = true + ) // NB: ObserverRegistry will take care of cleaning up internal references to 'observer' and // 'owner' when appropriate. @@ -196,6 +216,33 @@ class AccountSettingsFragment : PreferenceFragmentCompat() { ) } + private fun showPinDialogWarning(newValue: Boolean) { + context?.let { + AlertDialog.Builder(it).apply { + setTitle(getString(R.string.logins_warning_dialog_title)) + setMessage( + getString(R.string.logins_warning_dialog_message) + ) + + setNegativeButton(getString(R.string.logins_warning_dialog_later)) { _: DialogInterface, _ -> + SyncEnginesStorage(context).setStatus(SyncEngine.Passwords, newValue) + @Suppress("DeferredResultUnused") + context.components.backgroundServices.accountManager.syncNowAsync(SyncReason.EngineChange) + } + + setPositiveButton(getString(R.string.logins_warning_dialog_set_up_now)) { it: DialogInterface, _ -> + it.dismiss() + val intent = Intent( + Settings.ACTION_SECURITY_SETTINGS + ) + startActivity(intent) + } + create() + }.show() + it.settings().incrementShowLoginsSecureWarningSyncCount() + } + } + private fun updateSyncEngineStates() { val syncEnginesStatus = SyncEnginesStorage(context!!).getStatus() val bookmarksNameKey = getPreferenceKey(R.string.pref_key_sync_bookmarks) diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index 38a792693..414fdfb66 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -37,6 +37,8 @@ class Settings private constructor( private val isCrashReportEnabledInBuild: Boolean ) : PreferencesHolder { companion object { + const val showLoginsSecureWarningSyncMaxCount = 1 + const val showLoginsSecureWarningMaxCount = 1 const val autoBounceMaximumCount = 2 const val trackingProtectionOnboardingMaximumCount = 2 const val FENIX_PREFERENCES = "fenix_preferences" @@ -133,6 +135,12 @@ class Settings private constructor( val shouldAutoBounceQuickActionSheet: Boolean get() = autoBounceQuickActionSheetCount < autoBounceMaximumCount + val shouldShowSecurityPinWarningSync: Boolean + get() = loginsSecureWarningSyncCount < showLoginsSecureWarningSyncMaxCount + + val shouldShowSecurityPinWarning: Boolean + get() = loginsSecureWarningCount < showLoginsSecureWarningMaxCount + var shouldUseLightTheme by booleanPreference( appContext.getPreferenceKey(R.string.pref_key_light_theme), default = false @@ -249,6 +257,32 @@ class Settings private constructor( default = 0 ) + @VisibleForTesting(otherwise = PRIVATE) + internal val loginsSecureWarningSyncCount by intPreference( + appContext.getPreferenceKey(R.string.pref_key_logins_secure_warning_sync), + default = 0 + ) + + @VisibleForTesting(otherwise = PRIVATE) + internal val loginsSecureWarningCount by intPreference( + appContext.getPreferenceKey(R.string.pref_key_logins_secure_warning), + default = 0 + ) + + fun incrementShowLoginsSecureWarningCount() { + preferences.edit().putInt( + appContext.getPreferenceKey(R.string.pref_key_logins_secure_warning), + loginsSecureWarningCount + 1 + ).apply() + } + + fun incrementShowLoginsSecureWarningSyncCount() { + preferences.edit().putInt( + appContext.getPreferenceKey(R.string.pref_key_logins_secure_warning_sync), + loginsSecureWarningSyncCount + 1 + ).apply() + } + fun incrementAutomaticBounceQuickActionSheetCount() { preferences.edit().putInt( appContext.getPreferenceKey(R.string.pref_key_bounce_quick_action), diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 9a49b11bd..6fa4c535a 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -104,6 +104,8 @@ pref_key_saved_logins pref_key_password_sync_logins + pref_key_logins_secure_warning_sync + pref_key_logins_secure_warning pref_key_open_links_in_a_private_tab diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1707f1b0e..da9274c4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1009,4 +1009,16 @@ Show password Hide password + + Unlock to view your saved logins + + Secure your logins and passwords + + Set up a device lock pattern, PIN, or password to protect your saved logins and passwords from being accessed if someone else has your device. + + Later + + Set up now + + Unlock your device diff --git a/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt b/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt index fa76c9fd2..bf6131458 100644 --- a/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt +++ b/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt @@ -173,6 +173,58 @@ class SettingsTest { assertFalse(settings.shouldAutoBounceQuickActionSheet) } + @Test + fun showLoginsDialogWarningSync() { + // When just created + // Then + assertEquals(0, settings.loginsSecureWarningSyncCount) + + // When + settings.incrementShowLoginsSecureWarningSyncCount() + + // Then + assertEquals(1, settings.loginsSecureWarningSyncCount) + } + + @Test + fun shouldShowLoginsDialogWarningSync() { + // When just created + // Then + assertTrue(settings.shouldShowSecurityPinWarningSync) + + // When + settings.incrementShowLoginsSecureWarningSyncCount() + + // Then + assertFalse(settings.shouldShowSecurityPinWarningSync) + } + + @Test + fun showLoginsDialogWarning() { + // When just created + // Then + assertEquals(0, settings.loginsSecureWarningCount) + + // When + settings.incrementShowLoginsSecureWarningCount() + + // Then + assertEquals(1, settings.loginsSecureWarningCount) + } + + @Test + fun shouldShowLoginsDialogWarning() { + // When just created + // Then + assertTrue(settings.shouldShowSecurityPinWarning) + + // When + settings.incrementShowLoginsSecureWarningCount() + + // Then + assertFalse(settings.shouldShowSecurityPinWarning) + } + @Test fun shouldUseLightTheme() { // When just created @@ -298,7 +350,10 @@ class SettingsTest { fun sitePermissionsPhoneFeatureCameraAction() { // When just created // Then - assertEquals(ASK_TO_ALLOW, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA)) + assertEquals( + ASK_TO_ALLOW, + settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA) + ) // When settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.CAMERA, BLOCKED) @@ -311,33 +366,48 @@ class SettingsTest { fun sitePermissionsPhoneFeatureMicrophoneAction() { // When just created // Then - assertEquals(ASK_TO_ALLOW, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE)) + assertEquals( + ASK_TO_ALLOW, + settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE) + ) // When settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE, BLOCKED) // Then - assertEquals(BLOCKED, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE)) + assertEquals( + BLOCKED, + settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.MICROPHONE) + ) } @Test fun sitePermissionsPhoneFeatureNotificationAction() { // When just created // Then - assertEquals(ASK_TO_ALLOW, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION)) + assertEquals( + ASK_TO_ALLOW, + settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION) + ) // When settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION, BLOCKED) // Then - assertEquals(BLOCKED, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION)) + assertEquals( + BLOCKED, + settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.NOTIFICATION) + ) } @Test fun sitePermissionsPhoneFeatureLocation() { // When just created // Then - assertEquals(ASK_TO_ALLOW, settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION)) + assertEquals( + ASK_TO_ALLOW, + settings.getSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION) + ) // When settings.setSitePermissionsPhoneFeatureAction(PhoneFeature.LOCATION, BLOCKED) diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 9d12dcefd..62ab9f560 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -18,6 +18,7 @@ object Versions { const val osslicenses_library = "17.0.0" const val androidx_appcompat = "1.1.0" + const val androidx_biometric = "1.0.0-rc02" const val androidx_coordinator_layout = "1.1.0-beta01" const val androidx_constraint_layout = "2.0.0-beta2" const val androidx_preference = "1.1.0" @@ -166,6 +167,7 @@ object Deps { const val leanplum = "com.leanplum:leanplum-core:${Versions.leanplum}" const val androidx_annotation = "androidx.annotation:annotation:${Versions.androidx_annotation}" + const val androidx_biometric = "androidx.biometric:biometric:${Versions.androidx_biometric}" const val androidx_fragment = "androidx.fragment:fragment-ktx:${Versions.androidx_fragment}" const val androidx_appcompat = "androidx.appcompat:appcompat:${Versions.androidx_appcompat}" const val androidx_coordinatorlayout = "androidx.coordinatorlayout:coordinatorlayout:${Versions.androidx_coordinator_layout}"