diff options
author | 2024-03-21 19:14:55 +0000 | |
---|---|---|
committer | 2024-03-21 19:14:55 +0000 | |
commit | d458675f649ab745b53c27b60d08864cdb286052 (patch) | |
tree | a3b37c91666ffe996ac2fd95d1c77ae70cfb0c7d | |
parent | b231a189b970b9c180b02f956a867a8a7dda26cc (diff) | |
parent | 19033e9530e89e7c2ac32172e603718b194cd694 (diff) |
Merge changes from topic "getFlow_bio" into main
* changes:
Communicate Get Flow Biometric Success to Provider
Get Flow States, Conversion, and Biometric
13 files changed, 636 insertions, 31 deletions
diff --git a/packages/CredentialManager/AndroidManifest.xml b/packages/CredentialManager/AndroidManifest.xml index a5ccdb6575bb..7a8c25bd12ab 100644 --- a/packages/CredentialManager/AndroidManifest.xml +++ b/packages/CredentialManager/AndroidManifest.xml @@ -23,6 +23,7 @@ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/> <uses-permission android:name="android.permission.HIDE_NON_SYSTEM_OVERLAY_WINDOWS"/> <uses-permission android:name="android.permission.ACCESS_INSTANT_APPS" /> + <uses-permission android:name="android.permission.USE_BIOMETRIC" /> <application android:allowBackup="true" diff --git a/packages/CredentialManager/res/values/strings.xml b/packages/CredentialManager/res/values/strings.xml index 527701c06bc6..bc35a85e48f8 100644 --- a/packages/CredentialManager/res/values/strings.xml +++ b/packages/CredentialManager/res/values/strings.xml @@ -63,11 +63,11 @@ <!-- This appears as the description body of the modal bottom sheet which provides all available providers for users to choose. [CHAR LIMIT=200] --> <string name="choose_provider_body">Select a password manager to save your info and sign in faster next time</string> <!-- This appears as the title of the modal bottom sheet for users to choose the create option inside a provider when the credential type is passkey. [CHAR LIMIT=200] --> - <string name="choose_create_option_passkey_title">Create passkey to sign in to <xliff:g id="appName" example="Tribank">%1$s</xliff:g>?</string> + <string name="choose_create_option_passkey_title">Create passkey to sign in to <xliff:g id="app_name" example="Tribank">%1$s</xliff:g>?</string> <!-- This appears as the title of the modal bottom sheet for users to choose the create option inside a provider when the credential type is password. [CHAR LIMIT=200] --> - <string name="choose_create_option_password_title">Save password to sign in to <xliff:g id="appName" example="Tribank">%1$s</xliff:g>?</string> + <string name="choose_create_option_password_title">Save password to sign in to <xliff:g id="app_name" example="Tribank">%1$s</xliff:g>?</string> <!-- This appears as the title of the modal bottom sheet for users to choose the create option inside a provider when the credential type is others. [CHAR LIMIT=200] --> - <string name="choose_create_option_sign_in_title">Save sign-in info for <xliff:g id="appName" example="Tribank">%1$s</xliff:g>?</string> + <string name="choose_create_option_sign_in_title">Save sign-in info for <xliff:g id="app_name" example="Tribank">%1$s</xliff:g>?</string> <!-- Types which are inserted as a placeholder as credentialTypes for other strings. [CHAR LIMIT=200] --> <string name="passkey">passkey</string> <string name="password">password</string> @@ -122,6 +122,8 @@ <string name="get_dialog_title_use_passkey_for">Use your saved passkey for <xliff:g id="app_name" example="YouTube">%1$s</xliff:g>?</string> <!-- This appears as the title of the modal bottom sheet asking for user confirmation to use the single previously saved password to sign in to the app. [CHAR LIMIT=200] --> <string name="get_dialog_title_use_password_for">Use your saved password for <xliff:g id="app_name" example="YouTube">%1$s</xliff:g>?</string> + <!-- This appears as a description of the modal bottom sheet when the single tap sign in flow is used for the get flow. [CHAR LIMIT=200] --> + <string name="get_dialog_title_single_tap_for">Use your screen lock to sign in to <xliff:g id="app_name" example="Shrine">%1$s</xliff:g> with <xliff:g id="username" example="beckett-bakery@gmail.com">%2$s</xliff:g></string> <!-- This appears as the title of the dialog asking for user confirmation to use the single user credential (previously saved or to be created) to sign in to the app. [CHAR LIMIT=200] --> <string name="get_dialog_title_use_sign_in_for">Use your sign-in for <xliff:g id="app_name" example="YouTube">%1$s</xliff:g>?</string> <!-- This appears as the title of the dialog asking for user confirmation to unlock / authenticate (e.g. via fingerprint, faceId, passcode etc.) so that we can retrieve their sign-in options. [CHAR LIMIT=200] --> diff --git a/packages/CredentialManager/shared/src/com/android/credentialmanager/ktx/CredentialKtx.kt b/packages/CredentialManager/shared/src/com/android/credentialmanager/ktx/CredentialKtx.kt index 12cb7ffddd5d..f2c252ec6422 100644 --- a/packages/CredentialManager/shared/src/com/android/credentialmanager/ktx/CredentialKtx.kt +++ b/packages/CredentialManager/shared/src/com/android/credentialmanager/ktx/CredentialKtx.kt @@ -48,6 +48,7 @@ import com.android.credentialmanager.model.get.ProviderInfo import com.android.credentialmanager.model.get.RemoteEntryInfo import com.android.credentialmanager.shared.R import com.android.credentialmanager.TAG +import com.android.credentialmanager.model.BiometricRequestInfo import com.android.credentialmanager.model.EntryInfo fun EntryInfo.getIntentSenderRequest( @@ -139,6 +140,7 @@ private fun getCredentialOptionInfoList( isDefaultIconPreferredAsSingleProvider = credentialEntry.isDefaultIconPreferredAsSingleProvider, affiliatedDomain = credentialEntry.affiliatedDomain?.toString(), + biometricRequest = predetermineAndValidateBiometricFlow(it), ) ) } @@ -167,6 +169,7 @@ private fun getCredentialOptionInfoList( isDefaultIconPreferredAsSingleProvider = credentialEntry.isDefaultIconPreferredAsSingleProvider, affiliatedDomain = credentialEntry.affiliatedDomain?.toString(), + biometricRequest = predetermineAndValidateBiometricFlow(it), ) ) } @@ -194,6 +197,7 @@ private fun getCredentialOptionInfoList( isDefaultIconPreferredAsSingleProvider = credentialEntry.isDefaultIconPreferredAsSingleProvider, affiliatedDomain = credentialEntry.affiliatedDomain?.toString(), + biometricRequest = predetermineAndValidateBiometricFlow(it), ) ) } @@ -205,6 +209,36 @@ private fun getCredentialOptionInfoList( } return result } + +/** + * This validates if this is a biometric flow or not, and if it is, this returns the expected + * [BiometricRequestInfo]. Namely, the biometric flow must have at least the + * ALLOWED_AUTHENTICATORS bit passed from Jetpack. + * Note that the required values, such as the provider info's icon or display name, or the entries + * credential type or userName, and finally the display info's app name, are non-null and must + * exist to run through the flow. + * // TODO(b/326243754) : Presently, due to dependencies, the opId bit is parsed but is never + * // expected to be used. When it is added, it should be lightly validated. + */ +private fun predetermineAndValidateBiometricFlow( + it: Entry +): BiometricRequestInfo? { + // TODO(b/326243754) : When available, use the official jetpack structured type + val allowedAuthenticators: Int? = it.slice.items.firstOrNull { + it.hasHint("androidx.credentials." + + "provider.credentialEntry.SLICE_HINT_ALLOWED_AUTHENTICATORS") + }?.int + + // This is optional and does not affect validating the biometric flow in any case + val opId: Int? = it.slice.items.firstOrNull { + it.hasHint("androidx.credentials.provider.credentialEntry.SLICE_HINT_CRYPTO_OP_ID") + }?.int + if (allowedAuthenticators != null) { + return BiometricRequestInfo(opId = opId, allowedAuthenticators = allowedAuthenticators) + } + return null +} + val Slice.credentialEntry: CredentialEntry? get() = try { @@ -221,7 +255,6 @@ val Slice.credentialEntry: CredentialEntry? CustomCredentialEntry.fromSlice(this) } - /** * Note: caller required handle empty list due to parsing error. */ diff --git a/packages/CredentialManager/shared/src/com/android/credentialmanager/model/BiometricRequestInfo.kt b/packages/CredentialManager/shared/src/com/android/credentialmanager/model/BiometricRequestInfo.kt new file mode 100644 index 000000000000..486cfe7123dd --- /dev/null +++ b/packages/CredentialManager/shared/src/com/android/credentialmanager/model/BiometricRequestInfo.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.credentialmanager.model + +/** + * This allows reading the data from the request, and holding that state around the framework. + * The [opId] bit is required for some authentication flows where CryptoObjects are used. + * The [allowedAuthenticators] is needed for all flows, and our flow ensures this value is never + * null. + */ +data class BiometricRequestInfo( + val opId: Int? = null, + val allowedAuthenticators: Int +)
\ No newline at end of file diff --git a/packages/CredentialManager/shared/src/com/android/credentialmanager/model/creation/CreateOptionInfo.kt b/packages/CredentialManager/shared/src/com/android/credentialmanager/model/creation/CreateOptionInfo.kt index d6189eb15ff3..fe02e5ba026d 100644 --- a/packages/CredentialManager/shared/src/com/android/credentialmanager/model/creation/CreateOptionInfo.kt +++ b/packages/CredentialManager/shared/src/com/android/credentialmanager/model/creation/CreateOptionInfo.kt @@ -19,6 +19,7 @@ package com.android.credentialmanager.model.creation import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Drawable +import com.android.credentialmanager.model.BiometricRequestInfo import com.android.credentialmanager.model.EntryInfo import java.time.Instant @@ -36,6 +37,7 @@ class CreateOptionInfo( val lastUsedTime: Instant, val footerDescription: String?, val allowAutoSelect: Boolean, + val biometricRequest: BiometricRequestInfo? = null, ) : EntryInfo( providerId, entryKey, diff --git a/packages/CredentialManager/shared/src/com/android/credentialmanager/model/get/CredentialEntryInfo.kt b/packages/CredentialManager/shared/src/com/android/credentialmanager/model/get/CredentialEntryInfo.kt index a657e97de3cc..8913397db072 100644 --- a/packages/CredentialManager/shared/src/com/android/credentialmanager/model/get/CredentialEntryInfo.kt +++ b/packages/CredentialManager/shared/src/com/android/credentialmanager/model/get/CredentialEntryInfo.kt @@ -19,6 +19,7 @@ package com.android.credentialmanager.model.get import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Drawable +import com.android.credentialmanager.model.BiometricRequestInfo import com.android.credentialmanager.model.CredentialType import com.android.credentialmanager.model.EntryInfo import java.time.Instant @@ -49,6 +50,7 @@ class CredentialEntryInfo( // "For <value-of-entryGroupId>" on the more-option screen. val isDefaultIconPreferredAsSingleProvider: Boolean, val affiliatedDomain: String?, + val biometricRequest: BiometricRequestInfo? = null, ) : EntryInfo( providerId, entryKey, diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt index 879d64c761ec..b17a98b30eee 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt @@ -40,6 +40,7 @@ import com.android.credentialmanager.getflow.GetCredentialUiState import com.android.credentialmanager.getflow.findAutoSelectEntry import com.android.credentialmanager.common.ProviderActivityState import com.android.credentialmanager.createflow.isFlowAutoSelectable +import com.android.credentialmanager.getflow.findBiometricFlowEntry /** * Client for interacting with Credential Manager. Also holds data inputs from it. @@ -148,10 +149,17 @@ class CredentialManagerRepo( ) } RequestInfo.TYPE_GET -> { - val getCredentialInitialUiState = getCredentialInitialUiState(originName, + var getCredentialInitialUiState = getCredentialInitialUiState(originName, isReqForAllOptions)!! val autoSelectEntry = findAutoSelectEntry(getCredentialInitialUiState.providerDisplayInfo) + val biometricEntry = findBiometricFlowEntry( + getCredentialInitialUiState.providerDisplayInfo, + autoSelectEntry != null) + if (biometricEntry != null) { + getCredentialInitialUiState = getCredentialInitialUiState.copy( + activeEntry = biometricEntry) + } UiState( createCredentialUiState = null, getCredentialUiState = getCredentialInitialUiState, diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt index 1f2fa200e43d..28c40479962e 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt @@ -17,6 +17,7 @@ package com.android.credentialmanager import android.app.Activity +import android.hardware.biometrics.BiometricPrompt import android.os.IBinder import android.text.TextUtils import android.util.Log @@ -28,6 +29,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import com.android.credentialmanager.common.BiometricResult +import com.android.credentialmanager.common.BiometricState import com.android.credentialmanager.model.EntryInfo import com.android.credentialmanager.common.Constants import com.android.credentialmanager.common.DialogState @@ -54,6 +57,7 @@ data class UiState( val isAutoSelectFlow: Boolean = false, val cancelRequestState: CancelUiRequestState?, val isInitialRender: Boolean, + val biometricState: BiometricState = BiometricState() ) data class CancelUiRequestState( @@ -113,12 +117,21 @@ class CredentialSelectorViewModel( launcher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult> ) { val entry = uiState.selectedEntry + val biometricState = uiState.biometricState val pendingIntent = entry?.pendingIntent if (pendingIntent != null) { Log.d(Constants.LOG_TAG, "Launching provider activity") uiState = uiState.copy(providerActivityState = ProviderActivityState.PENDING) val entryIntent = entry.fillInIntent entryIntent?.putExtra(Constants.IS_AUTO_SELECTED_KEY, uiState.isAutoSelectFlow) + if (biometricState.biometricResult != null) { + if (uiState.isAutoSelectFlow) { + Log.w(Constants.LOG_TAG, "Unexpected biometric result exists when " + + "autoSelect is preferred.") + } + entryIntent?.putExtra(Constants.BIOMETRIC_AUTH_TYPE, + biometricState.biometricResult.biometricAuthenticationResult.authenticationType) + } val intentSenderRequest = IntentSenderRequest.Builder(pendingIntent) .setFillInIntent(entryIntent).build() try { @@ -200,13 +213,20 @@ class CredentialSelectorViewModel( /**************************************************************************/ /***** Get Flow Callbacks *****/ /**************************************************************************/ - fun getFlowOnEntrySelected(entry: EntryInfo) { + fun getFlowOnEntrySelected( + entry: EntryInfo, + authResult: BiometricPrompt.AuthenticationResult? = null + ) { Log.d(Constants.LOG_TAG, "credential selected: {provider=${entry.providerId}" + ", key=${entry.entryKey}, subkey=${entry.entrySubkey}}") uiState = if (entry.pendingIntent != null) { uiState.copy( selectedEntry = entry, providerActivityState = ProviderActivityState.READY_TO_LAUNCH, + biometricState = if (authResult == null) uiState.biometricState else uiState + .biometricState.copy(biometricResult = BiometricResult( + biometricAuthenticationResult = authResult) + ) ) } else { credManRepo.onOptionSelected(entry.providerId, entry.entryKey, entry.entrySubkey) @@ -347,4 +367,9 @@ class CredentialSelectorViewModel( fun logUiEvent(uiEventEnum: UiEventEnum) { this.uiMetrics.log(uiEventEnum, credManRepo.requestInfo?.packageName) } + + companion object { + // TODO(b/326243754) : Replace/remove once all failure flows added in + const val TEMPORARY_FAILURE_CODE = Integer.MIN_VALUE + } }
\ No newline at end of file diff --git a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt index 6a1998a5e24e..fd6fc6a44c7c 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt @@ -503,6 +503,8 @@ class CreateFlowUtils { it.hasHint("androidx.credentials.provider.createEntry.SLICE_HINT_AUTO_" + "SELECT_ALLOWED") }?.text == "true", + // TODO(b/326243754) : Handle this when the create flow is added; for now the + // create flow does not support biometric values ) ) } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/BiometricHandler.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/BiometricHandler.kt new file mode 100644 index 000000000000..db5ab569535f --- /dev/null +++ b/packages/CredentialManager/src/com/android/credentialmanager/common/BiometricHandler.kt @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.credentialmanager.common + +import android.content.Context +import android.graphics.Bitmap +import android.hardware.biometrics.BiometricManager +import android.hardware.biometrics.BiometricPrompt +import android.os.CancellationSignal +import android.util.Log +import androidx.core.content.ContextCompat.getMainExecutor +import androidx.core.graphics.drawable.toBitmap +import com.android.credentialmanager.R +import com.android.credentialmanager.createflow.EnabledProviderInfo +import com.android.credentialmanager.getflow.ProviderDisplayInfo +import com.android.credentialmanager.getflow.RequestDisplayInfo +import com.android.credentialmanager.getflow.generateDisplayTitleTextResCode +import com.android.credentialmanager.model.BiometricRequestInfo +import com.android.credentialmanager.model.EntryInfo +import com.android.credentialmanager.model.get.CredentialEntryInfo +import com.android.credentialmanager.model.get.ProviderInfo +import java.lang.Exception + +/** + * Aggregates common display information used for the Biometric Flow. + * Namely, this adds the ability to encapsulate the [providerIcon], the providers icon, the + * [providerName], which represents the name of the provider, the [displayTitleText] which is + * the large text displaying the flow in progress, and the [descriptionAboveBiometricButton], which + * describes details of where the credential is being saved, and how. + */ +data class BiometricDisplayInfo( + val providerIcon: Bitmap, + val providerName: String, + val displayTitleText: String, + val descriptionAboveBiometricButton: String, + val biometricRequestInfo: BiometricRequestInfo, +) + +/** + * Sets up generic state used by the create and get flows to hold the holistic states for the flow. + * These match all the present callback values from [BiometricPrompt], and may be extended to hold + * additional states that may improve the flow. + */ +data class BiometricState( + val biometricResult: BiometricResult? = null, + val biometricError: BiometricError? = null, + val biometricHelp: BiometricHelp? = null, + val biometricAcquireInfo: Int? = null, +) + +/** + * When a result exists, it must be retrievable. This encapsulates the result + * so that should this object exist, the result will be retrievable. + */ +data class BiometricResult( + val biometricAuthenticationResult: BiometricPrompt.AuthenticationResult +) + +/** + * Encapsulates the error callback results to easily manage biometric error states in the flow. + */ +data class BiometricError( + val errorCode: Int, + val errString: CharSequence? = null +) + +/** + * Encapsulates the help callback results to easily manage biometric help states in the flow. + * To specify, this allows us to parse the onAuthenticationHelp method in the [BiometricPrompt]. + */ +data class BiometricHelp( + val helpCode: Int, + var helpString: CharSequence? = null +) + +/** + * This will handle the logic for integrating credential manager with the biometric prompt for the + * single account biometric experience. This simultaneously handles both the get and create flows, + * by retrieving all the data from credential manager, and properly parsing that data into the + * biometric prompt. + */ +fun runBiometricFlow( + biometricEntry: EntryInfo, + context: Context, + openMoreOptionsPage: () -> Unit, + sendDataToProvider: (EntryInfo, BiometricPrompt.AuthenticationResult) -> Unit, + onCancelFlowAndFinish: (String) -> Unit, + getRequestDisplayInfo: RequestDisplayInfo? = null, + getProviderInfoList: List<ProviderInfo>? = null, + getProviderDisplayInfo: ProviderDisplayInfo? = null, + onBiometricFailureFallback: () -> Unit, + createRequestDisplayInfo: com.android.credentialmanager.createflow + .RequestDisplayInfo? = null, + createProviderInfo: EnabledProviderInfo? = null, +) { + var biometricDisplayInfo: BiometricDisplayInfo? = null + if (getRequestDisplayInfo != null) { + biometricDisplayInfo = validateAndRetrieveBiometricGetDisplayInfo(getRequestDisplayInfo, + getProviderInfoList, + getProviderDisplayInfo, + context, biometricEntry) + } else if (createRequestDisplayInfo != null) { + // TODO(b/326243754) : Create Flow to be implemented in follow up + biometricDisplayInfo = validateBiometricCreateFlow( + createRequestDisplayInfo, + createProviderInfo + ) + } + + if (biometricDisplayInfo == null) { + onBiometricFailureFallback() + return + } + + val biometricPrompt = setupBiometricPrompt(context, biometricDisplayInfo, openMoreOptionsPage, + biometricDisplayInfo.biometricRequestInfo.allowedAuthenticators) + + val callback: BiometricPrompt.AuthenticationCallback = + setupBiometricAuthenticationCallback(sendDataToProvider, biometricEntry, + onCancelFlowAndFinish) + + val cancellationSignal = CancellationSignal() + cancellationSignal.setOnCancelListener { + Log.d(TAG, "Your cancellation signal was called.") + // TODO(b/326243754) : Migrate towards passing along the developer cancellation signal + // or validate the necessity for this + } + + val executor = getMainExecutor(context) + + try { + biometricPrompt.authenticate(cancellationSignal, executor, callback) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Calling the biometric prompt API failed with: /n${e.localizedMessage}\n") + onBiometricFailureFallback() + } +} + +/** + * Sets up the biometric prompt with the UI specific bits. + * // TODO(b/326243754) : Pass in opId once dependency is confirmed via CryptoObject + * // TODO(b/326243754) : Given fallbacks aren't allowed, for now we validate that device creds + * // are NOT allowed to be passed in to avoid throwing an error. Later, however, once target + * // alignments occur, we should add the bit back properly. + */ +private fun setupBiometricPrompt( + context: Context, + biometricDisplayInfo: BiometricDisplayInfo, + openMoreOptionsPage: () -> Unit, + requestAllowedAuthenticators: Int, +): BiometricPrompt { + val finalAuthenticators = removeDeviceCredential(requestAllowedAuthenticators) + + val biometricPrompt = BiometricPrompt.Builder(context) + .setTitle(biometricDisplayInfo.displayTitleText) + // TODO(b/326243754) : Migrate to using new methods recently aligned upon + .setNegativeButton(context.getString(R.string + .dropdown_presentation_more_sign_in_options_text), + getMainExecutor(context)) { _, _ -> + openMoreOptionsPage() + } + .setAllowedAuthenticators(finalAuthenticators) + .setConfirmationRequired(true) + // TODO(b/326243754) : Add logo back once new permission privileges sorted out + .setDescription(biometricDisplayInfo.descriptionAboveBiometricButton) + .build() + + return biometricPrompt +} + +// TODO(b/326243754) : Remove after larger level alignments made on fallback negative button +// For the time being, we do not support the pin fallback until UX is decided. +private fun removeDeviceCredential(requestAllowedAuthenticators: Int): Int { + var finalAuthenticators = requestAllowedAuthenticators + + if (requestAllowedAuthenticators == (BiometricManager.Authenticators.DEVICE_CREDENTIAL or + BiometricManager.Authenticators.BIOMETRIC_WEAK)) { + finalAuthenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK + } + + if (requestAllowedAuthenticators == (BiometricManager.Authenticators.DEVICE_CREDENTIAL or + BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + finalAuthenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK + } + + if (requestAllowedAuthenticators == (BiometricManager.Authenticators.DEVICE_CREDENTIAL)) { + finalAuthenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK + } + + return finalAuthenticators +} + +/** + * Sets up the biometric authentication callback. + */ +private fun setupBiometricAuthenticationCallback( + sendDataToProvider: (EntryInfo, BiometricPrompt.AuthenticationResult) -> Unit, + selectedEntry: EntryInfo, + onCancelFlowAndFinish: (String) -> Unit +): BiometricPrompt.AuthenticationCallback { + val callback: BiometricPrompt.AuthenticationCallback = + object : BiometricPrompt.AuthenticationCallback() { + // TODO(b/326243754) : Validate remaining callbacks + override fun onAuthenticationSucceeded( + authResult: BiometricPrompt.AuthenticationResult? + ) { + super.onAuthenticationSucceeded(authResult) + try { + if (authResult != null) { + sendDataToProvider(selectedEntry, authResult) + } else { + onCancelFlowAndFinish("The biometric flow succeeded but unexpectedly " + + "returned a null value.") + // TODO(b/326243754) : Propagate to provider + } + } catch (e: Exception) { + onCancelFlowAndFinish("The biometric flow succeeded but failed on handling " + + "the result. See: \n$e\n") + // TODO(b/326243754) : Propagate to provider + } + } + + override fun onAuthenticationHelp(helpCode: Int, helpString: CharSequence?) { + super.onAuthenticationHelp(helpCode, helpString) + Log.d(TAG, "Authentication help discovered: $helpCode and $helpString") + // TODO(b/326243754) : Decide on strategy with provider (a simple log probably + // suffices here) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { + super.onAuthenticationError(errorCode, errString) + Log.d(TAG, "Authentication error-ed out: $errorCode and $errString") + // TODO(b/326243754) : Propagate to provider + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + Log.d(TAG, "Authentication failed.") + // TODO(b/326243754) : Propagate to provider + } + } + return callback +} + +/** + * Creates the [BiometricDisplayInfo] for get flows, and early handles conditional + * checking between the two. Note that while this method's main purpose is to retrieve the info + * required to display the biometric prompt, it acts as a secondary validator to handle any null + * checks at the beginning of the biometric flow and supports a quick fallback. + * While it's not expected for the flow to be triggered if values are + * missing, some values are by default nullable when they are pulled, such as entries. Thus, this + * acts as a final validation failsafe, without requiring null checks or null forcing around the + * codebase. + */ +private fun validateAndRetrieveBiometricGetDisplayInfo( + getRequestDisplayInfo: RequestDisplayInfo?, + getProviderInfoList: List<ProviderInfo>?, + getProviderDisplayInfo: ProviderDisplayInfo?, + context: Context, + selectedEntry: EntryInfo +): BiometricDisplayInfo? { + if (getRequestDisplayInfo != null && getProviderInfoList != null && + getProviderDisplayInfo != null) { + if (selectedEntry !is CredentialEntryInfo) { return null } + return getBiometricDisplayValues(getProviderInfoList, + context, getRequestDisplayInfo, selectedEntry) + } + return null +} + +/** + * Creates the [BiometricDisplayInfo] for create flows, and early handles conditional + * checking between the two. The reason for this method matches the logic for the + * [validateBiometricGetFlow] with the only difference being that this is for the create flow. + */ +private fun validateBiometricCreateFlow( + createRequestDisplayInfo: com.android.credentialmanager.createflow.RequestDisplayInfo?, + createProviderInfo: EnabledProviderInfo?, +): BiometricDisplayInfo? { + if (createRequestDisplayInfo != null && createProviderInfo != null) { + } else if (createRequestDisplayInfo != null && createProviderInfo != null) { + // TODO(b/326243754) : Create Flow to be implemented in follow up + return createFlowDisplayValues() + } + return null +} + +/** + * Handles the biometric sign in via the 'get credentials' flow. + * If any expected value is not present, the flow is considered unreachable and we will fallback + * to the original selector. Note that these redundant checks are just failsafe; the original + * flow should never reach here with invalid params. + */ +private fun getBiometricDisplayValues( + getProviderInfoList: List<ProviderInfo>, + context: Context, + getRequestDisplayInfo: RequestDisplayInfo, + selectedEntry: CredentialEntryInfo, +): BiometricDisplayInfo? { + var icon: Bitmap? = null + var providerName: String? = null + var displayTitleText: String? = null + var descriptionText: String? = null + val primaryAccountsProviderInfo = retrievePrimaryAccountProviderInfo(selectedEntry.providerId, + getProviderInfoList) + icon = primaryAccountsProviderInfo?.icon?.toBitmap() + providerName = primaryAccountsProviderInfo?.displayName + if (icon == null || providerName == null) { + Log.d(TAG, "Unexpectedly found invalid provider information.") + return null + } + if (selectedEntry.biometricRequest == null) { + Log.d(TAG, "Unexpectedly in biometric flow without a biometric request.") + return null + } + val singleEntryType = selectedEntry.credentialType + val username = selectedEntry.userName + displayTitleText = context.getString( + generateDisplayTitleTextResCode(singleEntryType), + getRequestDisplayInfo.appName + ) + descriptionText = context.getString( + R.string.get_dialog_title_single_tap_for, + getRequestDisplayInfo.appName, + username + ) + return BiometricDisplayInfo(providerIcon = icon, providerName = providerName, + displayTitleText = displayTitleText, descriptionAboveBiometricButton = descriptionText, + biometricRequestInfo = selectedEntry.biometricRequest as BiometricRequestInfo) +} + +/** + * Handles the biometric sign in via the 'create credentials' flow, or early validates this flow + * needs to fallback. + */ +private fun createFlowDisplayValues(): BiometricDisplayInfo? { + // TODO(b/326243754) : Create Flow to be implemented in follow up + return null +} + +/** + * During a get flow with single tap sign in enabled, this will match the credentialEntry that + * will single tap with the correct provider info. Namely, it's the first provider info that + * contains a matching providerId to the selected entry. + */ +private fun retrievePrimaryAccountProviderInfo( + providerId: String, + getProviderInfoList: List<ProviderInfo> +): ProviderInfo? { + var discoveredProviderInfo: ProviderInfo? = null + getProviderInfoList.forEach { provider -> + if (provider.id == providerId) { + discoveredProviderInfo = provider + return@forEach + } + } + return discoveredProviderInfo +} + +const val TAG = "BiometricHandler" diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/Constants.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/Constants.kt index 51ca5971cec4..7e7a74fd3107 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/common/Constants.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/common/Constants.kt @@ -22,5 +22,7 @@ class Constants { const val BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS = "androidx.credentials.BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED" const val IS_AUTO_SELECTED_KEY = "IS_AUTO_SELECTED" + const val BIOMETRIC_AUTH_TYPE = "BIOMETRIC_AUTH_TYPE" + const val BIOMETRIC_AUTH_FAILURE = "BIOMETRIC_AUTH_FAILURE" } } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt index b9c9d8994c45..b59ccc264630 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt @@ -16,8 +16,10 @@ package com.android.credentialmanager.getflow +import android.credentials.flags.Flags.credmanBiometricApiEnabled import android.credentials.flags.Flags.selectorUiImprovementsEnabled import android.graphics.drawable.Drawable +import android.hardware.biometrics.BiometricPrompt import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.result.ActivityResult import androidx.activity.result.IntentSenderRequest @@ -41,6 +43,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextLayoutResult @@ -49,30 +52,31 @@ import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap import com.android.credentialmanager.CredentialSelectorViewModel import com.android.credentialmanager.R -import com.android.credentialmanager.model.EntryInfo -import com.android.credentialmanager.model.CredentialType -import com.android.credentialmanager.model.get.ProviderInfo import com.android.credentialmanager.common.ProviderActivityState import com.android.credentialmanager.common.material.ModalBottomSheetDefaults +import com.android.credentialmanager.common.runBiometricFlow import com.android.credentialmanager.common.ui.ActionButton import com.android.credentialmanager.common.ui.ActionEntry import com.android.credentialmanager.common.ui.ConfirmButton import com.android.credentialmanager.common.ui.CredentialContainerCard +import com.android.credentialmanager.common.ui.CredentialListSectionHeader import com.android.credentialmanager.common.ui.CtaButtonRow import com.android.credentialmanager.common.ui.Entry +import com.android.credentialmanager.common.ui.HeadlineIcon +import com.android.credentialmanager.common.ui.HeadlineText +import com.android.credentialmanager.common.ui.LargeLabelTextOnSurfaceVariant import com.android.credentialmanager.common.ui.ModalBottomSheet import com.android.credentialmanager.common.ui.MoreOptionTopAppBar import com.android.credentialmanager.common.ui.SheetContainerCard -import com.android.credentialmanager.common.ui.SnackbarActionText -import com.android.credentialmanager.common.ui.HeadlineText -import com.android.credentialmanager.common.ui.CredentialListSectionHeader -import com.android.credentialmanager.common.ui.HeadlineIcon -import com.android.credentialmanager.common.ui.LargeLabelTextOnSurfaceVariant import com.android.credentialmanager.common.ui.Snackbar +import com.android.credentialmanager.common.ui.SnackbarActionText import com.android.credentialmanager.logging.GetCredentialEvent +import com.android.credentialmanager.model.CredentialType +import com.android.credentialmanager.model.EntryInfo import com.android.credentialmanager.model.get.ActionEntryInfo import com.android.credentialmanager.model.get.AuthenticationEntryInfo import com.android.credentialmanager.model.get.CredentialEntryInfo +import com.android.credentialmanager.model.get.ProviderInfo import com.android.credentialmanager.model.get.RemoteEntryInfo import com.android.credentialmanager.userAndDisplayNameForPasskey import com.android.internal.logging.UiEventLogger.UiEventEnum @@ -82,7 +86,7 @@ import kotlin.math.max fun GetCredentialScreen( viewModel: CredentialSelectorViewModel, getCredentialUiState: GetCredentialUiState, - providerActivityLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult> + providerActivityLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>, ) { if (getCredentialUiState.currentScreenState == GetScreenState.REMOTE_ONLY) { RemoteCredentialSnackBarScreen( @@ -137,6 +141,22 @@ fun GetCredentialScreen( } viewModel.uiMetrics.log(GetCredentialEvent .CREDMAN_GET_CRED_SCREEN_PRIMARY_SELECTION) + } else if (credmanBiometricApiEnabled() && getCredentialUiState + .currentScreenState == GetScreenState.BIOMETRIC_SELECTION) { + BiometricSelectionPage( + // TODO(b/326243754) : Utilize expected entry for this flow, confirm + // activeEntry will always be what represents the single tap flow + biometricEntry = getCredentialUiState.activeEntry, + onMoreOptionSelected = viewModel::getFlowOnMoreOptionSelected, + onCancelFlowAndFinish = viewModel::onIllegalUiState, + requestDisplayInfo = getCredentialUiState.requestDisplayInfo, + providerInfoList = getCredentialUiState.providerInfoList, + providerDisplayInfo = getCredentialUiState.providerDisplayInfo, + onBiometricEntrySelected = + viewModel::getFlowOnEntrySelected, + fallbackToOriginalFlow = + viewModel::getFlowOnBackToPrimarySelectionScreen, + ) } else { AllSignInOptionCard( providerInfoList = getCredentialUiState.providerInfoList, @@ -189,6 +209,34 @@ fun GetCredentialScreen( } } +@Composable +internal fun BiometricSelectionPage( + biometricEntry: EntryInfo?, + onCancelFlowAndFinish: (String) -> Unit, + onMoreOptionSelected: () -> Unit, + requestDisplayInfo: RequestDisplayInfo, + providerInfoList: List<ProviderInfo>, + providerDisplayInfo: ProviderDisplayInfo, + onBiometricEntrySelected: (EntryInfo, BiometricPrompt.AuthenticationResult?) -> Unit, + fallbackToOriginalFlow: () -> Unit, +) { + if (biometricEntry == null) { + fallbackToOriginalFlow() + return + } + runBiometricFlow( + biometricEntry = biometricEntry, + context = LocalContext.current, + openMoreOptionsPage = onMoreOptionSelected, + sendDataToProvider = onBiometricEntrySelected, + onCancelFlowAndFinish = onCancelFlowAndFinish, + getRequestDisplayInfo = requestDisplayInfo, + getProviderInfoList = providerInfoList, + getProviderDisplayInfo = providerDisplayInfo, + onBiometricFailureFallback = fallbackToOriginalFlow + ) +} + /** Draws the primary credential selection page, used in Android U. */ // TODO(b/327518384) - remove after flag selectorUiImprovementsEnabled is enabled. @Composable @@ -256,13 +304,8 @@ fun PrimarySelectionCard( if (hasSingleEntry) { val singleEntryType = sortedUserNameToCredentialEntryList.firstOrNull() ?.sortedCredentialEntryList?.firstOrNull()?.credentialType - if (singleEntryType == CredentialType.PASSKEY) - R.string.get_dialog_title_use_passkey_for - else if (singleEntryType == CredentialType.PASSWORD) - R.string.get_dialog_title_use_password_for - else if (authenticationEntryList.isNotEmpty()) - R.string.get_dialog_title_unlock_options_for - else R.string.get_dialog_title_use_sign_in_for + generateDisplayTitleTextResCode(singleEntryType!!, + authenticationEntryList) } else { if (authenticationEntryList.isNotEmpty() || sortedUserNameToCredentialEntryList.any { perNameEntryList -> diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt index e35acae547a6..6d5b52a7a5f9 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt @@ -17,8 +17,11 @@ package com.android.credentialmanager.getflow import android.credentials.flags.Flags.selectorUiImprovementsEnabled +import android.credentials.flags.Flags.credmanBiometricApiEnabled import android.graphics.drawable.Drawable import androidx.credentials.PriorityHints +import com.android.credentialmanager.R +import com.android.credentialmanager.model.CredentialType import com.android.credentialmanager.model.get.ProviderInfo import com.android.credentialmanager.model.EntryInfo import com.android.credentialmanager.model.get.AuthenticationEntryInfo @@ -39,6 +42,59 @@ data class GetCredentialUiState( val isNoAccount: Boolean = false, ) +/** + * Checks if this get flow is a biometric selection flow by ensuring that the first account has a + * single credential entry to display. The presently agreed upon condition validates this flow for + * a single account. In the case when there's a single credential, this flow matches the auto + * select criteria, but with the possibility that the two flows (autoselect and biometric) may + * collide. In those collision cases, the auto select flow is supported over the biometric flow. + * If there is a single account but more than one credential, and the first ranked credential has + * the biometric bit flipped on, we will use the biometric flow. If all conditions are valid, this + * responds with the entry utilized by the biometricFlow, or null otherwise. + */ +internal fun findBiometricFlowEntry( + providerDisplayInfo: ProviderDisplayInfo, + isAutoSelectFlow: Boolean +): CredentialEntryInfo? { + if (!credmanBiometricApiEnabled()) { + return null + } + if (isAutoSelectFlow) { + // For this to be true, it must be the case that there is a single entry and a single + // account. If that is the case, and auto-select is enabled along side the one-tap flow, we + // always favor that over the one tap flow. + return null + } + // The flow through an authentication entry, even if only a singular entry exists, is deemed + // as not being eligible for the single tap flow given that it adds any number of credentials + // once unlocked; essentially, this entry contains additional complexities behind it, making it + // invalid. + if (providerDisplayInfo.authenticationEntryList.isNotEmpty()) { + return null + } + val singleAccountEntryList = getCredentialEntryListIffSingleAccount( + providerDisplayInfo.sortedUserNameToCredentialEntryList) ?: return null + + val firstEntry = singleAccountEntryList.firstOrNull() + return if (firstEntry?.biometricRequest != null) firstEntry else null +} + +/** + * A utility method that will procure the credential entry list if and only if the credential entry + * list is for a singular account use case. This can be used for various flows that condition on + * a singular account. + */ +internal fun getCredentialEntryListIffSingleAccount( + sortedUserNameToCredentialEntryList: List<PerUserNameCredentialEntryList> +): List<CredentialEntryInfo>? { + if (sortedUserNameToCredentialEntryList.size != 1) { + return null + } + val entryList = sortedUserNameToCredentialEntryList.firstOrNull() ?: return null + val sortedEntryList = entryList.sortedCredentialEntryList + return sortedEntryList +} + internal fun hasContentToDisplay(state: GetCredentialUiState): Boolean { return state.providerDisplayInfo.sortedUserNameToCredentialEntryList.isNotEmpty() || state.providerDisplayInfo.authenticationEntryList.isNotEmpty() || @@ -50,15 +106,14 @@ internal fun findAutoSelectEntry(providerDisplayInfo: ProviderDisplayInfo): Cred if (providerDisplayInfo.authenticationEntryList.isNotEmpty()) { return null } - if (providerDisplayInfo.sortedUserNameToCredentialEntryList.size == 1) { - val entryList = providerDisplayInfo.sortedUserNameToCredentialEntryList.firstOrNull() - ?: return null - if (entryList.sortedCredentialEntryList.size == 1) { - val entry = entryList.sortedCredentialEntryList.firstOrNull() ?: return null - if (entry.isAutoSelectable) { - return entry - } - } + val entryList = getCredentialEntryListIffSingleAccount( + providerDisplayInfo.sortedUserNameToCredentialEntryList) ?: return null + if (entryList.size != 1) { + return null + } + val entry = entryList.firstOrNull() ?: return null + if (entry.isAutoSelectable) { + return entry } return null } @@ -105,6 +160,9 @@ enum class GetScreenState { /** The primary credential selection page. */ PRIMARY_SELECTION, + /** The single tap biometric selection page. */ + BIOMETRIC_SELECTION, + /** The secondary credential selection page, where all sign-in options are listed. */ ALL_SIGN_IN_OPTIONS, @@ -177,6 +235,22 @@ fun toProviderDisplayInfo( ) } +/** + * This generates the res code for the large display title text for the selector. For example, it + * retrieves the resource for strings like: "Use your saved passkey for *rpName*". + */ +internal fun generateDisplayTitleTextResCode( + singleEntryType: CredentialType, + authenticationEntryList: List<AuthenticationEntryInfo> = emptyList() +): Int = + if (singleEntryType == CredentialType.PASSKEY) + R.string.get_dialog_title_use_passkey_for + else if (singleEntryType == CredentialType.PASSWORD) + R.string.get_dialog_title_use_password_for + else if (authenticationEntryList.isNotEmpty()) + R.string.get_dialog_title_unlock_options_for + else R.string.get_dialog_title_use_sign_in_for + fun toActiveEntry( providerDisplayInfo: ProviderDisplayInfo, ): EntryInfo? { @@ -211,9 +285,18 @@ private fun toGetScreenState( GetScreenState.REMOTE_ONLY else if (isRequestForAllOptions) GetScreenState.ALL_SIGN_IN_OPTIONS + else if (isBiometricFlow(providerDisplayInfo)) + GetScreenState.BIOMETRIC_SELECTION else GetScreenState.PRIMARY_SELECTION } +/** + * Determines if the flow is a biometric flow by taking into account autoselect criteria. + */ +internal fun isBiometricFlow(providerDisplayInfo: ProviderDisplayInfo) = + findBiometricFlowEntry(providerDisplayInfo, + findAutoSelectEntry(providerDisplayInfo) != null) != null + internal class CredentialEntryInfoComparatorByTypeThenTimestamp( val typePriorityMap: Map<String, Int>, ) : Comparator<CredentialEntryInfo> { |