diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeature.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeature.kt new file mode 100644 index 000000000..05ef9e3ae --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeature.kt @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.biometric + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.M +import androidx.annotation.VisibleForTesting +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.fenix.settings.logins.biometric.ext.isEnrolled +import org.mozilla.fenix.settings.logins.biometric.ext.isHardwareAvailable + +/** + * A [LifecycleAwareFeature] for the Android Biometric API to prompt for user authentication. + * + * @param context Android context. + * @param fragment The fragment on which this feature will live. + * @param onAuthSuccess A success callback. + * @param onAuthFailure A failure callback if authentication failed. + */ +class BiometricPromptFeature( + private val context: Context, + private val fragment: Fragment, + private val onAuthFailure: () -> Unit, + private val onAuthSuccess: () -> Unit +) : LifecycleAwareFeature { + private val logger = Logger(javaClass.simpleName) + + @VisibleForTesting + internal var biometricPrompt: BiometricPrompt? = null + + override fun start() { + val executor = ContextCompat.getMainExecutor(context) + biometricPrompt = BiometricPrompt(fragment, executor, PromptCallback()) + } + + override fun stop() { + biometricPrompt = null + } + + /** + * Requests the user for biometric authentication. + * + * @param title Adds a title for the authentication prompt. + */ + fun requestAuthentication(title: String) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + .setTitle(title) + .build() + + biometricPrompt?.authenticate(promptInfo) + } + + internal inner class PromptCallback : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + logger.error("onAuthenticationError $errString") + onAuthFailure.invoke() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + logger.debug("onAuthenticationSucceeded") + onAuthSuccess.invoke() + } + + override fun onAuthenticationFailed() { + logger.error("onAuthenticationFailed") + onAuthFailure.invoke() + } + } + + companion object { + + /** + * Checks if the appropriate SDK version and hardware capabilities are met to use the feature. + */ + fun canUseFeature(context: Context): Boolean { + return if (SDK_INT >= M) { + val manager = BiometricManager.from(context) + + manager.isHardwareAvailable() && manager.isEnrolled() + } else { + false + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManager.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManager.kt new file mode 100644 index 000000000..323925d7d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManager.kt @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.biometric.ext + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS + +/** + * Checks if the hardware requirements are met for using the [BiometricManager]. + */ +fun BiometricManager.isHardwareAvailable(): Boolean { + val status = canAuthenticate(BIOMETRIC_WEAK) + return status != BIOMETRIC_ERROR_NO_HARDWARE && status != BIOMETRIC_ERROR_HW_UNAVAILABLE +} + +/** + * Checks if the user can use the [BiometricManager] and is therefore enrolled. + */ +fun BiometricManager.isEnrolled(): Boolean { + val status = canAuthenticate(BIOMETRIC_WEAK) + return status == BIOMETRIC_SUCCESS +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt index 9a2d70b6b..b20200fbf 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt @@ -9,17 +9,10 @@ import android.app.KeyguardManager import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.M import android.os.Bundle import android.provider.Settings.ACTION_SECURITY_SETTINGS -import android.util.Log +import android.view.View import androidx.appcompat.app.AlertDialog -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK -import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController @@ -29,6 +22,7 @@ import androidx.preference.SwitchPreference import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.R import org.mozilla.fenix.addons.runIfFragmentIsAttached import org.mozilla.fenix.components.metrics.Event @@ -38,32 +32,14 @@ import org.mozilla.fenix.ext.secure import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.SharedPreferenceUpdater +import org.mozilla.fenix.settings.logins.biometric.BiometricPromptFeature import org.mozilla.fenix.settings.logins.SyncLoginsPreferenceView import org.mozilla.fenix.settings.requirePreference @Suppress("TooManyFunctions") class SavedLoginsAuthFragment : PreferenceFragmentCompat() { - private lateinit var biometricPrompt: BiometricPrompt - private lateinit var promptInfo: BiometricPrompt.PromptInfo - - private val biometricPromptCallback = object : BiometricPrompt.AuthenticationCallback() { - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - Log.e(LOG_TAG, "onAuthenticationError $errString") - togglePrefsEnabledWhileAuthenticating(enabled = true) - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - Log.d(LOG_TAG, "onAuthenticationSucceeded") - navigateToSavedLogins() - } - - override fun onAuthenticationFailed() { - Log.e(LOG_TAG, "onAuthenticationFailed") - togglePrefsEnabledWhileAuthenticating(enabled = true) - } - } + private val biometricPromptFeature = ViewBoundFeatureWrapper() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.logins_preferences, rootKey) @@ -91,17 +67,19 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat() { } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val executor = ContextCompat.getMainExecutor(requireContext()) - - biometricPrompt = BiometricPrompt(this, executor, biometricPromptCallback) - - promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.logins_biometric_prompt_message)) - .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) - .build() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + biometricPromptFeature.set( + feature = BiometricPromptFeature( + context = requireContext(), + fragment = this, + onAuthFailure = { togglePrefsEnabledWhileAuthenticating(true) }, + onAuthSuccess = ::navigateToSavedLogins + ), + owner = this, + view = view + ) } override fun onResume() { @@ -150,28 +128,15 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat() { navController = findNavController() ) - togglePrefsEnabledWhileAuthenticating(enabled = true) - } - - private fun canUseBiometricPrompt(context: Context): Boolean { - return if (SDK_INT >= M) { - val manager = BiometricManager.from(context) - val canAuthenticate = manager.canAuthenticate(BIOMETRIC_WEAK) - - val hardwareUnavailable = canAuthenticate == BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE || - canAuthenticate == BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE - val biometricsEnrolled = canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS - - !hardwareUnavailable && biometricsEnrolled - } else { - false - } + togglePrefsEnabledWhileAuthenticating(true) } private fun verifyCredentialsOrShowSetupWarning(context: Context) { // Use the BiometricPrompt first - if (canUseBiometricPrompt(context)) { - biometricPrompt.authenticate(promptInfo) + if (BiometricPromptFeature.canUseFeature(context)) { + togglePrefsEnabledWhileAuthenticating(false) + biometricPromptFeature.get() + ?.requestAuthentication(getString(R.string.logins_biometric_prompt_message)) return } @@ -249,7 +214,6 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat() { companion object { const val SHORT_DELAY_MS = 100L - private const val LOG_TAG = "LoginsFragment" const val PIN_REQUEST = 303 } } diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeatureTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeatureTest.kt new file mode 100644 index 000000000..c91a99441 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeatureTest.kt @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.biometric + +import android.os.Build.VERSION_CODES.M +import android.os.Build.VERSION_CODES.N +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import mozilla.components.support.test.robolectric.createAddedTestFragment +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.settings.logins.biometric.ext.isEnrolled +import org.mozilla.fenix.settings.logins.biometric.ext.isHardwareAvailable +import org.robolectric.annotation.Config + +@RunWith(FenixRobolectricTestRunner::class) +class BiometricPromptFeatureTest { + + lateinit var fragment: Fragment + + @Before + fun setup() { + fragment = createAddedTestFragment { Fragment() } + } + + @Config(sdk = [N]) + @Test + fun `canUseFeature checks for SDK compatible`() { + assertFalse(BiometricPromptFeature.canUseFeature(testContext)) + } + + @Config(sdk = [M]) + @Test + fun `canUseFeature checks for hardware capabilities`() { + mockkStatic(BiometricManager::class) + val manager: BiometricManager = mockk() + every { BiometricManager.from(any()) } returns manager + every { manager.canAuthenticate(any()) } returns BIOMETRIC_SUCCESS + + assertTrue(BiometricPromptFeature.canUseFeature(testContext)) + + every { manager.canAuthenticate(any()) } returns BIOMETRIC_ERROR_HW_UNAVAILABLE + + assertFalse(BiometricPromptFeature.canUseFeature(testContext)) + + verify { manager.isEnrolled() } + verify { manager.isHardwareAvailable() } + + // cleanup + unmockkStatic(BiometricManager::class) + } + + @Test + fun `prompt is created and destroyed on start and stop`() { + val feature = BiometricPromptFeature(testContext, fragment, {}, {}) + + assertNull(feature.biometricPrompt) + + feature.start() + + assertNotNull(feature.biometricPrompt) + + feature.stop() + + assertNull(feature.biometricPrompt) + } + + @Test + fun `requestAuthentication invokes biometric prompt`() { + val feature = BiometricPromptFeature(testContext, fragment, {}, {}) + val prompt: BiometricPrompt = mockk(relaxed = true) + val promptInfo = slot() + + feature.biometricPrompt = prompt + + feature.requestAuthentication("test") + + verify { prompt.authenticate(capture(promptInfo)) } + assertEquals(BIOMETRIC_WEAK or DEVICE_CREDENTIAL, promptInfo.captured.allowedAuthenticators) + assertEquals("test", promptInfo.captured.title) + } + + @Test + fun `promptCallback fires feature callbacks`() { + val authSuccess: () -> Unit = mockk(relaxed = true) + val authFailure: () -> Unit = mockk(relaxed = true) + val feature = BiometricPromptFeature(testContext, fragment, authFailure, authSuccess) + val callback = feature.PromptCallback() + val prompt = BiometricPrompt(fragment, callback) + + feature.biometricPrompt = prompt + + callback.onAuthenticationError(0, "") + + verify { authFailure.invoke() } + + callback.onAuthenticationFailed() + + verify { authFailure.invoke() } + + callback.onAuthenticationSucceeded(mockk()) + + verify { authSuccess.invoke() } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManagerKtTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManagerKtTest.kt new file mode 100644 index 000000000..26b77a692 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManagerKtTest.kt @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.biometric.ext + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE +import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner + +@RunWith(FenixRobolectricTestRunner::class) +class BiometricManagerKtTest { + + lateinit var manager: BiometricManager + + @Before + fun setup() { + manager = mockk() + } + + @Test + fun `isHardwareAvailable checks status`() { + every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_ERROR_NO_HARDWARE } + + assertFalse(manager.isHardwareAvailable()) + + every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_ERROR_HW_UNAVAILABLE } + + assertFalse(manager.isHardwareAvailable()) + + every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_SUCCESS } + + assertTrue(manager.isHardwareAvailable()) + } + + @Test + fun `isEnrolled checks status`() { + every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_ERROR_NO_HARDWARE } + + assertFalse(manager.isEnrolled()) + + every { manager.canAuthenticate(any()) }.answers { BIOMETRIC_SUCCESS } + + assertTrue(manager.isEnrolled()) + } +} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 9d95c673e..52d3dcfa6 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -19,7 +19,7 @@ object Versions { const val jna = "5.6.0" const val androidx_appcompat = "1.2.0" - const val androidx_biometric = "1.1.0-beta01" + const val androidx_biometric = "1.1.0-rc01" const val androidx_coordinator_layout = "1.1.0" const val androidx_constraint_layout = "2.0.4" const val androidx_preference = "1.1.1" @@ -45,6 +45,7 @@ object Versions { const val mockwebserver = "4.9.0" const val uiautomator = "2.2.0" + const val robolectric = "4.3.1" const val google_ads_id_version = "16.0.0" @@ -217,7 +218,7 @@ object Deps { const val mockwebserver = "com.squareup.okhttp3:mockwebserver:${Versions.mockwebserver}" const val uiautomator = "androidx.test.uiautomator:uiautomator:${Versions.uiautomator}" - const val robolectric = "org.robolectric:robolectric:4.3.1" + const val robolectric = "org.robolectric:robolectric:${Versions.robolectric}" const val google_ads_id = "com.google.android.gms:play-services-ads-identifier:${Versions.google_ads_id_version}"