From 4d1312b2054f1914a7ff6510433d7462aec0fb75 Mon Sep 17 00:00:00 2001 From: Helen Qin Date: Fri, 11 Nov 2022 07:12:28 +0000 Subject: [PATCH] Support locked entries and action chips for the get flow. Recording: http://shortn/_M9TZSlyoy5 Bug: 253157237 Test: local deployment Change-Id: I1c63bc36f4e28b3640212f1cfac60c9908544a7d --- .../res/drawable/ic_face.xml | 30 ++++ .../res/drawable/ic_manage_accounts.xml | 30 ++++ .../CredentialManager/res/values/strings.xml | 8 +- .../CredentialManagerRepo.kt | 67 +++++++- .../credentialmanager/DataConverter.kt | 57 +++++-- .../getflow/GetCredentialComponents.kt | 151 ++++++++++++++++-- .../getflow/GetCredentialViewModel.kt | 33 ++-- .../credentialmanager/getflow/GetModel.kt | 14 ++ 8 files changed, 354 insertions(+), 36 deletions(-) create mode 100644 packages/CredentialManager/res/drawable/ic_face.xml create mode 100644 packages/CredentialManager/res/drawable/ic_manage_accounts.xml diff --git a/packages/CredentialManager/res/drawable/ic_face.xml b/packages/CredentialManager/res/drawable/ic_face.xml new file mode 100644 index 0000000000000..16fe14495e9d1 --- /dev/null +++ b/packages/CredentialManager/res/drawable/ic_face.xml @@ -0,0 +1,30 @@ + + + + + + + \ No newline at end of file diff --git a/packages/CredentialManager/res/drawable/ic_manage_accounts.xml b/packages/CredentialManager/res/drawable/ic_manage_accounts.xml new file mode 100644 index 0000000000000..adad2f105d55b --- /dev/null +++ b/packages/CredentialManager/res/drawable/ic_manage_accounts.xml @@ -0,0 +1,30 @@ + + + + + + + \ No newline at end of file diff --git a/packages/CredentialManager/res/values/strings.xml b/packages/CredentialManager/res/values/strings.xml index d47e3f49242c2..2f6d1b4bea02e 100644 --- a/packages/CredentialManager/res/values/strings.xml +++ b/packages/CredentialManager/res/values/strings.xml @@ -54,6 +54,12 @@ " - " Sign-in options - + For %1$s + + Locked password managers + + Tap to unlock + + Manage sign-ins \ No newline at end of file diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt index 0d5009a3bf800..8bd7cf03008b3 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt @@ -209,7 +209,7 @@ class CredentialManagerRepo( private fun testGetCredentialProviderList(): List { return listOf( - GetCredentialProviderData.Builder("com.google/com.google.CredentialManagerService") + GetCredentialProviderData.Builder("io.enpass.app") .setCredentialEntries( listOf( newGetEntry( @@ -225,8 +225,23 @@ class CredentialManagerRepo( "elisa.family@outlook.com", null, 100L ), ) + ).setAuthenticationEntry( + newAuthenticationEntry("key2", "subkey-1", TYPE_PASSWORD_CREDENTIAL) + ).setActionChips( + listOf( + newActionEntry( + "key3", "subkey-1", TYPE_PASSWORD_CREDENTIAL, + Icon.createWithResource(context, R.drawable.ic_manage_accounts), + "Open Google Password Manager", "elisa.beckett@gmail.com" + ), + newActionEntry( + "key3", "subkey-2", TYPE_PASSWORD_CREDENTIAL, + Icon.createWithResource(context, R.drawable.ic_manage_accounts), + "Open Google Password Manager", "beckett-family@gmail.com" + ), + ) ).build(), - GetCredentialProviderData.Builder("com.dashlane/com.dashlane.CredentialManagerService") + GetCredentialProviderData.Builder("com.dashlane") .setCredentialEntries( listOf( newGetEntry( @@ -238,10 +253,58 @@ class CredentialManagerRepo( "elisa.family@outlook.com", null, 100L ), ) + ).setAuthenticationEntry( + newAuthenticationEntry("key2", "subkey-1", TYPE_PASSWORD_CREDENTIAL) + ).setActionChips( + listOf( + newActionEntry( + "key3", "subkey-1", TYPE_PASSWORD_CREDENTIAL, + Icon.createWithResource(context, R.drawable.ic_face), + "Open Enpass" + ), + ) ).build(), ) } + private fun newActionEntry( + key: String, + subkey: String, + credentialType: String, + icon: Icon, + text: String, + subtext: String? = null, + ): Entry { + val slice = Slice.Builder( + Entry.CREDENTIAL_MANAGER_ENTRY_URI, SliceSpec(credentialType, 1) + ).addText( + text, null, listOf(Entry.HINT_ACTION_TITLE) + ).addIcon(icon, null, listOf(Entry.HINT_ACTION_ICON)) + if (subtext != null) { + slice.addText(subtext, null, listOf(Entry.HINT_ACTION_SUBTEXT)) + } + return Entry( + key, + subkey, + slice.build() + ) + } + + private fun newAuthenticationEntry( + key: String, + subkey: String, + credentialType: String, + ): Entry { + val slice = Slice.Builder( + Entry.CREDENTIAL_MANAGER_ENTRY_URI, SliceSpec(credentialType, 1) + ) + return Entry( + key, + subkey, + slice.build() + ) + } + private fun newGetEntry( key: String, subkey: String, diff --git a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt index 1e43d541ac131..33fb154f44f0d 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt @@ -22,12 +22,14 @@ import android.credentials.ui.Entry import android.credentials.ui.GetCredentialProviderData import android.credentials.ui.CreateCredentialProviderData import android.credentials.ui.DisabledProviderData +import android.graphics.drawable.Drawable import com.android.credentialmanager.createflow.CreateOptionInfo import com.android.credentialmanager.createflow.RemoteInfo 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.ActionUi import com.android.credentialmanager.jetpack.provider.CredentialEntryUi import com.android.credentialmanager.jetpack.provider.SaveEntryUi @@ -39,17 +41,27 @@ class GetFlowUtils { providerDataList: List, context: Context, ): List { + val packageManager = context.packageManager return providerDataList.map { + // TODO: get from the actual service info + val pkgInfo = packageManager + .getPackageInfo(it.providerFlattenedComponentName, + PackageManager.PackageInfoFlags.of(0)) + val providerDisplayName = pkgInfo.applicationInfo.loadLabel(packageManager).toString() + // TODO: decide what to do when failed to load a provider icon + val providerIcon = pkgInfo.applicationInfo.loadIcon(packageManager)!! ProviderInfo( id = it.providerFlattenedComponentName, - // TODO: replace to extract from the service data structure when available - icon = context.getDrawable(R.drawable.ic_passkey)!!, - // TODO: get the service display name and icon from the component name. - displayName = it.providerFlattenedComponentName, + // TODO: decide what to do when failed to load a provider icon + icon = providerIcon, + displayName = providerDisplayName, credentialEntryList = getCredentialOptionInfoList( it.providerFlattenedComponentName, it.credentialEntries, context), authenticationEntry = getAuthenticationEntry( - it.providerFlattenedComponentName, it.authenticationEntry, context), + it.providerFlattenedComponentName, + providerDisplayName, + providerIcon, + it.authenticationEntry), actionEntryList = getActionEntryList( it.providerFlattenedComponentName, it.actionChips, context), ) @@ -85,11 +97,22 @@ class GetFlowUtils { private fun getAuthenticationEntry( providerId: String, + providerDisplayName: String, + providerIcon: Drawable, authEntry: Entry?, - context: Context, ): AuthenticationEntryInfo? { - // TODO: implement - return null + // TODO: should also call fromSlice after getting the official jetpack code. + + if (authEntry == null) { + return null + } + return AuthenticationEntryInfo( + providerId = providerId, + entryKey = authEntry.key, + entrySubkey = authEntry.subkey, + title = providerDisplayName, + icon = providerIcon, + ) } private fun getActionEntryList( @@ -97,8 +120,19 @@ class GetFlowUtils { actionEntries: List, context: Context, ): List { - // TODO: implement - return emptyList() + return actionEntries.map { + val actionEntryUi = ActionUi.fromSlice(it.slice) + + return@map ActionEntryInfo( + providerId = providerId, + entryKey = it.key, + entrySubkey = it.subkey, + title = actionEntryUi.text.toString(), + // TODO: gracefully fail + icon = actionEntryUi.icon.loadDrawable(context)!!, + subTitle = actionEntryUi.subtext?.toString(), + ) + } } } } @@ -110,12 +144,14 @@ class CreateFlowUtils { providerDataList: List, context: Context, ): List { + // TODO: get from the actual service info val packageManager = context.packageManager return providerDataList.map { val pkgInfo = packageManager .getPackageInfo(it.providerFlattenedComponentName, PackageManager.PackageInfoFlags.of(0)) com.android.credentialmanager.createflow.EnabledProviderInfo( + // TODO: decide what to do when failed to load a provider icon icon = pkgInfo.applicationInfo.loadIcon(packageManager)!!, name = it.providerFlattenedComponentName, displayName = pkgInfo.applicationInfo.loadLabel(packageManager).toString(), @@ -130,6 +166,7 @@ class CreateFlowUtils { providerDataList: List, context: Context, ): List { + // TODO: get from the actual service info val packageManager = context.packageManager return providerDataList.map { val pkgInfo = packageManager diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt index 23592c370c53e..dcdd71a283a8c 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt @@ -55,8 +55,6 @@ import com.android.credentialmanager.common.material.rememberModalBottomSheetSta import com.android.credentialmanager.common.ui.CancelButton import com.android.credentialmanager.jetpack.developer.PublicKeyCredential - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun GetCredentialScreen( viewModel: GetCredentialViewModel, @@ -72,13 +70,14 @@ fun GetCredentialScreen( when (uiState.currentScreenState) { GetScreenState.PRIMARY_SELECTION -> PrimarySelectionCard( requestDisplayInfo = uiState.requestDisplayInfo, - sortedUserNameToCredentialEntryList = uiState.sortedUserNameToCredentialEntryList, + providerDisplayInfo = uiState.providerDisplayInfo, onEntrySelected = viewModel::onEntrySelected, onCancel = viewModel::onCancel, onMoreOptionSelected = viewModel::onMoreOptionSelected, ) GetScreenState.ALL_SIGN_IN_OPTIONS -> AllSignInOptionCard( - sortedUserNameToCredentialEntryList = uiState.sortedUserNameToCredentialEntryList, + providerInfoList = uiState.providerInfoList, + providerDisplayInfo = uiState.providerDisplayInfo, onEntrySelected = viewModel::onEntrySelected, onBackButtonClicked = viewModel::onBackToPrimarySelectionScreen, ) @@ -95,15 +94,16 @@ fun GetCredentialScreen( } /** Draws the primary credential selection page. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PrimarySelectionCard( requestDisplayInfo: RequestDisplayInfo, - sortedUserNameToCredentialEntryList: List, + providerDisplayInfo: ProviderDisplayInfo, onEntrySelected: (EntryInfo) -> Unit, onCancel: () -> Unit, onMoreOptionSelected: () -> Unit, ) { + val sortedUserNameToCredentialEntryList = providerDisplayInfo.sortedUserNameToCredentialEntryList + val authenticationEntryList = providerDisplayInfo.authenticationEntryList Card() { Column() { Text( @@ -133,7 +133,13 @@ fun PrimarySelectionCard( items(sortedUserNameToCredentialEntryList) { CredentialEntryRow( credentialEntryInfo = it.sortedCredentialEntryList.first(), - onEntrySelected = onEntrySelected + onEntrySelected = onEntrySelected, + ) + } + items(authenticationEntryList) { + AuthenticationEntryRow( + authenticationEntryInfo = it, + onEntrySelected = onEntrySelected, ) } item { @@ -164,10 +170,13 @@ fun PrimarySelectionCard( @OptIn(ExperimentalMaterial3Api::class) @Composable fun AllSignInOptionCard( - sortedUserNameToCredentialEntryList: List, + providerInfoList: List, + providerDisplayInfo: ProviderDisplayInfo, onEntrySelected: (EntryInfo) -> Unit, onBackButtonClicked: () -> Unit, ) { + val sortedUserNameToCredentialEntryList = providerDisplayInfo.sortedUserNameToCredentialEntryList + val authenticationEntryList = providerDisplayInfo.authenticationEntryList Card() { Column() { TopAppBar( @@ -199,19 +208,74 @@ fun AllSignInOptionCard( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // For username items(sortedUserNameToCredentialEntryList) { item -> PerUserNameCredentials( perUserNameCredentialEntryList = item, - onEntrySelected = onEntrySelected + onEntrySelected = onEntrySelected, ) } + // Locked password manager + item { + if (!authenticationEntryList.isEmpty()) { + LockedCredentials( + authenticationEntryList = authenticationEntryList, + onEntrySelected = onEntrySelected, + ) + } + } + // TODO: Remote action + // Manage sign-ins + item { + ActionChips(providerInfoList = providerInfoList, onEntrySelected = onEntrySelected) + } } } } } } -@OptIn(ExperimentalMaterial3Api::class) +// TODO: create separate rows for primary and secondary pages. +// TODO: reuse rows and columns across types. + +@Composable +fun ActionChips( + providerInfoList: List, + onEntrySelected: (EntryInfo) -> Unit, +) { + val actionChips = providerInfoList.flatMap { it.actionEntryList } + if (actionChips.isEmpty()) { + return + } + + Text( + text = stringResource(R.string.get_dialog_heading_manage_sign_ins), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + // TODO: tweak padding. + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + actionChips.forEach { + ActionEntryRow(it, onEntrySelected) + } + } +} + +@Composable +fun LockedCredentials( + authenticationEntryList: List, + onEntrySelected: (EntryInfo) -> Unit, +) { + Text( + text = stringResource(R.string.get_dialog_heading_locked_password_managers), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + authenticationEntryList.forEach { + AuthenticationEntryRow(it, onEntrySelected) + } +} + @Composable fun PerUserNameCredentials( perUserNameCredentialEntryList: PerUserNameCredentialEntryList, @@ -268,6 +332,73 @@ fun CredentialEntryRow( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AuthenticationEntryRow( + authenticationEntryInfo: AuthenticationEntryInfo, + onEntrySelected: (EntryInfo) -> Unit, +) { + SuggestionChip( + modifier = Modifier.fillMaxWidth(), + onClick = {onEntrySelected(authenticationEntryInfo)}, + icon = { + Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp), + bitmap = authenticationEntryInfo.icon.toBitmap().asImageBitmap(), + // TODO: add description. + contentDescription = "") + }, + shape = MaterialTheme.shapes.large, + label = { + Column() { + // TODO: fix the text values. + Text( + text = authenticationEntryInfo.title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + text = stringResource(R.string.locked_credential_entry_label_subtext), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionEntryRow( + actionEntryInfo: ActionEntryInfo, + onEntrySelected: (EntryInfo) -> Unit, +) { + SuggestionChip( + modifier = Modifier.fillMaxWidth(), + onClick = { onEntrySelected(actionEntryInfo) }, + icon = { + Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp), + bitmap = actionEntryInfo.icon.toBitmap().asImageBitmap(), + // TODO: add description. + contentDescription = "") + }, + shape = MaterialTheme.shapes.large, + label = { + Column() { + Text( + text = actionEntryInfo.title, + style = MaterialTheme.typography.titleLarge, + ) + if (actionEntryInfo.subTitle != null) { + Text( + text = actionEntryInfo.subTitle, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SignInAnotherWayRow(onSelect: () -> Unit) { diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt index f44927482fed5..f78456aab332c 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialViewModel.kt @@ -32,12 +32,7 @@ data class GetCredentialUiState( val providerInfoList: List, val currentScreenState: GetScreenState, val requestDisplayInfo: RequestDisplayInfo, - /** - * 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), + val providerDisplayInfo: ProviderDisplayInfo = toProviderDisplayInfo(providerInfoList), ) class GetCredentialViewModel( @@ -85,14 +80,19 @@ class GetCredentialViewModel( } } -internal fun createSortedUserNameToCredentialEntryList( +private fun toProviderDisplayInfo( providerInfoList: List -): List { - // Group by username - val userNameToEntryMap = mutableMapOf>() +): ProviderDisplayInfo { + + val userNameToCredentialEntryMap = mutableMapOf>() + val authenticationEntryList = mutableListOf() providerInfoList.forEach { providerInfo -> + if (providerInfo.authenticationEntry != null) { + authenticationEntryList.add(providerInfo.authenticationEntry) + } + providerInfo.credentialEntryList.forEach { - userNameToEntryMap.compute( + userNameToCredentialEntryMap.compute( it.userName ) { _, v -> @@ -105,17 +105,24 @@ internal fun createSortedUserNameToCredentialEntryList( } } } + + // Compose sortedUserNameToCredentialEntryList val comparator = CredentialEntryInfoComparator() // Sort per username - userNameToEntryMap.values.forEach { + userNameToCredentialEntryMap.values.forEach { it.sortWith(comparator) } // Transform to list of PerUserNameCredentialEntryLists and then sort across usernames - return userNameToEntryMap.map { + val sortedUserNameToCredentialEntryList = userNameToCredentialEntryMap.map { PerUserNameCredentialEntryList(it.key, it.value) }.sortedWith( compareBy(comparator) { it.sortedCredentialEntryList.first() } ) + + return ProviderDisplayInfo( + sortedUserNameToCredentialEntryList = sortedUserNameToCredentialEntryList, + authenticationEntryList = authenticationEntryList, + ) } internal class CredentialEntryInfoComparator : Comparator { diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt index 84009b1eb4415..c1d9ea9b91882 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetModel.kt @@ -32,6 +32,17 @@ data class ProviderInfo( // TODO: add remote entry ) +/** Display-centric data structure derived from the [ProviderInfo]. This abstraction is not grouping + * by the provider id but instead focuses on structures convenient for display purposes. */ +data class ProviderDisplayInfo( + /** + * 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, + val authenticationEntryList: List, +) + abstract class EntryInfo ( /** Unique id combination of this entry. Not for display purpose. */ val providerId: String, @@ -59,6 +70,8 @@ class AuthenticationEntryInfo( providerId: String, entryKey: String, entrySubkey: String, + val title: String, + val icon: Drawable, ) : EntryInfo(providerId, entryKey, entrySubkey) class ActionEntryInfo( @@ -66,6 +79,7 @@ class ActionEntryInfo( entryKey: String, entrySubkey: String, val title: String, + val icon: Drawable, val subTitle: String?, ) : EntryInfo(providerId, entryKey, entrySubkey)