From 78715c302060a52771f1ec28e3848c5b0532c3ab Mon Sep 17 00:00:00 2001 From: Jonathan Almeida Date: Sat, 14 Nov 2020 12:20:05 -0500 Subject: [PATCH] For #13477 - Move BiometricPrompt to a separate feature (#16498) Instead of simply fixing the memory leak for this issue by directly removing references, it makes more sense to move the whole BiometricPrompt out of the fragment and into it's own feature to be re-usable. --- .../biometric/BiometricPromptFeature.kt | 96 +++++++++++++ .../logins/biometric/ext/BiometricManager.kt | 27 ++++ .../fragment/SavedLoginsAuthFragment.kt | 80 +++-------- .../biometric/BiometricPromptFeatureTest.kt | 126 ++++++++++++++++++ .../biometric/ext/BiometricManagerKtTest.kt | 55 ++++++++ buildSrc/src/main/java/Dependencies.kt | 5 +- 6 files changed, 329 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeature.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManager.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/logins/biometric/BiometricPromptFeatureTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/logins/biometric/ext/BiometricManagerKtTest.kt 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}"