From b4e2a7040dff3084f8dc7d57191aea6f5768c75f Mon Sep 17 00:00:00 2001 From: Helen Qin Date: Tue, 8 Nov 2022 06:16:05 +0000 Subject: [PATCH] Get flow fundamental E2E. Get the regular paths (credential selection, cancellation, navigations etc.) functional E2E. Still missing cases like authentication entry, remote entry, etc. Those will be added later. Recording: http://shortn/_fEseNusjo2 Bug: 253157237 Test: local deployment Change-Id: I715c0143c03f7a99a94da784542260590ab4767c --- core/java/android/credentials/ui/Entry.java | 3 +- .../CredentialManager/res/values/strings.xml | 22 +++ .../credentialmanager/CredentialEntryUi.kt | 59 ------ .../CredentialManagerRepo.kt | 119 ++++++++---- .../credentialmanager/DataConverter.kt | 53 ++++-- .../common/ui/CancelButton.kt | 28 +++ .../common/ui/ConfirmButton.kt | 28 +++ .../createflow/CreatePasskeyComponents.kt | 23 +-- .../getflow/GetCredentialComponents.kt | 178 +++++++++++++----- .../getflow/GetCredentialViewModel.kt | 94 ++++++++- .../credentialmanager/getflow/GetModel.kt | 64 +++++-- .../jetpack/provider/CredentialEntryUi.kt | 48 +++-- .../provider/PasskeyCredentialEntryUi.kt | 63 ------- .../provider/PasswordCredentialEntryUi.kt | 68 ------- 14 files changed, 509 insertions(+), 341 deletions(-) delete mode 100644 packages/CredentialManager/src/com/android/credentialmanager/CredentialEntryUi.kt create mode 100644 packages/CredentialManager/src/com/android/credentialmanager/common/ui/CancelButton.kt create mode 100644 packages/CredentialManager/src/com/android/credentialmanager/common/ui/ConfirmButton.kt delete mode 100644 packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasskeyCredentialEntryUi.kt delete mode 100644 packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasswordCredentialEntryUi.kt diff --git a/core/java/android/credentials/ui/Entry.java b/core/java/android/credentials/ui/Entry.java index 33427d373219c..6fa331bbdc545 100644 --- a/core/java/android/credentials/ui/Entry.java +++ b/core/java/android/credentials/ui/Entry.java @@ -53,7 +53,8 @@ public class Entry implements Parcelable { /** Below are only available for get flows. */ public static final String HINT_NOTE = "HINT_NOTE"; public static final String HINT_USER_NAME = "HINT_USER_NAME"; - public static final String HINT_CREDENTIAL_TYPE = "HINT_CREDENTIAL_TYPE"; + public static final String HINT_CREDENTIAL_TYPE_DISPLAY_NAME = + "HINT_CREDENTIAL_TYPE_DISPLAY_NAME"; public static final String HINT_PASSKEY_USER_DISPLAY_NAME = "HINT_PASSKEY_USER_DISPLAY_NAME"; public static final String HINT_PASSWORD_VALUE = "HINT_PASSWORD_VALUE"; diff --git a/packages/CredentialManager/res/values/strings.xml b/packages/CredentialManager/res/values/strings.xml index 6178efcf86c01..7a4b7cb0288d4 100644 --- a/packages/CredentialManager/res/values/strings.xml +++ b/packages/CredentialManager/res/values/strings.xml @@ -29,4 +29,26 @@ CreateOptionInfo credentialType icon "Close sheet" + + "Go back to the previous page" + + + + Use your saved passkey for %1$s? + + Use your saved sign-in for %1$s? + + Choose a saved sign-in for %1$s + + Sign in another way + + No thanks + + Continue + + " - " + + Sign-in options + + For %1$s \ No newline at end of file diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialEntryUi.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialEntryUi.kt deleted file mode 100644 index ee4f4ca9c832f..0000000000000 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialEntryUi.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2022 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 - -import android.app.slice.Slice -import android.credentials.ui.Entry -import android.graphics.drawable.Icon - -/** - * UI representation for a credential entry used during the get credential flow. - * - * TODO: move to jetpack. - */ -class CredentialEntryUi( - val userName: CharSequence, - val displayName: CharSequence?, - val icon: Icon?, - val usageData: CharSequence?, - // TODO: add last used. -) { - companion object { - fun fromSlice(slice: Slice): CredentialEntryUi { - val items = slice.items - - var title: String? = null - var subTitle: String? = null - var icon: Icon? = null - var usageData: String? = null - - items.forEach { - if (it.hasHint(Entry.HINT_ICON)) { - icon = it.icon - } else if (it.hasHint(Entry.HINT_SUBTITLE) && it.subType == null) { - subTitle = it.text.toString() - } else if (it.hasHint(Entry.HINT_TITLE)) { - title = it.text.toString() - } else if (it.hasHint(Entry.HINT_SUBTITLE) && it.subType == Slice.SUBTYPE_MESSAGE) { - usageData = it.text.toString() - } - } - // TODO: fail NPE more elegantly. - return CredentialEntryUi(title!!, subTitle, icon, usageData) - } - } -} diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt index 33bc79c0a4e8a..2099a235a3e88 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt @@ -16,11 +16,14 @@ package com.android.credentialmanager +import android.credentials.Credential.TYPE_PASSWORD_CREDENTIAL import android.app.slice.Slice import android.app.slice.SliceSpec import android.content.Context import android.content.Intent import android.credentials.CreateCredentialRequest +import android.credentials.GetCredentialOption +import android.credentials.GetCredentialRequest import android.credentials.ui.Constants import android.credentials.ui.Entry import android.credentials.ui.CreateCredentialProviderData @@ -40,7 +43,7 @@ import com.android.credentialmanager.createflow.ProviderInfo import com.android.credentialmanager.createflow.RequestDisplayInfo import com.android.credentialmanager.getflow.GetCredentialUiState import com.android.credentialmanager.getflow.GetScreenState -import com.android.credentialmanager.jetpack.provider.CredentialEntryUi.Companion.TYPE_PUBLIC_KEY_CREDENTIAL +import com.android.credentialmanager.jetpack.developer.PublicKeyCredential.Companion.TYPE_PUBLIC_KEY_CREDENTIAL // Consider repo per screen, similar to view model? class CredentialManagerRepo( @@ -56,7 +59,7 @@ class CredentialManagerRepo( requestInfo = intent.extras?.getParcelable( RequestInfo.EXTRA_REQUEST_INFO, RequestInfo::class.java - ) ?: testRequestInfo() + ) ?: testCreateRequestInfo() providerList = when (requestInfo.type) { RequestInfo.TYPE_CREATE -> @@ -101,19 +104,14 @@ class CredentialManagerRepo( fun getCredentialInitialUiState(): GetCredentialUiState { val providerList = GetFlowUtils.toProviderList( - // TODO: handle runtime cast error - providerList as List, context) + // TODO: handle runtime cast error + providerList as List, context) // TODO: covert from real requestInfo - val requestDisplayInfo = com.android.credentialmanager.getflow.RequestDisplayInfo( - "Elisa Beckett", - "beckett-bakert@gmail.com", - TYPE_PUBLIC_KEY_CREDENTIAL, - "tribank") + val requestDisplayInfo = com.android.credentialmanager.getflow.RequestDisplayInfo("tribank") return GetCredentialUiState( providerList, - GetScreenState.CREDENTIAL_SELECTION, + GetScreenState.PRIMARY_SELECTION, requestDisplayInfo, - providerList.first() ) } @@ -165,9 +163,9 @@ class CredentialManagerRepo( .Builder("com.google/com.google.CredentialManagerService") .setSaveEntries( listOf( - newEntry("key1", "subkey-1", "elisa.beckett@gmail.com", + newCreateEntry("key1", "subkey-1", "elisa.beckett@gmail.com", 20, 7, 27, 10000), - newEntry("key1", "subkey-2", "elisa.work@google.com", + newCreateEntry("key1", "subkey-2", "elisa.work@google.com", 20, 7, 27, 11000), ) ) @@ -177,9 +175,9 @@ class CredentialManagerRepo( .Builder("com.dashlane/com.dashlane.CredentialManagerService") .setSaveEntries( listOf( - newEntry("key1", "subkey-3", "elisa.beckett@dashlane.com", + newCreateEntry("key1", "subkey-3", "elisa.beckett@dashlane.com", 20, 7, 27, 30000), - newEntry("key1", "subkey-4", "elisa.work@dashlane.com", + newCreateEntry("key1", "subkey-4", "elisa.work@dashlane.com", 20, 7, 27, 31000), ) ) @@ -192,37 +190,69 @@ class CredentialManagerRepo( GetCredentialProviderData.Builder("com.google/com.google.CredentialManagerService") .setCredentialEntries( listOf( - newEntry("key1", "subkey-1", "elisa.beckett@gmail.com", - 20, 7, 27, 10000), - newEntry("key1", "subkey-2", "elisa.work@google.com", - 20, 7, 27, 11000), + newGetEntry( + "key1", "subkey-1", TYPE_PUBLIC_KEY_CREDENTIAL, "Passkey", + "elisa.bakery@gmail.com", "Elisa Beckett", 300L + ), + newGetEntry( + "key1", "subkey-2", TYPE_PASSWORD_CREDENTIAL, "Password", + "elisa.bakery@gmail.com", null, 300L + ), + newGetEntry( + "key1", "subkey-3", TYPE_PASSWORD_CREDENTIAL, "Password", + "elisa.family@outlook.com", null, 100L + ), ) - ).setActionChips( - listOf( - newEntry("key2", "subkey-1", "Go to Settings", - 20, 7, 27, 20000), - newEntry("key2", "subkey-2", "Switch Account", - 20, 7, 27, 21000), - ), ).build(), GetCredentialProviderData.Builder("com.dashlane/com.dashlane.CredentialManagerService") .setCredentialEntries( listOf( - newEntry("key1", "subkey-3", "elisa.beckett@dashlane.com", - 20, 7, 27, 30000), - newEntry("key1", "subkey-4", "elisa.work@dashlane.com", - 20, 7, 27, 31000), + newGetEntry( + "key1", "subkey-1", TYPE_PASSWORD_CREDENTIAL, "Password", + "elisa.family@outlook.com", null, 600L + ), + newGetEntry( + "key1", "subkey-2", TYPE_PUBLIC_KEY_CREDENTIAL, "Passkey", + "elisa.family@outlook.com", null, 100L + ), ) - ).setActionChips( - listOf( - newEntry("key2", "subkey-3", "Manage Accounts", - 20, 7, 27, 40000), - ), ).build(), ) } - private fun newEntry( + private fun newGetEntry( + key: String, + subkey: String, + credentialType: String, + credentialTypeDisplayName: String, + userName: String, + userDisplayName: String?, + lastUsedTimeMillis: Long?, + ): Entry { + val slice = Slice.Builder( + Entry.CREDENTIAL_MANAGER_ENTRY_URI, SliceSpec(credentialType, 1) + ).addText( + credentialTypeDisplayName, null, listOf(Entry.HINT_CREDENTIAL_TYPE_DISPLAY_NAME) + ).addText( + userName, null, listOf(Entry.HINT_USER_NAME) + ).addIcon( + Icon.createWithResource(context, R.drawable.ic_passkey), + null, + listOf(Entry.HINT_PROFILE_ICON)) + if (userDisplayName != null) { + slice.addText(userDisplayName, null, listOf(Entry.HINT_PASSKEY_USER_DISPLAY_NAME)) + } + if (lastUsedTimeMillis != null) { + slice.addLong(lastUsedTimeMillis, null, listOf(Entry.HINT_LAST_USED_TIME_MILLIS)) + } + return Entry( + key, + subkey, + slice.build() + ) + } + + private fun newCreateEntry( key: String, subkey: String, providerDisplayName: String, @@ -259,12 +289,11 @@ class CredentialManagerRepo( ) } - private fun testRequestInfo(): RequestInfo { + private fun testCreateRequestInfo(): RequestInfo { val data = Bundle() return RequestInfo.newCreateRequestInfo( Binder(), CreateCredentialRequest( - // TODO: use the jetpack type and utils once defined. TYPE_PUBLIC_KEY_CREDENTIAL, data ), @@ -272,4 +301,18 @@ class CredentialManagerRepo( "tribank.us" ) } + + private fun testGetRequestInfo(): RequestInfo { + val data = Bundle() + return RequestInfo.newGetRequestInfo( + Binder(), + GetCredentialRequest.Builder() + .addGetCredentialOption( + GetCredentialOption(TYPE_PUBLIC_KEY_CREDENTIAL, Bundle()) + ) + .build(), + /*isFirstUsage=*/false, + "tribank.us" + ) + } } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt index 3a8e975ea0c1c..5c79564234690 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt @@ -21,8 +21,11 @@ import android.credentials.ui.Entry import android.credentials.ui.GetCredentialProviderData import android.credentials.ui.CreateCredentialProviderData import com.android.credentialmanager.createflow.CreateOptionInfo -import com.android.credentialmanager.getflow.CredentialOptionInfo +import com.android.credentialmanager.getflow.ActionEntryInfo +import com.android.credentialmanager.getflow.AuthenticationEntryInfo +import com.android.credentialmanager.getflow.CredentialEntryInfo import com.android.credentialmanager.getflow.ProviderInfo +import com.android.credentialmanager.jetpack.provider.CredentialEntryUi import com.android.credentialmanager.jetpack.provider.SaveEntryUi /** Utility functions for converting CredentialManager data structures to or from UI formats. */ @@ -35,37 +38,65 @@ class GetFlowUtils { ): List { return providerDataList.map { ProviderInfo( + id = it.providerFlattenedComponentName, // TODO: replace to extract from the service data structure when available icon = context.getDrawable(R.drawable.ic_passkey)!!, - name = it.providerFlattenedComponentName, // TODO: get the service display name and icon from the component name. displayName = it.providerFlattenedComponentName, - credentialTypeIcon = context.getDrawable(R.drawable.ic_passkey)!!, - credentialOptions = toCredentialOptionInfoList(it.credentialEntries, context), + credentialEntryList = getCredentialOptionInfoList( + it.providerFlattenedComponentName, it.credentialEntries, context), + authenticationEntry = getAuthenticationEntry( + it.providerFlattenedComponentName, it.authenticationEntry, context), + actionEntryList = getActionEntryList( + it.providerFlattenedComponentName, it.actionChips, context), ) } } /* From service data structure to UI credential entry list representation. */ - private fun toCredentialOptionInfoList( + private fun getCredentialOptionInfoList( + providerId: String, credentialEntries: List, context: Context, - ): List { + ): List { return credentialEntries.map { val credentialEntryUi = CredentialEntryUi.fromSlice(it.slice) // Consider directly move the UI object into the class. - return@map CredentialOptionInfo( - // TODO: remove fallbacks - icon = credentialEntryUi.icon?.loadDrawable(context) - ?: context.getDrawable(R.drawable.ic_passkey)!!, + return@map CredentialEntryInfo( + providerId = providerId, entryKey = it.key, entrySubkey = it.subkey, - usageData = credentialEntryUi.usageData?.toString() ?: "Unknown usageData", + credentialType = credentialEntryUi.credentialType.toString(), + credentialTypeDisplayName = credentialEntryUi.credentialTypeDisplayName.toString(), + userName = credentialEntryUi.userName.toString(), + displayName = credentialEntryUi.userDisplayName?.toString(), + // TODO: proper fallback + icon = credentialEntryUi.entryIcon.loadDrawable(context) + ?: context.getDrawable(R.drawable.ic_passkey)!!, + lastUsedTimeMillis = credentialEntryUi.lastUsedTimeMillis, ) } } + + private fun getAuthenticationEntry( + providerId: String, + authEntry: Entry?, + context: Context, + ): AuthenticationEntryInfo? { + // TODO: implement + return null + } + + private fun getActionEntryList( + providerId: String, + actionEntries: List, + context: Context, + ): List { + // TODO: implement + return emptyList() + } } } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/ui/CancelButton.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/CancelButton.kt new file mode 100644 index 0000000000000..177d0e0288c7d --- /dev/null +++ b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/CancelButton.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 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.ui + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun CancelButton(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text(text = text) + } +} \ No newline at end of file diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/ui/ConfirmButton.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/ConfirmButton.kt new file mode 100644 index 0000000000000..b2b0bdcdf3ce6 --- /dev/null +++ b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/ConfirmButton.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 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.ui + +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun ConfirmButton(text: String, onClick: () -> Unit) { + FilledTonalButton(onClick = onClick) { + Text(text = text) + } +} \ No newline at end of file diff --git a/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt index 278b8352f8d12..2344847ff28e0 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt @@ -1,5 +1,6 @@ package com.android.credentialmanager.createflow +import android.credentials.Credential.TYPE_PASSWORD_CREDENTIAL import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Card import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,8 +36,9 @@ import com.android.credentialmanager.R import com.android.credentialmanager.common.material.ModalBottomSheetLayout import com.android.credentialmanager.common.material.ModalBottomSheetValue import com.android.credentialmanager.common.material.rememberModalBottomSheetState -import com.android.credentialmanager.jetpack.provider.CredentialEntryUi.Companion.TYPE_PASSWORD_CREDENTIAL -import com.android.credentialmanager.jetpack.provider.CredentialEntryUi.Companion.TYPE_PUBLIC_KEY_CREDENTIAL +import com.android.credentialmanager.common.ui.CancelButton +import com.android.credentialmanager.common.ui.ConfirmButton +import com.android.credentialmanager.jetpack.developer.PublicKeyCredential.Companion.TYPE_PUBLIC_KEY_CREDENTIAL @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -226,7 +227,7 @@ fun MoreOptionsSelectionCard( IconButton(onClick = onBackButtonSelected) { Icon( Icons.Filled.ArrowBack, - "backIcon") + stringResource(R.string.accessibility_back_arrow_button)) } } ) @@ -332,20 +333,6 @@ fun ProviderRow(providerInfo: ProviderInfo, onProviderSelected: (String) -> Unit ) } -@Composable -fun CancelButton(text: String, onClick: () -> Unit) { - TextButton(onClick = onClick) { - Text(text = text) - } -} - -@Composable -fun ConfirmButton(text: String, onClick: () -> Unit) { - FilledTonalButton(onClick = onClick) { - Text(text = text) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreationSelectionCard( diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt index 76350218ca0bd..23592c370c53e 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt @@ -16,22 +16,29 @@ package com.android.credentialmanager.getflow +import android.text.TextUtils + import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -45,7 +52,9 @@ import com.android.credentialmanager.R import com.android.credentialmanager.common.material.ModalBottomSheetLayout import com.android.credentialmanager.common.material.ModalBottomSheetValue import com.android.credentialmanager.common.material.rememberModalBottomSheetState -import com.android.credentialmanager.createflow.CancelButton +import com.android.credentialmanager.common.ui.CancelButton +import com.android.credentialmanager.jetpack.developer.PublicKeyCredential + @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,14 +70,18 @@ fun GetCredentialScreen( sheetContent = { val uiState = viewModel.uiState when (uiState.currentScreenState) { - GetScreenState.CREDENTIAL_SELECTION -> CredentialSelectionCard( + GetScreenState.PRIMARY_SELECTION -> PrimarySelectionCard( requestDisplayInfo = uiState.requestDisplayInfo, - providerInfo = uiState.selectedProvider!!, + sortedUserNameToCredentialEntryList = uiState.sortedUserNameToCredentialEntryList, + onEntrySelected = viewModel::onEntrySelected, onCancel = viewModel::onCancel, - onOptionSelected = viewModel::onCredentailSelected, - multiProvider = uiState.providers.size > 1, onMoreOptionSelected = viewModel::onMoreOptionSelected, ) + GetScreenState.ALL_SIGN_IN_OPTIONS -> AllSignInOptionCard( + sortedUserNameToCredentialEntryList = uiState.sortedUserNameToCredentialEntryList, + onEntrySelected = viewModel::onEntrySelected, + onBackButtonClicked = viewModel::onBackToPrimarySelectionScreen, + ) } }, scrimColor = Color.Transparent, @@ -81,42 +94,35 @@ fun GetCredentialScreen( } } +/** Draws the primary credential selection page. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CredentialSelectionCard( +fun PrimarySelectionCard( requestDisplayInfo: RequestDisplayInfo, - providerInfo: ProviderInfo, - onOptionSelected: (String, String) -> Unit, + sortedUserNameToCredentialEntryList: List, + onEntrySelected: (EntryInfo) -> Unit, onCancel: () -> Unit, - multiProvider: Boolean, onMoreOptionSelected: () -> Unit, ) { Card() { Column() { - Icon( - bitmap = providerInfo.credentialTypeIcon.toBitmap().asImageBitmap(), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.align(alignment = Alignment.CenterHorizontally).padding(top = 24.dp) - ) Text( - text = stringResource(R.string.choose_sign_in_title), + text = stringResource( + if (sortedUserNameToCredentialEntryList.size == 1) { + if (sortedUserNameToCredentialEntryList.first().sortedCredentialEntryList + .first().credentialType == PublicKeyCredential.TYPE_PUBLIC_KEY_CREDENTIAL + ) + R.string.get_dialog_title_use_passkey_for + else R.string.get_dialog_title_use_sign_in_for + } else R.string.get_dialog_title_choose_sign_in_for, + requestDisplayInfo.appDomainName + ), style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(all = 24.dp) - .align(alignment = Alignment.CenterHorizontally) - ) - Text( - text = requestDisplayInfo.appDomainName, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 28.dp) - ) - Divider( - thickness = 24.dp, - color = Color.Transparent + modifier = Modifier.padding(all = 24.dp).align(alignment = Alignment.CenterHorizontally) ) + Card( - shape = MaterialTheme.shapes.medium, + shape = MaterialTheme.shapes.large, modifier = Modifier .padding(horizontal = 24.dp) .align(alignment = Alignment.CenterHorizontally) @@ -124,15 +130,14 @@ fun CredentialSelectionCard( LazyColumn( verticalArrangement = Arrangement.spacedBy(2.dp) ) { - providerInfo.credentialOptions.forEach { - item { - CredentialOptionRow(credentialOptionInfo = it, onOptionSelected = onOptionSelected) - } + items(sortedUserNameToCredentialEntryList) { + CredentialEntryRow( + credentialEntryInfo = it.sortedCredentialEntryList.first(), + onEntrySelected = onEntrySelected + ) } - if (multiProvider) { - item { - MoreOptionRow(onSelect = onMoreOptionSelected) - } + item { + SignInAnotherWayRow(onSelect = onMoreOptionSelected) } } } @@ -155,32 +160,106 @@ fun CredentialSelectionCard( } } +/** Draws the secondary credential selection page, where all sign-in options are listed. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CredentialOptionRow( - credentialOptionInfo: CredentialOptionInfo, - onOptionSelected: (String, String) -> Unit, +fun AllSignInOptionCard( + sortedUserNameToCredentialEntryList: List, + onEntrySelected: (EntryInfo) -> Unit, + onBackButtonClicked: () -> Unit, +) { + Card() { + Column() { + TopAppBar( + colors = TopAppBarDefaults.smallTopAppBarColors( + containerColor = Color.Transparent, + ), + title = { + Text( + text = stringResource(R.string.get_dialog_title_sign_in_options), + style = MaterialTheme.typography.titleMedium + ) + }, + navigationIcon = { + IconButton(onClick = onBackButtonClicked) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.accessibility_back_arrow_button)) + } + }, + modifier = Modifier.padding(top = 12.dp) + ) + + Card( + shape = MaterialTheme.shapes.large, + modifier = Modifier + .padding(start = 24.dp, end = 24.dp, bottom = 24.dp) + .align(alignment = Alignment.CenterHorizontally) + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(sortedUserNameToCredentialEntryList) { item -> + PerUserNameCredentials( + perUserNameCredentialEntryList = item, + onEntrySelected = onEntrySelected + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PerUserNameCredentials( + perUserNameCredentialEntryList: PerUserNameCredentialEntryList, + onEntrySelected: (EntryInfo) -> Unit, +) { + Text( + text = stringResource( + R.string.get_dialog_heading_for_username, perUserNameCredentialEntryList.userName), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + perUserNameCredentialEntryList.sortedCredentialEntryList.forEach { + CredentialEntryRow(it, onEntrySelected) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CredentialEntryRow( + credentialEntryInfo: CredentialEntryInfo, + onEntrySelected: (EntryInfo) -> Unit, ) { SuggestionChip( modifier = Modifier.fillMaxWidth(), - onClick = {onOptionSelected(credentialOptionInfo.entryKey, credentialOptionInfo.entrySubkey)}, + onClick = {onEntrySelected(credentialEntryInfo)}, icon = { Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp), - bitmap = credentialOptionInfo.icon.toBitmap().asImageBitmap(), + bitmap = credentialEntryInfo.icon.toBitmap().asImageBitmap(), // TODO: add description. - contentDescription = "") + contentDescription = "") }, shape = MaterialTheme.shapes.large, label = { Column() { // TODO: fix the text values. Text( - text = credentialOptionInfo.entryKey, + text = credentialEntryInfo.userName, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(top = 16.dp) ) Text( - text = credentialOptionInfo.entrySubkey, + text = + if (TextUtils.isEmpty(credentialEntryInfo.displayName)) + credentialEntryInfo.credentialTypeDisplayName + else + credentialEntryInfo.credentialTypeDisplayName + + stringResource(R.string.get_dialog_sign_in_type_username_separator) + + credentialEntryInfo.displayName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 16.dp) ) @@ -191,15 +270,16 @@ fun CredentialOptionRow( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MoreOptionRow(onSelect: () -> Unit) { +fun SignInAnotherWayRow(onSelect: () -> Unit) { SuggestionChip( - modifier = Modifier.fillMaxWidth().height(52.dp), + modifier = Modifier.fillMaxWidth(), onClick = onSelect, shape = MaterialTheme.shapes.large, label = { Text( - text = stringResource(R.string.string_more_options), + text = stringResource(R.string.get_dialog_use_saved_passkey_for), style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(vertical = 16.dp) ) } ) diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt index 7b6c30aca89e4..f44927482fed5 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt @@ -26,12 +26,18 @@ import androidx.lifecycle.ViewModel import com.android.credentialmanager.CredentialManagerRepo import com.android.credentialmanager.common.DialogResult import com.android.credentialmanager.common.ResultState +import com.android.credentialmanager.jetpack.developer.PublicKeyCredential data class GetCredentialUiState( - val providers: List, + val providerInfoList: List, val currentScreenState: GetScreenState, val requestDisplayInfo: RequestDisplayInfo, - val selectedProvider: ProviderInfo? = null, + /** + * The credential entries grouped by userName, derived from all entries of the [providerInfoList]. + * Note that the list order matters to the display order. + */ + val sortedUserNameToCredentialEntryList: List = + createSortedUserNameToCredentialEntryList(providerInfoList), ) class GetCredentialViewModel( @@ -49,20 +55,28 @@ class GetCredentialViewModel( return dialogResult } - fun onCredentailSelected(entryKey: String, entrySubkey: String) { - Log.d("Account Selector", "credential selected: {key=$entryKey,subkey=$entrySubkey}") + fun onEntrySelected(entry: EntryInfo) { + Log.d("Account Selector", "credential selected:" + + " {provider=${entry.providerId}, key=${entry.entryKey}, subkey=${entry.entrySubkey}}") CredentialManagerRepo.getInstance().onOptionSelected( - uiState.selectedProvider!!.name, - entryKey, - entrySubkey - ) - dialogResult.value = DialogResult( - ResultState.COMPLETE, + entry.providerId, + entry.entryKey, + entry.entrySubkey ) + dialogResult.value = DialogResult(ResultState.COMPLETE) } fun onMoreOptionSelected() { Log.d("Account Selector", "More Option selected") + uiState = uiState.copy( + currentScreenState = GetScreenState.ALL_SIGN_IN_OPTIONS + ) + } + + fun onBackToPrimarySelectionScreen() { + uiState = uiState.copy( + currentScreenState = GetScreenState.PRIMARY_SELECTION + ) } fun onCancel() { @@ -70,3 +84,63 @@ class GetCredentialViewModel( dialogResult.value = DialogResult(ResultState.CANCELED) } } + +internal fun createSortedUserNameToCredentialEntryList( + providerInfoList: List +): List { + // Group by username + val userNameToEntryMap = mutableMapOf>() + providerInfoList.forEach { providerInfo -> + providerInfo.credentialEntryList.forEach { + userNameToEntryMap.compute( + it.userName + ) { + _, v -> + if (v == null) { + mutableListOf(it) + } else { + v.add(it) + v + } + } + } + } + val comparator = CredentialEntryInfoComparator() + // Sort per username + userNameToEntryMap.values.forEach { + it.sortWith(comparator) + } + // Transform to list of PerUserNameCredentialEntryLists and then sort across usernames + return userNameToEntryMap.map { + PerUserNameCredentialEntryList(it.key, it.value) + }.sortedWith( + compareBy(comparator) { it.sortedCredentialEntryList.first() } + ) +} + +internal class CredentialEntryInfoComparator : Comparator { + override fun compare(p0: CredentialEntryInfo, p1: CredentialEntryInfo): Int { + // First order by last used timestamp + if (p0.lastUsedTimeMillis != null && p1.lastUsedTimeMillis != null) { + if (p0.lastUsedTimeMillis < p1.lastUsedTimeMillis) { + return 1 + } else if (p0.lastUsedTimeMillis > p1.lastUsedTimeMillis) { + return -1 + } + } else if (p0.lastUsedTimeMillis != null && p0.lastUsedTimeMillis > 0) { + return -1 + } else if (p1.lastUsedTimeMillis != null && p1.lastUsedTimeMillis > 0) { + return 1 + } + + // Then prefer passkey type for its security benefits + if (p0.credentialType != p1.credentialType) { + if (PublicKeyCredential.TYPE_PUBLIC_KEY_CREDENTIAL == p0.credentialType) { + return -1 + } else if (PublicKeyCredential.TYPE_PUBLIC_KEY_CREDENTIAL == p1.credentialType) { + return 1 + } + } + return 0 + } +} \ No newline at end of file diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt index b427de61505fa..84009b1eb4415 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt @@ -19,34 +19,74 @@ package com.android.credentialmanager.getflow import android.graphics.drawable.Drawable data class ProviderInfo( + /** + * Unique id (component name) of this provider. + * Not for display purpose - [displayName] should be used for ui rendering. + */ + val id: String, val icon: Drawable, - val name: String, val displayName: String, - val credentialTypeIcon: Drawable, - val credentialOptions: List, - // TODO: Add the authenticationOption + val credentialEntryList: List, + val authenticationEntry: AuthenticationEntryInfo?, + val actionEntryList: List, + // TODO: add remote entry ) -open class EntryInfo ( +abstract class EntryInfo ( + /** Unique id combination of this entry. Not for display purpose. */ + val providerId: String, val entryKey: String, val entrySubkey: String, ) -class CredentialOptionInfo( +class CredentialEntryInfo( + providerId: String, entryKey: String, entrySubkey: String, + /** Type of this credential used for sorting. Not localized so must not be directly displayed. */ + val credentialType: String, + /** Localized type value of this credential used for display purpose. */ + val credentialTypeDisplayName: String, + val userName: String, + val displayName: String?, val icon: Drawable, - val usageData: String, -) : EntryInfo(entryKey, entrySubkey) + val lastUsedTimeMillis: Long?, +) : EntryInfo(providerId, entryKey, entrySubkey) + +// TODO: handle sub credential type values like password obfuscation. + +class AuthenticationEntryInfo( + providerId: String, + entryKey: String, + entrySubkey: String, +) : EntryInfo(providerId, entryKey, entrySubkey) + +class ActionEntryInfo( + providerId: String, + entryKey: String, + entrySubkey: String, + val title: String, + val subTitle: String?, +) : EntryInfo(providerId, entryKey, entrySubkey) data class RequestDisplayInfo( - val userName: String, - val displayName: String, - val type: String, val appDomainName: String, ) +/** + * @property userName the userName that groups all the entries in this list + * @property sortedCredentialEntryList the credential entries associated with the [userName] sorted + * by last used timestamps and then by credential types + */ +data class PerUserNameCredentialEntryList( + val userName: String, + val sortedCredentialEntryList: List, +) + /** The name of the current screen. */ enum class GetScreenState { - CREDENTIAL_SELECTION, + /** The primary credential selection page. */ + PRIMARY_SELECTION, + /** The secondary credential selection page, where all sign-in options are listed. */ + ALL_SIGN_IN_OPTIONS, } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/CredentialEntryUi.kt b/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/CredentialEntryUi.kt index 12ab436e1507a..dfbcae119fa07 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/CredentialEntryUi.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/CredentialEntryUi.kt @@ -17,6 +17,7 @@ package com.android.credentialmanager.jetpack.provider import android.app.slice.Slice +import android.credentials.ui.Entry import android.graphics.drawable.Icon /** @@ -24,23 +25,46 @@ import android.graphics.drawable.Icon * * TODO: move to jetpack. */ -abstract class CredentialEntryUi( - val credentialTypeIcon: Icon, - val profileIcon: Icon?, +class CredentialEntryUi( + val credentialType: CharSequence, + val credentialTypeDisplayName: CharSequence, + val userName: CharSequence, + val userDisplayName: CharSequence?, + val entryIcon: Icon, val lastUsedTimeMillis: Long?, val note: CharSequence?, ) { companion object { fun fromSlice(slice: Slice): CredentialEntryUi { - return when (slice.spec?.type) { - TYPE_PUBLIC_KEY_CREDENTIAL -> PasskeyCredentialEntryUi.fromSlice(slice) - TYPE_PASSWORD_CREDENTIAL -> PasswordCredentialEntryUi.fromSlice(slice) - else -> throw IllegalArgumentException("Unexpected type: ${slice.spec?.type}") - } - } + var credentialType = slice.spec!!.type + var credentialTypeDisplayName: CharSequence? = null + var userName: CharSequence? = null + var userDisplayName: CharSequence? = null + var entryIcon: Icon? = null + var lastUsedTimeMillis: Long? = null + var note: CharSequence? = null - const val TYPE_PUBLIC_KEY_CREDENTIAL: String = - "androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL" - const val TYPE_PASSWORD_CREDENTIAL: String = "androidx.credentials.TYPE_PASSWORD" + val items = slice.items + items.forEach { + if (it.hasHint(Entry.HINT_CREDENTIAL_TYPE_DISPLAY_NAME)) { + credentialTypeDisplayName = it.text + } else if (it.hasHint(Entry.HINT_USER_NAME)) { + userName = it.text + } else if (it.hasHint(Entry.HINT_PASSKEY_USER_DISPLAY_NAME)) { + userDisplayName = it.text + } else if (it.hasHint(Entry.HINT_PROFILE_ICON)) { + entryIcon = it.icon + } else if (it.hasHint(Entry.HINT_LAST_USED_TIME_MILLIS)) { + lastUsedTimeMillis = it.long + } else if (it.hasHint(Entry.HINT_NOTE)) { + note = it.text + } + } + + return CredentialEntryUi( + credentialType, credentialTypeDisplayName!!, userName!!, userDisplayName, entryIcon!!, + lastUsedTimeMillis, note, + ) + } } } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasskeyCredentialEntryUi.kt b/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasskeyCredentialEntryUi.kt deleted file mode 100644 index c5dbe66e8dbbf..0000000000000 --- a/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasskeyCredentialEntryUi.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (C) 2022 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.jetpack.provider - -import android.app.slice.Slice -import android.credentials.ui.Entry -import android.graphics.drawable.Icon - -class PasskeyCredentialEntryUi( - val userName: CharSequence, - val userDisplayName: CharSequence?, - credentialTypeIcon: Icon, - profileIcon: Icon?, - lastUsedTimeMillis: Long?, - note: CharSequence?, -) : CredentialEntryUi(credentialTypeIcon, profileIcon, lastUsedTimeMillis, note) { - companion object { - fun fromSlice(slice: Slice): CredentialEntryUi { - var userName: CharSequence? = null - var userDisplayName: CharSequence? = null - var credentialTypeIcon: Icon? = null - var profileIcon: Icon? = null - var lastUsedTimeMillis: Long? = null - var note: CharSequence? = null - - val items = slice.items - items.forEach { - if (it.hasHint(Entry.HINT_USER_NAME)) { - userName = it.text - } else if (it.hasHint(Entry.HINT_PASSKEY_USER_DISPLAY_NAME)) { - userDisplayName = it.text - } else if (it.hasHint(Entry.HINT_CREDENTIAL_TYPE_ICON)) { - credentialTypeIcon = it.icon - } else if (it.hasHint(Entry.HINT_PROFILE_ICON)) { - profileIcon = it.icon - } else if (it.hasHint(Entry.HINT_LAST_USED_TIME_MILLIS)) { - lastUsedTimeMillis = it.long - } else if (it.hasHint(Entry.HINT_NOTE)) { - note = it.text - } - } - // TODO: fail NPE more elegantly. - return PasskeyCredentialEntryUi( - userName!!, userDisplayName, credentialTypeIcon!!, - profileIcon, lastUsedTimeMillis, note, - ) - } - } -} diff --git a/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasswordCredentialEntryUi.kt b/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasswordCredentialEntryUi.kt deleted file mode 100644 index 5049503b32c1d..0000000000000 --- a/packages/CredentialManager/src/com/android/credentialmanager/jetpack/provider/PasswordCredentialEntryUi.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2022 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.jetpack.provider - -import android.app.slice.Slice -import android.credentials.ui.Entry -import android.graphics.drawable.Icon - -/** - * UI representation for a password credential entry used during the get credential flow. - * - * TODO: move to jetpack. - */ -class PasswordCredentialEntryUi( - val userName: CharSequence, - val password: CharSequence, - credentialTypeIcon: Icon, - profileIcon: Icon?, - lastUsedTimeMillis: Long?, - note: CharSequence?, -) : CredentialEntryUi(credentialTypeIcon, profileIcon, lastUsedTimeMillis, note) { - companion object { - fun fromSlice(slice: Slice): CredentialEntryUi { - var userName: CharSequence? = null - var password: CharSequence? = null - var credentialTypeIcon: Icon? = null - var profileIcon: Icon? = null - var lastUsedTimeMillis: Long? = null - var note: CharSequence? = null - - val items = slice.items - items.forEach { - if (it.hasHint(Entry.HINT_USER_NAME)) { - userName = it.text - } else if (it.hasHint(Entry.HINT_PASSWORD_VALUE)) { - password = it.text - } else if (it.hasHint(Entry.HINT_CREDENTIAL_TYPE_ICON)) { - credentialTypeIcon = it.icon - } else if (it.hasHint(Entry.HINT_PROFILE_ICON)) { - profileIcon = it.icon - } else if (it.hasHint(Entry.HINT_LAST_USED_TIME_MILLIS)) { - lastUsedTimeMillis = it.long - } else if (it.hasHint(Entry.HINT_NOTE)) { - note = it.text - } - } - // TODO: fail NPE more elegantly. - return PasswordCredentialEntryUi( - userName!!, password!!, credentialTypeIcon!!, - profileIcon, lastUsedTimeMillis, note, - ) - } - } -}