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}"