[fenix] For https://github.com/mozilla-mobile/fenix/issues/13477 - Move BiometricPrompt to a separate feature (https://github.com/mozilla-mobile/fenix/pull/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.pull/600/head
parent
d68aee5549
commit
8fd55d87f0
@ -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
|
||||||
|
}
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue