diff options
9 files changed, 485 insertions, 29 deletions
diff --git a/packages/CredentialManager/AndroidManifest.xml b/packages/CredentialManager/AndroidManifest.xml index a5ccdb6575bb..ab7f419216c0 100644 --- a/packages/CredentialManager/AndroidManifest.xml +++ b/packages/CredentialManager/AndroidManifest.xml @@ -22,7 +22,9 @@ <uses-permission android:name="android.permission.LAUNCH_CREDENTIAL_SELECTOR"/> <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.SET_BIOMETRIC_DIALOG_ADVANCED"/> <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 892eabf14191..e0ddb125bef7 100644 --- a/packages/CredentialManager/shared/src/com/android/credentialmanager/ktx/CredentialKtx.kt +++ b/packages/CredentialManager/shared/src/com/android/credentialmanager/ktx/CredentialKtx.kt @@ -139,6 +139,7 @@ private fun getCredentialOptionInfoList( isDefaultIconPreferredAsSingleProvider = credentialEntry.isDefaultIconPreferredAsSingleProvider, affiliatedDomain = credentialEntry.affiliatedDomain?.toString(), + isSupportingSingleTap = false, // TODO(b/326243754) : Fill in as product built ) ) } @@ -167,6 +168,7 @@ private fun getCredentialOptionInfoList( isDefaultIconPreferredAsSingleProvider = credentialEntry.isDefaultIconPreferredAsSingleProvider, affiliatedDomain = credentialEntry.affiliatedDomain?.toString(), + isSupportingSingleTap = false, // TODO(b/326243754) : Fill in as product built ) ) } @@ -194,6 +196,9 @@ private fun getCredentialOptionInfoList( isDefaultIconPreferredAsSingleProvider = credentialEntry.isDefaultIconPreferredAsSingleProvider, affiliatedDomain = credentialEntry.affiliatedDomain?.toString(), + isSupportingSingleTap = false, // TODO(b/326243754) : Fill in as product built + // TODO(b/326243754) : If required info above is missing, force condition to + // false. ) ) } 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..49c71f17839f 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 @@ -49,6 +49,7 @@ class CredentialEntryInfo( // "For <value-of-entryGroupId>" on the more-option screen. val isDefaultIconPreferredAsSingleProvider: Boolean, val affiliatedDomain: String?, + val isSupportingSingleTap: Boolean, ) : EntryInfo( providerId, entryKey, diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt index 1f2fa200e43d..c05544e537d8 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,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +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 +56,7 @@ data class UiState( val isAutoSelectFlow: Boolean = false, val cancelRequestState: CancelUiRequestState?, val isInitialRender: Boolean, + val biometricState: BiometricState? = null ) data class CancelUiRequestState( @@ -200,13 +203,19 @@ 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 = uiState.biometricState?.copy( + biometricAuthenticationResult = authResult + ) ) } else { credManRepo.onOptionSelected(entry.providerId, entry.entryKey, entry.entrySubkey) 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..5bba688e7b8c --- /dev/null +++ b/packages/CredentialManager/src/com/android/credentialmanager/common/BiometricHandler.kt @@ -0,0 +1,322 @@ +/* + * 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.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.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( + var providerIcon: Bitmap, + var providerName: String, + var displayTitleText: String, + var descriptionAboveBiometricButton: String +) + +/** + * 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 biometricAuthenticationResult: BiometricPrompt.AuthenticationResult? = null, + val biometricError: BiometricError? = null, + val biometricHelp: BiometricHelp? = null, + val biometricAcquireInfo: Int? = null +) + +/** + * 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( + selectedEntry: 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, +) { + if (selectedEntry == null) { + onBiometricFailureFallback() + return + } + var biometricDisplayInfo: BiometricDisplayInfo? = null + if (getRequestDisplayInfo != null) { + biometricDisplayInfo = validateAndRetrieveBiometricGetDisplayInfo(getRequestDisplayInfo, + getProviderInfoList, + getProviderDisplayInfo, + context, selectedEntry) + } 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) + + val callback: BiometricPrompt.AuthenticationCallback = + setupBiometricAuthenticationCallback(sendDataToProvider, selectedEntry, + 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. + */ +private fun setupBiometricPrompt( + context: Context, + biometricDisplayInfo: BiometricDisplayInfo, + openMoreOptionsPage: () -> Unit +): 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() + } + .setConfirmationRequired(true) + .setLogoBitmap(biometricDisplayInfo.providerIcon) + .setLogoDescription(biometricDisplayInfo + .providerName) + .setDescription(biometricDisplayInfo.descriptionAboveBiometricButton) + .build() + +/** + * 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. + */ +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, getProviderDisplayInfo) + } + return null +} + +/** + * Creates the [BiometricDisplayInfo] for create flows, and early handles conditional + * checking between the two. + */ +private fun validateBiometricCreateFlow( + createRequestDisplayInfo: com.android.credentialmanager.createflow.RequestDisplayInfo?, + createProviderInfo: EnabledProviderInfo?, +): BiometricDisplayInfo? { + 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. + */ +private fun getBiometricDisplayValues( + getProviderInfoList: List<ProviderInfo>, + context: Context, + getRequestDisplayInfo: RequestDisplayInfo, + selectedEntry: CredentialEntryInfo, + getProviderDisplayInfo: ProviderDisplayInfo, +): 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 + } + 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) +} + +/** + * 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/getflow/GetCredentialComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt index b9c9d8994c45..efde4c3d0eb2 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt @@ -16,8 +16,11 @@ package com.android.credentialmanager.getflow +import android.content.Context +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 +44,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 +53,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 +87,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 +142,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 + selectedEntry = 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 +210,30 @@ fun GetCredentialScreen( } } +@Composable +internal fun BiometricSelectionPage( + selectedEntry: EntryInfo?, + onCancelFlowAndFinish: (String) -> Unit, + onMoreOptionSelected: () -> Unit, + requestDisplayInfo: RequestDisplayInfo, + providerInfoList: List<ProviderInfo>, + providerDisplayInfo: ProviderDisplayInfo, + onBiometricEntrySelected: (EntryInfo, BiometricPrompt.AuthenticationResult?) -> Unit, + fallbackToOriginalFlow: () -> Unit, +) { + runBiometricFlow( + selectedEntry = selectedEntry, + 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 +301,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..3e7004a63317 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,56 @@ 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 matches the auto + * select criteria, but with one additional requirement that the entry contains an extra parameter + * that indicates the biometric flow. The auto select flow is supported over the biometric flow + * at the moment in cases where they collide. + */ +internal fun isBiometricFlow( + providerDisplayInfo: ProviderDisplayInfo, + isAutoSelectFlow: Boolean +): Boolean { + if (!credmanBiometricApiEnabled()) { + return false + } + 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 false + } + // 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 false + } + val singleAccountEntryList = getCredentialEntryListIffSingleAccount( + providerDisplayInfo.sortedUserNameToCredentialEntryList) ?: return false + + // TODO(b/326243754) : Set this value from dynamic slice until structured jetpack object is used + return singleAccountEntryList.firstOrNull()?.isSupportingSingleTap ?: false +} + +/** + * 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 +103,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 +157,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 +232,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,6 +282,9 @@ private fun toGetScreenState( GetScreenState.REMOTE_ONLY else if (isRequestForAllOptions) GetScreenState.ALL_SIGN_IN_OPTIONS + else if (isBiometricFlow(providerDisplayInfo, + findAutoSelectEntry(providerDisplayInfo) != null)) + GetScreenState.BIOMETRIC_SELECTION else GetScreenState.PRIMARY_SELECTION } diff --git a/packages/CredentialManager/tests/robotests/screenshot/src/com/android/credentialmanager/GetCredScreenshotTest.kt b/packages/CredentialManager/tests/robotests/screenshot/src/com/android/credentialmanager/GetCredScreenshotTest.kt index b8432137b35e..01fb1cc1fd67 100644 --- a/packages/CredentialManager/tests/robotests/screenshot/src/com/android/credentialmanager/GetCredScreenshotTest.kt +++ b/packages/CredentialManager/tests/robotests/screenshot/src/com/android/credentialmanager/GetCredScreenshotTest.kt @@ -152,6 +152,7 @@ class GetCredScreenshotTest(emulationSpec: DeviceEmulationSpec) { isDefaultIconPreferredAsSingleProvider = false, rawCredentialType = "unknown-type", affiliatedDomain = null, + isSupportingSingleTap = false, ) ), authenticationEntryList = emptyList(), |