blob: f8c89bbf4c4686f57f1e42d12e3239978e4023ef [file] [log] [blame]
@file:OptIn(ExperimentalMaterial3Api::class)
package com.android.credentialmanager.createflow
import android.credentials.Credential.TYPE_PASSWORD_CREDENTIAL
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.result.ActivityResult
import androidx.activity.result.IntentSenderRequest
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.padding
import androidx.compose.foundation.layout.size
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.material.icons.outlined.NewReleases
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
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.common.ui.CancelButton
import com.android.credentialmanager.common.ui.ConfirmButton
import com.android.credentialmanager.common.ui.Entry
import com.android.credentialmanager.ui.theme.EntryShape
import com.android.credentialmanager.ui.theme.LocalAndroidColorScheme
import com.android.credentialmanager.jetpack.developer.PublicKeyCredential.Companion.TYPE_PUBLIC_KEY_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateCredentialScreen(
viewModel: CreateCredentialViewModel,
providerActivityLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>
) {
val selectEntryCallback: (EntryInfo) -> Unit = {
viewModel.onEntrySelected(it, providerActivityLauncher)
}
val confirmEntryCallback: () -> Unit = {
viewModel.onConfirmCreationSelected(providerActivityLauncher)
}
val state = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded,
skipHalfExpanded = true
)
ModalBottomSheetLayout(
sheetState = state,
sheetContent = {
val uiState = viewModel.uiState
when (uiState.currentScreenState) {
CreateScreenState.PASSKEY_INTRO -> ConfirmationCard(
onConfirm = viewModel::onConfirmIntro,
onCancel = viewModel::onCancel,
)
CreateScreenState.PROVIDER_SELECTION -> ProviderSelectionCard(
requestDisplayInfo = uiState.requestDisplayInfo,
enabledProviderList = uiState.enabledProviders,
disabledProviderList = uiState.disabledProviders,
onCancel = viewModel::onCancel,
onOptionSelected = viewModel::onEntrySelectedFromFirstUseScreen,
onDisabledPasswordManagerSelected = viewModel::onDisabledPasswordManagerSelected,
onRemoteEntrySelected = selectEntryCallback,
)
CreateScreenState.CREATION_OPTION_SELECTION -> CreationSelectionCard(
requestDisplayInfo = uiState.requestDisplayInfo,
enabledProviderList = uiState.enabledProviders,
providerInfo = uiState.activeEntry?.activeProvider!!,
createOptionInfo = uiState.activeEntry.activeEntryInfo as CreateOptionInfo,
showActiveEntryOnly = uiState.showActiveEntryOnly,
onOptionSelected = selectEntryCallback,
onConfirm = confirmEntryCallback,
onCancel = viewModel::onCancel,
onMoreOptionsSelected = viewModel::onMoreOptionsSelected,
)
CreateScreenState.MORE_OPTIONS_SELECTION -> MoreOptionsSelectionCard(
requestDisplayInfo = uiState.requestDisplayInfo,
enabledProviderList = uiState.enabledProviders,
disabledProviderList = uiState.disabledProviders,
onBackButtonSelected = viewModel::onBackButtonSelected,
onOptionSelected = viewModel::onEntrySelectedFromMoreOptionScreen,
onDisabledPasswordManagerSelected = viewModel::onDisabledPasswordManagerSelected,
onRemoteEntrySelected = selectEntryCallback,
)
CreateScreenState.MORE_OPTIONS_ROW_INTRO -> MoreOptionsRowIntroCard(
providerInfo = uiState.activeEntry?.activeProvider!!,
onDefaultOrNotSelected = viewModel::onDefaultOrNotSelected
)
}
},
scrimColor = MaterialTheme.colorScheme.scrim,
sheetShape = EntryShape.TopRoundedCorner,
) {}
LaunchedEffect(state.currentValue) {
if (state.currentValue == ModalBottomSheetValue.Hidden) {
viewModel.onCancel()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmationCard(
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
Card() {
Column() {
Icon(
painter = painterResource(R.drawable.ic_passkey),
contentDescription = null,
tint = LocalAndroidColorScheme.current.colorAccentPrimaryVariant,
modifier = Modifier.align(alignment = Alignment.CenterHorizontally)
.padding(top = 24.dp, bottom = 12.dp)
)
Text(
text = stringResource(R.string.passkey_creation_intro_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
Divider(
thickness = 16.dp,
color = Color.Transparent
)
Text(
text = stringResource(R.string.passkey_creation_intro_body),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 28.dp)
)
Divider(
thickness = 32.dp,
color = Color.Transparent
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
) {
CancelButton(
stringResource(R.string.string_cancel),
onClick = onCancel
)
ConfirmButton(
stringResource(R.string.string_continue),
onClick = onConfirm
)
}
Divider(
thickness = 18.dp,
color = Color.Transparent,
modifier = Modifier.padding(bottom = 18.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProviderSelectionCard(
requestDisplayInfo: RequestDisplayInfo,
enabledProviderList: List<EnabledProviderInfo>,
disabledProviderList: List<DisabledProviderInfo>?,
onOptionSelected: (ActiveEntry) -> Unit,
onDisabledPasswordManagerSelected: () -> Unit,
onCancel: () -> Unit,
onRemoteEntrySelected: (EntryInfo) -> Unit,
) {
Card() {
Column() {
Icon(
bitmap = requestDisplayInfo.typeIcon.toBitmap().asImageBitmap(),
contentDescription = null,
tint = LocalAndroidColorScheme.current.colorAccentPrimaryVariant,
modifier = Modifier.align(alignment = Alignment.CenterHorizontally)
.padding(top = 24.dp, bottom = 16.dp).size(32.dp)
)
Text(
text = stringResource(
R.string.choose_provider_title,
when (requestDisplayInfo.type) {
TYPE_PUBLIC_KEY_CREDENTIAL -> stringResource(R.string.create_your_passkey)
TYPE_PASSWORD_CREDENTIAL -> stringResource(R.string.save_your_password)
else -> stringResource(R.string.save_your_sign_in_info)
},
),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
Divider(
thickness = 16.dp,
color = Color.Transparent
)
Text(
text = stringResource(R.string.choose_provider_body),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 28.dp)
)
Divider(
thickness = 18.dp,
color = Color.Transparent
)
Card(
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally),
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
enabledProviderList.forEach { enabledProviderInfo ->
enabledProviderInfo.createOptions.forEach { createOptionInfo ->
item {
MoreOptionsInfoRow(
providerInfo = enabledProviderInfo,
createOptionInfo = createOptionInfo,
onOptionSelected = {
onOptionSelected(ActiveEntry(enabledProviderInfo, createOptionInfo))
})
}
}
}
if (disabledProviderList != null) {
item {
MoreOptionsDisabledProvidersRow(
disabledProviders = disabledProviderList,
onDisabledPasswordManagerSelected = onDisabledPasswordManagerSelected,
)
}
}
}
}
// TODO: handle the error situation that if multiple remoteInfos exists
enabledProviderList.forEach { enabledProvider ->
if (enabledProvider.remoteEntry != null) {
TextButton(
onClick = {
onRemoteEntrySelected(enabledProvider.remoteEntry!!) },
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally)
) {
Text(
text = stringResource(R.string.string_save_to_another_device),
textAlign = TextAlign.Center,
)
}
}
}
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
) {
CancelButton(stringResource(R.string.string_cancel), onCancel)
}
Divider(
thickness = 18.dp,
color = Color.Transparent,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoreOptionsSelectionCard(
requestDisplayInfo: RequestDisplayInfo,
enabledProviderList: List<EnabledProviderInfo>,
disabledProviderList: List<DisabledProviderInfo>?,
onBackButtonSelected: () -> Unit,
onOptionSelected: (ActiveEntry) -> Unit,
onDisabledPasswordManagerSelected: () -> Unit,
onRemoteEntrySelected: (EntryInfo) -> Unit,
) {
Card() {
Column() {
TopAppBar(
title = {
Text(
text = when (requestDisplayInfo.type) {
TYPE_PUBLIC_KEY_CREDENTIAL -> stringResource(R.string.create_passkey_in)
TYPE_PASSWORD_CREDENTIAL -> stringResource(R.string.save_password_to)
else -> stringResource(R.string.save_sign_in_to)
},
style = MaterialTheme.typography.titleMedium
)
},
navigationIcon = {
IconButton(onClick = onBackButtonSelected) {
Icon(
Icons.Filled.ArrowBack,
stringResource(R.string.accessibility_back_arrow_button))
}
},
colors = TopAppBarDefaults.smallTopAppBarColors
(containerColor = Color.Transparent),
)
Divider(
thickness = 8.dp,
color = Color.Transparent
)
Card(
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally)
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
enabledProviderList.forEach { enabledProviderInfo ->
enabledProviderInfo.createOptions.forEach { createOptionInfo ->
item {
MoreOptionsInfoRow(
providerInfo = enabledProviderInfo,
createOptionInfo = createOptionInfo,
onOptionSelected = {
onOptionSelected(ActiveEntry(enabledProviderInfo, createOptionInfo))
})
}
}
}
if (disabledProviderList != null) {
item {
MoreOptionsDisabledProvidersRow(
disabledProviders = disabledProviderList,
onDisabledPasswordManagerSelected = onDisabledPasswordManagerSelected,
)
}
}
// TODO: handle the error situation that if multiple remoteInfos exists
enabledProviderList.forEach {
if (it.remoteEntry != null) {
item {
RemoteEntryRow(
remoteInfo = it.remoteEntry!!,
onRemoteEntrySelected = onRemoteEntrySelected,
)
}
}
}
}
}
Divider(
thickness = 18.dp,
color = Color.Transparent,
modifier = Modifier.padding(bottom = 40.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoreOptionsRowIntroCard(
providerInfo: EnabledProviderInfo,
onDefaultOrNotSelected: () -> Unit,
) {
Card() {
Column() {
Icon(
Icons.Outlined.NewReleases,
contentDescription = null,
modifier = Modifier.align(alignment = Alignment.CenterHorizontally).padding(all = 24.dp)
)
Text(
text = stringResource(R.string.use_provider_for_all_title, providerInfo.displayName),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.confirm_default_or_use_once_description),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(all = 24.dp).align(alignment = Alignment.CenterHorizontally)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
) {
CancelButton(
stringResource(R.string.use_once),
onClick = onDefaultOrNotSelected
)
ConfirmButton(
stringResource(R.string.set_as_default),
onClick = onDefaultOrNotSelected
)
}
Divider(
thickness = 18.dp,
color = Color.Transparent,
modifier = Modifier.padding(bottom = 40.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreationSelectionCard(
requestDisplayInfo: RequestDisplayInfo,
enabledProviderList: List<EnabledProviderInfo>,
providerInfo: EnabledProviderInfo,
createOptionInfo: CreateOptionInfo,
showActiveEntryOnly: Boolean,
onOptionSelected: (EntryInfo) -> Unit,
onConfirm: () -> Unit,
onCancel: () -> Unit,
onMoreOptionsSelected: () -> Unit,
) {
Card() {
Column() {
Icon(
bitmap = providerInfo.icon.toBitmap().asImageBitmap(),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.align(alignment = Alignment.CenterHorizontally)
.padding(all = 24.dp).size(32.dp)
)
Text(
text = when (requestDisplayInfo.type) {
TYPE_PUBLIC_KEY_CREDENTIAL -> stringResource(R.string.choose_create_option_passkey_title,
providerInfo.displayName)
TYPE_PASSWORD_CREDENTIAL -> stringResource(R.string.choose_create_option_password_title,
providerInfo.displayName)
else -> stringResource(R.string.choose_create_option_sign_in_title,
providerInfo.displayName)
},
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
)
if (createOptionInfo.userProviderDisplayName != null) {
Text(
text = stringResource(
R.string.choose_create_option_description,
requestDisplayInfo.appDomainName,
when (requestDisplayInfo.type) {
TYPE_PUBLIC_KEY_CREDENTIAL -> stringResource(R.string.passkey)
TYPE_PASSWORD_CREDENTIAL -> stringResource(R.string.password)
else -> stringResource(R.string.sign_ins)
},
providerInfo.displayName,
createOptionInfo.userProviderDisplayName
),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(all = 24.dp).align(alignment = Alignment.CenterHorizontally)
)
}
Card(
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally),
) {
PrimaryCreateOptionRow(
requestDisplayInfo = requestDisplayInfo,
createOptionInfo = createOptionInfo,
onOptionSelected = onOptionSelected
)
}
if (!showActiveEntryOnly) {
var createOptionsSize = 0
enabledProviderList.forEach{
enabledProvider -> createOptionsSize += enabledProvider.createOptions.size}
if (createOptionsSize > 1) {
TextButton(
onClick = onMoreOptionsSelected,
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally)){
Text(
text =
when (requestDisplayInfo.type) {
TYPE_PUBLIC_KEY_CREDENTIAL ->
stringResource(R.string.string_create_in_another_place)
else -> stringResource(R.string.string_save_to_another_place)},
textAlign = TextAlign.Center,
)
}
} else if (
requestDisplayInfo.type == TYPE_PUBLIC_KEY_CREDENTIAL
) {
// TODO: handle the error situation that if multiple remoteInfos exists
enabledProviderList.forEach { enabledProvider ->
if (enabledProvider.remoteEntry != null) {
TextButton(
onClick = {
onOptionSelected(enabledProvider.remoteEntry!!) },
modifier = Modifier
.padding(horizontal = 24.dp)
.align(alignment = Alignment.CenterHorizontally)
) {
Text(
text = stringResource(R.string.string_use_another_device),
textAlign = TextAlign.Center,
)
}
}
}
}
}
Divider(
thickness = 24.dp,
color = Color.Transparent
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp)
) {
CancelButton(
stringResource(R.string.string_cancel),
onClick = onCancel
)
ConfirmButton(
stringResource(R.string.string_continue),
onClick = onConfirm
)
}
Divider(
thickness = 18.dp,
color = Color.Transparent,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrimaryCreateOptionRow(
requestDisplayInfo: RequestDisplayInfo,
createOptionInfo: CreateOptionInfo,
onOptionSelected: (EntryInfo) -> Unit
) {
Entry(
onClick = {onOptionSelected(createOptionInfo)},
icon = {
Icon(
bitmap = createOptionInfo.profileIcon.toBitmap().asImageBitmap(),
contentDescription = null,
tint = LocalAndroidColorScheme.current.colorAccentPrimaryVariant,
modifier = Modifier.padding(start = 18.dp).size(32.dp)
)
},
label = {
Column() {
// TODO: Add the function to hide/view password when the type is create password
if (requestDisplayInfo.type == TYPE_PUBLIC_KEY_CREDENTIAL ||
requestDisplayInfo.type == TYPE_PASSWORD_CREDENTIAL) {
Text(
text = requestDisplayInfo.title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = requestDisplayInfo.subtitle,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
} else {
Text(
text = requestDisplayInfo.title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 16.dp, bottom = 16.dp)
)
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoreOptionsInfoRow(
providerInfo: EnabledProviderInfo,
createOptionInfo: CreateOptionInfo,
onOptionSelected: () -> Unit
) {
Entry(
onClick = onOptionSelected,
icon = {
Image(modifier = Modifier.size(32.dp).padding(start = 16.dp),
bitmap = providerInfo.icon.toBitmap().asImageBitmap(),
contentDescription = null)
},
label = {
Column() {
Text(
text = providerInfo.displayName,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 16.dp, start = 16.dp)
)
if (createOptionInfo.userProviderDisplayName != null) {
Text(
text = createOptionInfo.userProviderDisplayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 16.dp)
)
}
if (createOptionInfo.passwordCount != null && createOptionInfo.passkeyCount != null) {
Text(
text =
stringResource(
R.string.more_options_usage_passwords_passkeys,
createOptionInfo.passwordCount,
createOptionInfo.passkeyCount
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp, start = 16.dp)
)
} else if (createOptionInfo.passwordCount != null) {
Text(
text =
stringResource(
R.string.more_options_usage_passwords,
createOptionInfo.passwordCount
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp, start = 16.dp)
)
} else if (createOptionInfo.passkeyCount != null) {
Text(
text =
stringResource(
R.string.more_options_usage_passkeys,
createOptionInfo.passkeyCount
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp, start = 16.dp)
)
} else if (createOptionInfo.totalCredentialCount != null) {
// TODO: Handle the case when there is total count
// but no passwords and passkeys after design is set
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoreOptionsDisabledProvidersRow(
disabledProviders: List<ProviderInfo>,
onDisabledPasswordManagerSelected: () -> Unit,
) {
Entry(
onClick = onDisabledPasswordManagerSelected,
icon = {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.padding(start = 16.dp)
)
},
label = {
Column() {
Text(
text = stringResource(R.string.other_password_manager),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 16.dp, start = 16.dp)
)
// TODO: Update the subtitle once design is confirmed
Text(
text = disabledProviders.joinToString(separator = ", "){ it.displayName },
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp, start = 16.dp)
)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoteEntryRow(
remoteInfo: RemoteInfo,
onRemoteEntrySelected: (RemoteInfo) -> Unit,
) {
Entry(
onClick = {onRemoteEntrySelected(remoteInfo)},
icon = {
Icon(
painter = painterResource(R.drawable.ic_other_devices),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.padding(start = 18.dp)
)
},
label = {
Column() {
Text(
text = stringResource(R.string.another_device),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 16.dp, top = 18.dp, bottom = 18.dp)
.align(alignment = Alignment.CenterHorizontally)
)
}
}
)
}