From a1404bf7c0c91d429fcd742e3687cebebcf73f64 Mon Sep 17 00:00:00 2001 From: MickeyMoz Date: Fri, 6 May 2022 12:23:00 +0300 Subject: [PATCH] Biometric prompt feature --- .buildconfig.yml | 4 + buildSrc/src/main/java/Dependencies.kt | 2 + .../ui/AbstractAutofillUnlockActivity.kt | 1 - .../feature/biometric-prompt/.gitignore | 1 + .../feature/biometric-prompt/build.gradle | 39 +++++ .../biometric-prompt/consumer-rules.pro | 0 .../biometric-prompt/proguard-rules.pro | 21 +++ .../src/main/AndroidManifest.xml | 4 + .../biometric/AuthenticationCallbacks.kt | 14 ++ .../biometric/BiometricPromptFeature.kt | 114 ++++++++++++++ .../biometric/BiometricPromptFeatureTest.kt | 139 ++++++++++++++++++ taskcluster/ci/config.yml | 1 + 12 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 components/feature/biometric-prompt/.gitignore create mode 100644 components/feature/biometric-prompt/build.gradle create mode 100644 components/feature/biometric-prompt/consumer-rules.pro create mode 100644 components/feature/biometric-prompt/proguard-rules.pro create mode 100644 components/feature/biometric-prompt/src/main/AndroidManifest.xml create mode 100644 components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/AuthenticationCallbacks.kt create mode 100644 components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/BiometricPromptFeature.kt create mode 100644 components/feature/biometric-prompt/src/test/java/mozilla/components/feature/biometric/BiometricPromptFeatureTest.kt diff --git a/.buildconfig.yml b/.buildconfig.yml index 26c732d2c1f..52a97a3d876 100644 --- a/.buildconfig.yml +++ b/.buildconfig.yml @@ -23,6 +23,10 @@ projects: path: components/concept/awesomebar description: 'An abstract definition of an awesomebar component.' publish: true + feature-biometric-prompt: + path: components/feature/biometric-prompt + description: 'Component for authentication using biometric' + publish: true concept-base: path: components/concept/base description: 'A component for basic interfaces needed by multiple components and that do not warrant a standalone component.' diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index b85282dcc3e..2acbdd850aa 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -14,6 +14,7 @@ object Versions { const val robolectric = "4.7.3" const val mockito = "3.11.2" const val maven_ant_tasks = "2.1.3" + const val mockito_inline = "4.6.0" const val mockwebserver = "3.10.0" @@ -85,6 +86,7 @@ object Dependencies { const val testing_robolectric = "org.robolectric:robolectric:${Versions.robolectric}" const val testing_robolectric_playservices = "org.robolectric:shadows-playservices:${Versions.robolectric}" const val testing_mockito = "org.mockito:mockito-core:${Versions.mockito}" + const val testing_mockito_inline = "org.mockito:mockito-inline:${Versions.mockito_inline}" const val testing_mockwebserver = "com.squareup.okhttp3:mockwebserver:${Versions.mockwebserver}" const val testing_coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutines}" const val testing_maven_ant_tasks = "org.apache.maven:maven-ant-tasks:${Versions.maven_ant_tasks}" diff --git a/components/feature/autofill/src/main/java/mozilla/components/feature/autofill/ui/AbstractAutofillUnlockActivity.kt b/components/feature/autofill/src/main/java/mozilla/components/feature/autofill/ui/AbstractAutofillUnlockActivity.kt index 6931cd1ea63..515b6d5c04c 100644 --- a/components/feature/autofill/src/main/java/mozilla/components/feature/autofill/ui/AbstractAutofillUnlockActivity.kt +++ b/components/feature/autofill/src/main/java/mozilla/components/feature/autofill/ui/AbstractAutofillUnlockActivity.kt @@ -39,7 +39,6 @@ abstract class AbstractAutofillUnlockActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val parsedStructure = intent.getParcelableExtra(EXTRA_PARSED_STRUCTURE) val imeSpec = intent.getImeSpec() val maxSuggestionCount = intent.getIntExtra(EXTRA_MAX_SUGGESTION_COUNT, MAX_LOGINS) diff --git a/components/feature/biometric-prompt/.gitignore b/components/feature/biometric-prompt/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/components/feature/biometric-prompt/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/components/feature/biometric-prompt/build.gradle b/components/feature/biometric-prompt/build.gradle new file mode 100644 index 00000000000..5ad3e90b4d6 --- /dev/null +++ b/components/feature/biometric-prompt/build.gradle @@ -0,0 +1,39 @@ +/* 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/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion config.compileSdkVersion + + defaultConfig { + minSdkVersion config.minSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + + implementation 'androidx.core:core-ktx:1.8.0' + implementation project(':support-base') + implementation Dependencies.androidx_biometric + + testImplementation project(':support-test') + + testImplementation Dependencies.androidx_test_core + testImplementation Dependencies.androidx_test_junit + testImplementation Dependencies.testing_robolectric + testImplementation Dependencies.testing_mockito_inline + +} +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/components/feature/biometric-prompt/consumer-rules.pro b/components/feature/biometric-prompt/consumer-rules.pro new file mode 100644 index 00000000000..e69de29bb2d diff --git a/components/feature/biometric-prompt/proguard-rules.pro b/components/feature/biometric-prompt/proguard-rules.pro new file mode 100644 index 00000000000..481bb434814 --- /dev/null +++ b/components/feature/biometric-prompt/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/components/feature/biometric-prompt/src/main/AndroidManifest.xml b/components/feature/biometric-prompt/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..8baf6b5450e --- /dev/null +++ b/components/feature/biometric-prompt/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/AuthenticationCallbacks.kt b/components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/AuthenticationCallbacks.kt new file mode 100644 index 00000000000..cf7d79e4542 --- /dev/null +++ b/components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/AuthenticationCallbacks.kt @@ -0,0 +1,14 @@ +/* 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 mozilla.components.feature.biometric + +/** + * Callbacks for BiometricPrompt Authentication + */ +interface AuthenticationCallbacks { + val onAuthFailure: () -> Unit + val onAuthSuccess: () -> Unit + val onAuthError: (errorText: String) -> Unit +} diff --git a/components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/BiometricPromptFeature.kt b/components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/BiometricPromptFeature.kt new file mode 100644 index 00000000000..c9f78440fc2 --- /dev/null +++ b/components/feature/biometric-prompt/src/main/java/mozilla/components/feature/biometric/BiometricPromptFeature.kt @@ -0,0 +1,114 @@ +/* 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 mozilla.components.feature.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 + +/** + * 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 authenticationCallbacks Callbacks for BiometricPrompt. + */ + +class BiometricPromptFeature( + private val context: Context, + private val fragment: Fragment, + private val authenticationCallbacks: AuthenticationCallbacks +) : 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. + * @param subtitle Adds a subtitle for the authentication prompt. + */ + fun requestAuthentication( + title: String, + subtitle: String = "" + ) { + val promptInfo: BiometricPrompt.PromptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + .setTitle(title) + .setSubtitle(subtitle) + .build() + biometricPrompt?.authenticate(promptInfo) + } + + internal inner class PromptCallback : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + logger.error("onAuthenticationError: errorMessage $errString errorCode=$errorCode") + authenticationCallbacks.onAuthError.invoke(errString.toString()) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + logger.debug("onAuthenticationSucceeded") + authenticationCallbacks.onAuthSuccess.invoke() + } + + override fun onAuthenticationFailed() { + logger.error("onAuthenticationFailed") + authenticationCallbacks.onAuthFailure.invoke() + } + } + + internal fun getAndroidBiometricManager(context: Context): BiometricManager { + return BiometricManager.from(context) + } + + /** + * 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 = getAndroidBiometricManager(context) + isHardwareAvailable(manager) && isEnrolled(manager) + } else { + false + } + } + + /** + * Checks if the hardware requirements are met for using the [BiometricManager]. + */ + fun isHardwareAvailable(biometricManager: BiometricManager): Boolean { + val status = biometricManager.canAuthenticate(BIOMETRIC_WEAK) + return status != BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE && + status != BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE + } + + /** + * Checks if the user can use the [BiometricManager] and is therefore enrolled. + */ + fun isEnrolled(biometricManager: BiometricManager): Boolean { + val status = biometricManager.canAuthenticate(BIOMETRIC_WEAK) + return status == BiometricManager.BIOMETRIC_SUCCESS + } +} diff --git a/components/feature/biometric-prompt/src/test/java/mozilla/components/feature/biometric/BiometricPromptFeatureTest.kt b/components/feature/biometric-prompt/src/test/java/mozilla/components/feature/biometric/BiometricPromptFeatureTest.kt new file mode 100644 index 00000000000..5e1d9fcbfb7 --- /dev/null +++ b/components/feature/biometric-prompt/src/test/java/mozilla/components/feature/biometric/BiometricPromptFeatureTest.kt @@ -0,0 +1,139 @@ +/* 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 mozilla.components.feature.biometric + +import android.os.Build.VERSION_CODES.LOLLIPOP +import android.os.Build.VERSION_CODES.M +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.createAddedTestFragment +import mozilla.components.support.test.robolectric.testContext +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.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class BiometricPromptFeatureTest { + + private lateinit var biometricPromptFeature: BiometricPromptFeature + private lateinit var biometricManager: BiometricManager + private lateinit var fragment: Fragment + + @Before + fun setup() { + fragment = createAddedTestFragment { Fragment() } + biometricPromptFeature = spy( + BiometricPromptFeature( + testContext, + fragment, + object : AuthenticationCallbacks { + override val onAuthFailure: () -> Unit + get() = {} + override val onAuthSuccess: () -> Unit + get() = { } + override val onAuthError: (errorText: String) -> Unit + get() = {} + } + ) + ) + biometricManager = mock() + + doReturn(biometricManager).`when`(biometricPromptFeature) + .getAndroidBiometricManager(testContext) + } + + @Config(sdk = [LOLLIPOP]) + @Test + fun `canUseFeature checks for SDK compatible`() { + assertFalse(biometricPromptFeature.canUseFeature(testContext)) + } + + @Config(sdk = [M]) + @Test + fun `GIVEN canUseFeature is called WHEN hardware is available and biometric is enrolled THEN canUseFeature return true`() { + doReturn(true).`when`(biometricPromptFeature).isHardwareAvailable(biometricManager) + doReturn(true).`when`(biometricPromptFeature).isEnrolled(biometricManager) + assertTrue(biometricPromptFeature.canUseFeature(testContext)) + } + + @Config(sdk = [M]) + @Test + fun `GIVEN canUseFeature is called WHEN hardware is available and biometric is not enrolled THEN canUseFeature return false`() { + doReturn(false).`when`(biometricPromptFeature).isHardwareAvailable(biometricManager) + doReturn(true).`when`(biometricPromptFeature).isEnrolled(biometricManager) + assertFalse(biometricPromptFeature.canUseFeature(testContext)) + } + + @Config(sdk = [M]) + @Test + fun `GIVEN canUseFeature is called WHEN hardware is not available and biometric is not enrolled THEN canUseFeature return false`() { + doReturn(false).`when`(biometricPromptFeature).isHardwareAvailable(biometricManager) + doReturn(false).`when`(biometricPromptFeature).isEnrolled(biometricManager) + assertFalse(biometricPromptFeature.canUseFeature(testContext)) + } + + @Config(sdk = [M]) + @Test + fun `GIVEN canUseFeature is called WHEN hardware is not available and biometric is enrolled THEN canUseFeature return false`() { + doReturn(false).`when`(biometricPromptFeature).isHardwareAvailable(biometricManager) + doReturn(true).`when`(biometricPromptFeature).isEnrolled(biometricManager) + assertFalse(biometricPromptFeature.canUseFeature(testContext)) + } + + @Test + fun `prompt is created and destroyed on start and stop`() { + assertNull(biometricPromptFeature.biometricPrompt) + + biometricPromptFeature.start() + + assertNotNull(biometricPromptFeature.biometricPrompt) + + biometricPromptFeature.stop() + + assertNull(biometricPromptFeature.biometricPrompt) + } + + @Test + fun `requestAuthentication invokes biometric prompt`() { + val prompt: BiometricPrompt = mock() + + biometricPromptFeature.biometricPrompt = prompt + + biometricPromptFeature.requestAuthentication("title", "subtitle") + + verify(prompt).authenticate(any()) + } + + @Test + fun `promptCallback fires feature callbacks`() { + val promptCallback: BiometricPromptFeature.PromptCallback = mock() + val prompt = BiometricPrompt(fragment, promptCallback) + biometricPromptFeature.biometricPrompt = prompt + + promptCallback.onAuthenticationError(0, "") + + verify(promptCallback).onAuthenticationError(0, "") + + promptCallback.onAuthenticationFailed() + + verify(promptCallback).onAuthenticationFailed() + + promptCallback.onAuthenticationSucceeded(any()) + + verify(promptCallback).onAuthenticationSucceeded(any()) + } +} diff --git a/taskcluster/ci/config.yml b/taskcluster/ci/config.yml index 31554212c7a..7f711d1f62d 100644 --- a/taskcluster/ci/config.yml +++ b/taskcluster/ci/config.yml @@ -35,6 +35,7 @@ treeherder: concept-tabstray: concept-tabstray concept-toolbar: concept-toolbar feature-accounts-push: feature-accounts-push + feature-biometric-prompt: feature-biometric-prompt feature-accounts: feature-accounts feature-addons: feature-addons feature-app-links: feature-app-links