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.
upstream-sync
Jonathan Almeida 4 years ago committed by GitHub
parent 85dd2f8313
commit 78715c3020
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

@ -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<BiometricPromptFeature>()
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
}
}

@ -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<BiometricPrompt.PromptInfo>()
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() }
}
}

@ -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())
}
}

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

Loading…
Cancel
Save