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
This commit is contained in:
Helen Qin
2022-11-08 06:16:05 +00:00
parent 454d80ad81
commit b4e2a7040d
14 changed files with 509 additions and 341 deletions

View File

@@ -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";

View File

@@ -29,4 +29,26 @@
<string name="createOptionInfo_icon_description">CreateOptionInfo credentialType icon</string>
<!-- Spoken content description of an element which will close the sheet when clicked. -->
<string name="close_sheet">"Close sheet"</string>
<!-- Spoken content description of the back arrow button. -->
<string name="accessibility_back_arrow_button">"Go back to the previous page"</string>
<!-- Strings for the get flow. -->
<!-- This appears as the title of the modal bottom sheet asking for user confirmation to use the single previously saved passkey to sign in to the app. [CHAR LIMIT=200] -->
<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 dialog asking for user confirmation to use the single previously saved credential to sign in to the app. [CHAR LIMIT=200] -->
<string name="get_dialog_title_use_sign_in_for">Use your saved 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 to make a choice from various previously saved credentials to sign in to the app. [CHAR LIMIT=200] -->
<string name="get_dialog_title_choose_sign_in_for">Choose a saved sign-in for <xliff:g id="app_name" example="YouTube">%1$s</xliff:g></string>
<!-- Appears as an option row for viewing all the available sign-in options. [CHAR LIMIT=80] -->
<string name="get_dialog_use_saved_passkey_for">Sign in another way</string>
<!-- Button label to close the dialog when the user does not want to use any sign-in. [CHAR LIMIT=40] -->
<string name="get_dialog_button_label_no_thanks">No thanks</string>
<!-- Button label to continue with the selected sign-in. [CHAR LIMIT=40] -->
<string name="get_dialog_button_label_continue">Continue</string>
<!-- Separator for sign-in type and username in a sign-in entry. -->
<string name="get_dialog_sign_in_type_username_separator" translatable="false">" - "</string>
<!-- Modal bottom sheet title for displaying all the available sign-in options. [CHAR LIMIT=80] -->
<string name="get_dialog_title_sign_in_options">Sign-in options</string>
<!-- Column heading for displaying sign-ins for a specific username. [CHAR LIMIT=20] -->
<string name="get_dialog_heading_for_username">For <xliff:g id="username" example="becket@gmail.com">%1$s</xliff:g></string>
</resources>

View File

@@ -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)
}
}
}

View File

@@ -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<GetCredentialProviderData>, context)
// TODO: handle runtime cast error
providerList as List<GetCredentialProviderData>, 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<Entry>(
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<Entry>(
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<Entry>(
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<Entry>(
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<Entry>(
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<Entry>(
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"
)
}
}

View File

@@ -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<ProviderInfo> {
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<Entry>,
context: Context,
): List<CredentialOptionInfo> {
): List<CredentialEntryInfo> {
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<Entry>,
context: Context,
): List<ActionEntryInfo> {
// TODO: implement
return emptyList()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -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<PerUserNameCredentialEntryList>,
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<PerUserNameCredentialEntryList>,
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)
)
}
)

View File

@@ -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<ProviderInfo>,
val providerInfoList: List<ProviderInfo>,
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<PerUserNameCredentialEntryList> =
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<ProviderInfo>
): List<PerUserNameCredentialEntryList> {
// Group by username
val userNameToEntryMap = mutableMapOf<String, MutableList<CredentialEntryInfo>>()
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<CredentialEntryInfo> {
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
}
}

View File

@@ -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<CredentialOptionInfo>,
// TODO: Add the authenticationOption
val credentialEntryList: List<CredentialEntryInfo>,
val authenticationEntry: AuthenticationEntryInfo?,
val actionEntryList: List<ActionEntryInfo>,
// 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<CredentialEntryInfo>,
)
/** 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,
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
}
}