1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.credentialmanager.createflow 18 19 import android.text.TextUtils 20 import androidx.activity.compose.ManagedActivityResultLauncher 21 import androidx.activity.result.ActivityResult 22 import androidx.activity.result.IntentSenderRequest 23 import androidx.compose.foundation.isSystemInDarkTheme 24 import androidx.compose.foundation.Image 25 import androidx.compose.foundation.layout.Arrangement 26 import androidx.compose.foundation.layout.Column 27 import androidx.compose.foundation.layout.Row 28 import androidx.compose.foundation.layout.fillMaxWidth 29 import androidx.compose.foundation.layout.padding 30 import androidx.compose.foundation.layout.size 31 import androidx.compose.foundation.layout.wrapContentHeight 32 import androidx.compose.material3.Divider 33 import androidx.compose.material.icons.Icons 34 import androidx.compose.material.icons.outlined.NewReleases 35 import androidx.compose.material.icons.filled.Add 36 import androidx.compose.material.icons.outlined.QrCodeScanner 37 import androidx.compose.runtime.Composable 38 import androidx.compose.runtime.LaunchedEffect 39 import androidx.compose.runtime.mutableStateOf 40 import androidx.compose.runtime.remember 41 import androidx.compose.ui.Modifier 42 import androidx.compose.ui.graphics.Color 43 import androidx.compose.ui.graphics.asImageBitmap 44 import androidx.compose.ui.res.painterResource 45 import androidx.compose.ui.res.stringResource 46 import androidx.compose.ui.unit.Dp 47 import androidx.compose.ui.unit.dp 48 import androidx.core.graphics.drawable.toBitmap 49 import com.android.credentialmanager.CredentialSelectorViewModel 50 import com.android.credentialmanager.R 51 import com.android.credentialmanager.common.BaseEntry 52 import com.android.credentialmanager.common.CredentialType 53 import com.android.credentialmanager.common.ProviderActivityState 54 import com.android.credentialmanager.common.material.ModalBottomSheetDefaults 55 import com.android.credentialmanager.common.ui.ActionButton 56 import com.android.credentialmanager.common.ui.BodyMediumText 57 import com.android.credentialmanager.common.ui.BodySmallText 58 import com.android.credentialmanager.common.ui.ConfirmButton 59 import com.android.credentialmanager.common.ui.CredentialContainerCard 60 import com.android.credentialmanager.common.ui.CtaButtonRow 61 import com.android.credentialmanager.common.ui.Entry 62 import com.android.credentialmanager.common.ui.HeadlineIcon 63 import com.android.credentialmanager.common.ui.LargeLabelTextOnSurfaceVariant 64 import com.android.credentialmanager.common.ui.ModalBottomSheet 65 import com.android.credentialmanager.common.ui.MoreAboutPasskeySectionHeader 66 import com.android.credentialmanager.common.ui.MoreOptionTopAppBar 67 import com.android.credentialmanager.common.ui.SheetContainerCard 68 import com.android.credentialmanager.common.ui.PasskeyBenefitRow 69 import com.android.credentialmanager.common.ui.HeadlineText 70 import com.android.credentialmanager.logging.CreateCredentialEvent 71 import com.android.credentialmanager.ui.theme.LocalAndroidColorScheme 72 import com.android.internal.logging.UiEventLogger.UiEventEnum 73 74 @Composable 75 fun CreateCredentialScreen( 76 viewModel: CredentialSelectorViewModel, 77 createCredentialUiState: CreateCredentialUiState, 78 providerActivityLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult> 79 ) { 80 ModalBottomSheet( 81 sheetContent = { 82 // Hide the sheet content as opposed to the whole bottom sheet to maintain the scrim 83 // background color even when the content should be hidden while waiting for 84 // results from the provider app. 85 when (viewModel.uiState.providerActivityState) { 86 ProviderActivityState.NOT_APPLICABLE -> { 87 when (createCredentialUiState.currentScreenState) { 88 CreateScreenState.PASSKEY_INTRO -> PasskeyIntroCard( 89 onConfirm = viewModel::createFlowOnConfirmIntro, 90 onLearnMore = viewModel::createFlowOnLearnMore, 91 onLog = { viewModel.logUiEvent(it) }, 92 ) 93 CreateScreenState.CREATION_OPTION_SELECTION -> CreationSelectionCard( 94 requestDisplayInfo = createCredentialUiState.requestDisplayInfo, 95 enabledProviderList = createCredentialUiState.enabledProviders, 96 providerInfo = createCredentialUiState 97 .activeEntry?.activeProvider!!, 98 createOptionInfo = 99 createCredentialUiState.activeEntry.activeEntryInfo 100 as CreateOptionInfo, 101 onOptionSelected = viewModel::createFlowOnEntrySelected, 102 onConfirm = viewModel::createFlowOnConfirmEntrySelected, 103 onMoreOptionsSelected = 104 viewModel::createFlowOnMoreOptionsSelectedOnCreationSelection, 105 onLog = { viewModel.logUiEvent(it) }, 106 ) 107 CreateScreenState.MORE_OPTIONS_SELECTION -> MoreOptionsSelectionCard( 108 requestDisplayInfo = createCredentialUiState.requestDisplayInfo, 109 enabledProviderList = createCredentialUiState.enabledProviders, 110 disabledProviderList = createCredentialUiState.disabledProviders, 111 sortedCreateOptionsPairs = 112 createCredentialUiState.sortedCreateOptionsPairs, 113 onBackCreationSelectionButtonSelected = 114 viewModel::createFlowOnBackCreationSelectionButtonSelected, 115 onOptionSelected = 116 viewModel::createFlowOnEntrySelectedFromMoreOptionScreen, 117 onDisabledProvidersSelected = 118 viewModel::createFlowOnLaunchSettings, 119 onRemoteEntrySelected = viewModel::createFlowOnEntrySelected, 120 onLog = { viewModel.logUiEvent(it) }, 121 ) 122 CreateScreenState.DEFAULT_PROVIDER_CONFIRMATION -> { 123 if (createCredentialUiState.activeEntry == null) { 124 viewModel.onIllegalUiState("Expect active entry to be non-null" + 125 " upon default provider dialog.") 126 } else { 127 NonDefaultUsageConfirmationCard( 128 selectedEntry = createCredentialUiState.activeEntry, 129 onIllegalScreenState = viewModel::onIllegalUiState, 130 onLaunchSettings = 131 viewModel::createFlowOnLaunchSettings, 132 onUseOnceSelected = viewModel::createFlowOnUseOnceSelected, 133 onLog = { viewModel.logUiEvent(it) }, 134 ) 135 } 136 } 137 CreateScreenState.EXTERNAL_ONLY_SELECTION -> ExternalOnlySelectionCard( 138 requestDisplayInfo = createCredentialUiState.requestDisplayInfo, 139 activeRemoteEntry = 140 createCredentialUiState.activeEntry?.activeEntryInfo!!, 141 onOptionSelected = viewModel::createFlowOnEntrySelected, 142 onConfirm = viewModel::createFlowOnConfirmEntrySelected, 143 onLog = { viewModel.logUiEvent(it) }, 144 ) 145 CreateScreenState.MORE_ABOUT_PASSKEYS_INTRO -> MoreAboutPasskeysIntroCard( 146 onBackPasskeyIntroButtonSelected = 147 viewModel::createFlowOnBackPasskeyIntroButtonSelected, 148 onLog = { viewModel.logUiEvent(it) }, 149 ) 150 } 151 } 152 ProviderActivityState.READY_TO_LAUNCH -> { 153 // This is a native bug from ModalBottomSheet. For now, use the temporary 154 // solution of not having an empty state. 155 if (viewModel.uiState.isAutoSelectFlow) { 156 Divider( 157 thickness = Dp.Hairline, color = ModalBottomSheetDefaults.scrimColor 158 ) 159 } 160 // Launch only once per providerActivityState change so that the provider 161 // UI will not be accidentally launched twice. 162 LaunchedEffect(viewModel.uiState.providerActivityState) { 163 viewModel.launchProviderUi(providerActivityLauncher) 164 } 165 viewModel.uiMetrics.log( 166 CreateCredentialEvent 167 .CREDMAN_CREATE_CRED_PROVIDER_ACTIVITY_READY_TO_LAUNCH) 168 } 169 ProviderActivityState.PENDING -> { 170 if (viewModel.uiState.isAutoSelectFlow) { 171 Divider( 172 thickness = Dp.Hairline, color = ModalBottomSheetDefaults.scrimColor 173 ) 174 } 175 // Hide our content when the provider activity is active. 176 viewModel.uiMetrics.log( 177 CreateCredentialEvent.CREDMAN_CREATE_CRED_PROVIDER_ACTIVITY_PENDING) 178 } 179 } 180 }, 181 onDismiss = viewModel::onUserCancel, 182 isInitialRender = viewModel.uiState.isInitialRender, 183 isAutoSelectFlow = viewModel.uiState.isAutoSelectFlow, 184 onInitialRenderComplete = viewModel::onInitialRenderComplete, 185 ) 186 } 187 188 @Composable 189 fun PasskeyIntroCard( 190 onConfirm: () -> Unit, 191 onLearnMore: () -> Unit, 192 onLog: @Composable (UiEventEnum) -> Unit, 193 ) { 194 SheetContainerCard { 195 item { 196 val onboardingImageResource = remember { 197 mutableStateOf(R.drawable.ic_passkeys_onboarding) 198 } 199 if (isSystemInDarkTheme()) { 200 onboardingImageResource.value = R.drawable.ic_passkeys_onboarding_dark 201 } else { 202 onboardingImageResource.value = R.drawable.ic_passkeys_onboarding 203 } 204 Row( 205 modifier = Modifier.wrapContentHeight().fillMaxWidth(), 206 horizontalArrangement = Arrangement.Center, 207 ) { 208 Image( 209 painter = painterResource(onboardingImageResource.value), 210 contentDescription = null, 211 modifier = Modifier.size(316.dp, 168.dp) 212 ) 213 } 214 } 215 item { Divider(thickness = 16.dp, color = Color.Transparent) } 216 item { HeadlineText(text = stringResource(R.string.passkey_creation_intro_title)) } 217 item { Divider(thickness = 16.dp, color = Color.Transparent) } 218 item { 219 PasskeyBenefitRow( 220 leadingIconPainter = painterResource(R.drawable.ic_passkeys_onboarding_password), 221 text = stringResource(R.string.passkey_creation_intro_body_password), 222 ) 223 } 224 item { Divider(thickness = 16.dp, color = Color.Transparent) } 225 item { 226 PasskeyBenefitRow( 227 leadingIconPainter = painterResource(R.drawable.ic_passkeys_onboarding_fingerprint), 228 text = stringResource(R.string.passkey_creation_intro_body_fingerprint), 229 ) 230 } 231 item { Divider(thickness = 16.dp, color = Color.Transparent) } 232 item { 233 PasskeyBenefitRow( 234 leadingIconPainter = painterResource(R.drawable.ic_passkeys_onboarding_device), 235 text = stringResource(R.string.passkey_creation_intro_body_device), 236 ) 237 } 238 item { Divider(thickness = 24.dp, color = Color.Transparent) } 239 240 item { 241 CtaButtonRow( 242 leftButton = { 243 ActionButton( 244 stringResource(R.string.string_learn_more), 245 onClick = onLearnMore 246 ) 247 }, 248 rightButton = { 249 ConfirmButton( 250 stringResource(R.string.string_continue), 251 onClick = onConfirm 252 ) 253 }, 254 ) 255 } 256 } 257 onLog(CreateCredentialEvent.CREDMAN_CREATE_CRED_PASSKEY_INTRO) 258 } 259 260 @Composable 261 fun MoreOptionsSelectionCard( 262 requestDisplayInfo: RequestDisplayInfo, 263 enabledProviderList: List<EnabledProviderInfo>, 264 disabledProviderList: List<DisabledProviderInfo>?, 265 sortedCreateOptionsPairs: List<Pair<CreateOptionInfo, EnabledProviderInfo>>, 266 onBackCreationSelectionButtonSelected: () -> Unit, 267 onOptionSelected: (ActiveEntry) -> Unit, 268 onDisabledProvidersSelected: () -> Unit, 269 onRemoteEntrySelected: (BaseEntry) -> Unit, 270 onLog: @Composable (UiEventEnum) -> Unit, 271 ) { 272 SheetContainerCard(topAppBar = { 273 MoreOptionTopAppBar( 274 text = stringResource( 275 R.string.save_credential_to_title, 276 when (requestDisplayInfo.type) { 277 CredentialType.PASSKEY -> 278 stringResource(R.string.passkey) 279 CredentialType.PASSWORD -> 280 stringResource(R.string.password) 281 CredentialType.UNKNOWN -> stringResource(R.string.sign_in_info) 282 } 283 ), 284 onNavigationIconClicked = onBackCreationSelectionButtonSelected, 285 bottomPadding = 16.dp, 286 ) 287 }) { 288 // bottom padding already 289 item { 290 CredentialContainerCard { 291 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { 292 sortedCreateOptionsPairs.forEach { entry -> 293 MoreOptionsInfoRow( 294 requestDisplayInfo = requestDisplayInfo, 295 providerInfo = entry.second, 296 createOptionInfo = entry.first, 297 onOptionSelected = { 298 onOptionSelected( 299 ActiveEntry( 300 entry.second, 301 entry.first 302 ) 303 ) 304 } 305 ) 306 } 307 MoreOptionsDisabledProvidersRow( 308 disabledProviders = disabledProviderList, 309 onDisabledProvidersSelected = 310 onDisabledProvidersSelected, 311 ) 312 enabledProviderList.forEach { 313 if (it.remoteEntry != null) { 314 RemoteEntryRow( 315 remoteInfo = it.remoteEntry!!, 316 onRemoteEntrySelected = onRemoteEntrySelected, 317 ) 318 return@forEach 319 } 320 } 321 } 322 } 323 } 324 } 325 onLog(CreateCredentialEvent.CREDMAN_CREATE_CRED_MORE_OPTIONS_SELECTION) 326 } 327 328 @Composable 329 fun NonDefaultUsageConfirmationCard( 330 selectedEntry: ActiveEntry, 331 onIllegalScreenState: (String) -> Unit, 332 onLaunchSettings: () -> Unit, 333 onUseOnceSelected: () -> Unit, 334 onLog: @Composable (UiEventEnum) -> Unit, 335 ) { 336 val entryInfo = selectedEntry.activeEntryInfo 337 if (entryInfo !is CreateOptionInfo) { 338 onIllegalScreenState("Encountered unexpected type of entry during the default provider" + 339 " dialog: ${entryInfo::class}") 340 return 341 } 342 SheetContainerCard { 343 item { HeadlineIcon(imageVector = Icons.Outlined.NewReleases) } 344 item { Divider(thickness = 16.dp, color = Color.Transparent) } 345 item { 346 HeadlineText( 347 text = stringResource( 348 R.string.use_provider_for_all_title, selectedEntry.activeProvider.displayName) 349 ) 350 } 351 item { Divider(thickness = 24.dp, color = Color.Transparent) } 352 item { 353 Row(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { 354 BodyMediumText(text = stringResource( 355 R.string.use_provider_for_all_description, entryInfo.userProviderDisplayName)) 356 } 357 } 358 item { Divider(thickness = 24.dp, color = Color.Transparent) } 359 item { 360 CtaButtonRow( 361 leftButton = { 362 ActionButton( 363 stringResource(R.string.settings), 364 onClick = onLaunchSettings, 365 ) 366 }, 367 rightButton = { 368 ConfirmButton( 369 stringResource(R.string.use_once), 370 onClick = onUseOnceSelected, 371 ) 372 }, 373 ) 374 } 375 } 376 onLog(CreateCredentialEvent.CREDMAN_CREATE_CRED_MORE_OPTIONS_ROW_INTRO) 377 } 378 379 @Composable 380 fun CreationSelectionCard( 381 requestDisplayInfo: RequestDisplayInfo, 382 enabledProviderList: List<EnabledProviderInfo>, 383 providerInfo: EnabledProviderInfo, 384 createOptionInfo: CreateOptionInfo, 385 onOptionSelected: (BaseEntry) -> Unit, 386 onConfirm: () -> Unit, 387 onMoreOptionsSelected: () -> Unit, 388 onLog: @Composable (UiEventEnum) -> Unit, 389 ) { 390 SheetContainerCard { 391 item { 392 HeadlineIcon( 393 bitmap = providerInfo.icon.toBitmap().asImageBitmap(), 394 tint = Color.Unspecified, 395 ) 396 } 397 item { Divider(thickness = 4.dp, color = Color.Transparent) } 398 item { LargeLabelTextOnSurfaceVariant(text = providerInfo.displayName) } 399 item { Divider(thickness = 16.dp, color = Color.Transparent) } 400 item { 401 HeadlineText( 402 text = when (requestDisplayInfo.type) { 403 CredentialType.PASSKEY -> stringResource( 404 R.string.choose_create_option_passkey_title, 405 requestDisplayInfo.appName 406 ) 407 CredentialType.PASSWORD -> stringResource( 408 R.string.choose_create_option_password_title, 409 requestDisplayInfo.appName 410 ) 411 CredentialType.UNKNOWN -> stringResource( 412 R.string.choose_create_option_sign_in_title, 413 requestDisplayInfo.appName 414 ) 415 } 416 ) 417 } 418 item { Divider(thickness = 24.dp, color = Color.Transparent) } 419 item { 420 CredentialContainerCard { 421 PrimaryCreateOptionRow( 422 requestDisplayInfo = requestDisplayInfo, 423 entryInfo = createOptionInfo, 424 onOptionSelected = onOptionSelected 425 ) 426 } 427 } 428 item { Divider(thickness = 24.dp, color = Color.Transparent) } 429 var createOptionsSize = 0 430 var remoteEntry: RemoteInfo? = null 431 enabledProviderList.forEach { enabledProvider -> 432 if (enabledProvider.remoteEntry != null) { 433 remoteEntry = enabledProvider.remoteEntry 434 } 435 createOptionsSize += enabledProvider.sortedCreateOptions.size 436 } 437 val shouldShowMoreOptionsButton = createOptionsSize > 1 || remoteEntry != null 438 item { 439 CtaButtonRow( 440 leftButton = if (shouldShowMoreOptionsButton) { 441 { 442 ActionButton( 443 stringResource(R.string.string_more_options), 444 onMoreOptionsSelected 445 ) 446 } 447 } else null, 448 rightButton = { 449 ConfirmButton( 450 stringResource(R.string.string_continue), 451 onClick = onConfirm 452 ) 453 }, 454 ) 455 } 456 val footerDescription = createOptionInfo.footerDescription 457 if (footerDescription != null && footerDescription.length > 0) { 458 item { 459 Divider( 460 thickness = 1.dp, 461 color = LocalAndroidColorScheme.current.colorOutlineVariant, 462 modifier = Modifier.padding(vertical = 16.dp) 463 ) 464 } 465 item { 466 Row(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { 467 BodySmallText(text = footerDescription) 468 } 469 } 470 } 471 } 472 onLog(CreateCredentialEvent.CREDMAN_CREATE_CRED_CREATION_OPTION_SELECTION) 473 } 474 475 @Composable 476 fun ExternalOnlySelectionCard( 477 requestDisplayInfo: RequestDisplayInfo, 478 activeRemoteEntry: BaseEntry, 479 onOptionSelected: (BaseEntry) -> Unit, 480 onConfirm: () -> Unit, 481 onLog: @Composable (UiEventEnum) -> Unit, 482 ) { 483 SheetContainerCard { 484 item { HeadlineIcon(imageVector = Icons.Outlined.QrCodeScanner) } 485 item { Divider(thickness = 16.dp, color = Color.Transparent) } 486 item { 487 HeadlineText( 488 text = stringResource( 489 when (requestDisplayInfo.type) { 490 CredentialType.PASSKEY -> R.string.create_passkey_in_other_device_title 491 CredentialType.PASSWORD -> R.string.save_password_on_other_device_title 492 else -> R.string.save_sign_in_on_other_device_title 493 } 494 ) 495 ) 496 } 497 item { Divider(thickness = 24.dp, color = Color.Transparent) } 498 item { 499 CredentialContainerCard { 500 PrimaryCreateOptionRow( 501 requestDisplayInfo = requestDisplayInfo, 502 entryInfo = activeRemoteEntry, 503 onOptionSelected = onOptionSelected 504 ) 505 } 506 } 507 item { Divider(thickness = 24.dp, color = Color.Transparent) } 508 item { 509 CtaButtonRow( 510 rightButton = { 511 ConfirmButton( 512 stringResource(R.string.string_continue), 513 onClick = onConfirm 514 ) 515 }, 516 ) 517 } 518 } 519 onLog(CreateCredentialEvent.CREDMAN_CREATE_CRED_EXTERNAL_ONLY_SELECTION) 520 } 521 522 @Composable 523 fun MoreAboutPasskeysIntroCard( 524 onBackPasskeyIntroButtonSelected: () -> Unit, 525 onLog: @Composable (UiEventEnum) -> Unit, 526 ) { 527 SheetContainerCard( 528 topAppBar = { 529 MoreOptionTopAppBar( 530 text = stringResource(R.string.more_about_passkeys_title), 531 onNavigationIconClicked = onBackPasskeyIntroButtonSelected, 532 bottomPadding = 0.dp, 533 ) 534 }, 535 ) { 536 item { 537 MoreAboutPasskeySectionHeader( 538 text = stringResource(R.string.passwordless_technology_title) 539 ) 540 Row(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { 541 BodyMediumText(text = stringResource(R.string.passwordless_technology_detail)) 542 } 543 } 544 item { 545 Divider(thickness = 8.dp, color = Color.Transparent) 546 MoreAboutPasskeySectionHeader( 547 text = stringResource(R.string.public_key_cryptography_title) 548 ) 549 Row(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { 550 BodyMediumText(text = stringResource(R.string.public_key_cryptography_detail)) 551 } 552 } 553 item { 554 Divider(thickness = 8.dp, color = Color.Transparent) 555 MoreAboutPasskeySectionHeader( 556 text = stringResource(R.string.improved_account_security_title) 557 ) 558 Row(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { 559 BodyMediumText(text = stringResource(R.string.improved_account_security_detail)) 560 } 561 } 562 item { 563 Divider(thickness = 8.dp, color = Color.Transparent) 564 MoreAboutPasskeySectionHeader( 565 text = stringResource(R.string.seamless_transition_title) 566 ) 567 Row(modifier = Modifier.fillMaxWidth().wrapContentHeight()) { 568 BodyMediumText(text = stringResource(R.string.seamless_transition_detail)) 569 } 570 } 571 } 572 onLog(CreateCredentialEvent.CREDMAN_CREATE_CRED_MORE_ABOUT_PASSKEYS_INTRO) 573 } 574 575 @Composable 576 fun PrimaryCreateOptionRow( 577 requestDisplayInfo: RequestDisplayInfo, 578 entryInfo: BaseEntry, 579 onOptionSelected: (BaseEntry) -> Unit 580 ) { 581 Entry( 582 onClick = { onOptionSelected(entryInfo) }, 583 iconImageBitmap = 584 if (entryInfo is CreateOptionInfo && entryInfo.profileIcon != null) { 585 entryInfo.profileIcon.toBitmap().asImageBitmap() 586 } else { 587 requestDisplayInfo.typeIcon.toBitmap().asImageBitmap() 588 }, 589 shouldApplyIconImageBitmapTint = !(entryInfo is CreateOptionInfo && 590 entryInfo.profileIcon != null), 591 entryHeadlineText = requestDisplayInfo.title, 592 entrySecondLineText = when (requestDisplayInfo.type) { 593 CredentialType.PASSKEY -> { 594 if (!TextUtils.isEmpty(requestDisplayInfo.subtitle)) { 595 requestDisplayInfo.subtitle + " • " + stringResource( 596 R.string.passkey_before_subtitle 597 ) 598 } else { 599 stringResource(R.string.passkey_before_subtitle) 600 } 601 } 602 // Set passwordValue instead 603 CredentialType.PASSWORD -> null 604 CredentialType.UNKNOWN -> requestDisplayInfo.subtitle 605 }, 606 passwordValue = 607 if (requestDisplayInfo.type == CredentialType.PASSWORD) 608 // This subtitle would never be null for create password 609 requestDisplayInfo.subtitle ?: "" 610 else null, 611 enforceOneLine = true, 612 ) 613 } 614 615 @Composable 616 fun MoreOptionsInfoRow( 617 requestDisplayInfo: RequestDisplayInfo, 618 providerInfo: EnabledProviderInfo, 619 createOptionInfo: CreateOptionInfo, 620 onOptionSelected: () -> Unit 621 ) { 622 Entry( 623 onClick = onOptionSelected, 624 iconImageBitmap = providerInfo.icon.toBitmap().asImageBitmap(), 625 entryHeadlineText = providerInfo.displayName, 626 entrySecondLineText = createOptionInfo.userProviderDisplayName, 627 entryThirdLineText = 628 if (requestDisplayInfo.type == CredentialType.PASSKEY || 629 requestDisplayInfo.type == CredentialType.PASSWORD) { 630 if (createOptionInfo.passwordCount != null && 631 createOptionInfo.passkeyCount != null 632 ) { 633 stringResource( 634 R.string.more_options_usage_passwords_passkeys, 635 createOptionInfo.passwordCount, 636 createOptionInfo.passkeyCount 637 ) 638 } else if (createOptionInfo.passwordCount != null) { 639 stringResource( 640 R.string.more_options_usage_passwords, 641 createOptionInfo.passwordCount 642 ) 643 } else if (createOptionInfo.passkeyCount != null) { 644 stringResource( 645 R.string.more_options_usage_passkeys, 646 createOptionInfo.passkeyCount 647 ) 648 } else { 649 null 650 } 651 } else { 652 if (createOptionInfo.totalCredentialCount != null) { 653 stringResource( 654 R.string.more_options_usage_credentials, 655 createOptionInfo.totalCredentialCount 656 ) 657 } else { 658 null 659 } 660 }, 661 ) 662 } 663 664 @Composable 665 fun MoreOptionsDisabledProvidersRow( 666 disabledProviders: List<ProviderInfo>?, 667 onDisabledProvidersSelected: () -> Unit, 668 ) { 669 if (disabledProviders != null && disabledProviders.isNotEmpty()) { 670 Entry( 671 onClick = onDisabledProvidersSelected, 672 iconImageVector = Icons.Filled.Add, 673 entryHeadlineText = stringResource(R.string.other_password_manager), 674 entrySecondLineText = disabledProviders.joinToString(separator = " • ") { 675 it.displayName 676 }, 677 ) 678 } 679 } 680 681 @Composable 682 fun RemoteEntryRow( 683 remoteInfo: RemoteInfo, 684 onRemoteEntrySelected: (RemoteInfo) -> Unit, 685 ) { 686 Entry( 687 onClick = { onRemoteEntrySelected(remoteInfo) }, 688 iconImageVector = Icons.Outlined.QrCodeScanner, 689 entryHeadlineText = stringResource(R.string.another_device), 690 ) 691 }