1 /*
<lambda>null2  * Copyright (C) 2023 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.getflow
18 
19 import android.credentials.flags.Flags.credmanBiometricApiEnabled
20 import android.credentials.flags.Flags.selectorUiImprovementsEnabled
21 import android.graphics.drawable.Drawable
22 import android.hardware.biometrics.BiometricPrompt
23 import android.os.CancellationSignal
24 import androidx.activity.compose.ManagedActivityResultLauncher
25 import androidx.activity.result.ActivityResult
26 import androidx.activity.result.IntentSenderRequest
27 import androidx.compose.foundation.layout.Arrangement
28 import androidx.compose.foundation.layout.Column
29 import androidx.compose.foundation.layout.PaddingValues
30 import androidx.compose.foundation.layout.fillMaxWidth
31 import androidx.compose.foundation.layout.heightIn
32 import androidx.compose.foundation.layout.padding
33 import androidx.compose.foundation.layout.wrapContentHeight
34 import androidx.compose.foundation.lazy.items
35 import androidx.compose.material.icons.Icons
36 import androidx.compose.material.icons.filled.ArrowBack
37 import androidx.compose.material.icons.filled.Close
38 import androidx.compose.material.icons.outlined.QrCodeScanner
39 import androidx.compose.material3.Divider
40 import androidx.compose.material3.TextButton
41 import androidx.compose.runtime.Composable
42 import androidx.compose.runtime.LaunchedEffect
43 import androidx.compose.runtime.mutableStateOf
44 import androidx.compose.runtime.remember
45 import androidx.compose.ui.Modifier
46 import androidx.compose.ui.graphics.Color
47 import androidx.compose.ui.graphics.asImageBitmap
48 import androidx.compose.ui.graphics.painter.Painter
49 import androidx.compose.ui.platform.LocalContext
50 import androidx.compose.ui.res.painterResource
51 import androidx.compose.ui.res.stringResource
52 import androidx.compose.ui.text.TextLayoutResult
53 import androidx.compose.ui.unit.Dp
54 import androidx.compose.ui.unit.dp
55 import androidx.core.graphics.drawable.toBitmap
56 import com.android.credentialmanager.CredentialSelectorViewModel
57 import com.android.credentialmanager.R
58 import com.android.credentialmanager.common.BiometricError
59 import com.android.credentialmanager.common.BiometricFlowType
60 import com.android.credentialmanager.common.BiometricPromptState
61 import com.android.credentialmanager.common.ProviderActivityState
62 import com.android.credentialmanager.common.material.ModalBottomSheetDefaults
63 import com.android.credentialmanager.common.runBiometricFlowForGet
64 import com.android.credentialmanager.common.ui.ActionButton
65 import com.android.credentialmanager.common.ui.ActionEntry
66 import com.android.credentialmanager.common.ui.ConfirmButton
67 import com.android.credentialmanager.common.ui.CredentialContainerCard
68 import com.android.credentialmanager.common.ui.CredentialListSectionHeader
69 import com.android.credentialmanager.common.ui.CtaButtonRow
70 import com.android.credentialmanager.common.ui.Entry
71 import com.android.credentialmanager.common.ui.HeadlineIcon
72 import com.android.credentialmanager.common.ui.HeadlineText
73 import com.android.credentialmanager.common.ui.LargeLabelTextOnSurfaceVariant
74 import com.android.credentialmanager.common.ui.ModalBottomSheet
75 import com.android.credentialmanager.common.ui.MoreOptionTopAppBar
76 import com.android.credentialmanager.common.ui.SheetContainerCard
77 import com.android.credentialmanager.common.ui.Snackbar
78 import com.android.credentialmanager.common.ui.SnackbarActionText
79 import com.android.credentialmanager.logging.GetCredentialEvent
80 import com.android.credentialmanager.model.CredentialType
81 import com.android.credentialmanager.model.EntryInfo
82 import com.android.credentialmanager.model.get.ActionEntryInfo
83 import com.android.credentialmanager.model.get.AuthenticationEntryInfo
84 import com.android.credentialmanager.model.get.CredentialEntryInfo
85 import com.android.credentialmanager.model.get.ProviderInfo
86 import com.android.credentialmanager.model.get.RemoteEntryInfo
87 import com.android.credentialmanager.userAndDisplayNameForPasskey
88 import com.android.internal.logging.UiEventLogger.UiEventEnum
89 import kotlin.math.max
90 
91 @Composable
92 fun GetCredentialScreen(
93     viewModel: CredentialSelectorViewModel,
94     getCredentialUiState: GetCredentialUiState,
95     providerActivityLauncher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>,
96 ) {
97     if (getCredentialUiState.currentScreenState == GetScreenState.REMOTE_ONLY) {
98         RemoteCredentialSnackBarScreen(
99             onClick = viewModel::getFlowOnMoreOptionOnSnackBarSelected,
100             onCancel = viewModel::onUserCancel,
101             onLog = { viewModel.logUiEvent(it) },
102         )
103         viewModel.uiMetrics.log(GetCredentialEvent.CREDMAN_GET_CRED_SCREEN_REMOTE_ONLY)
104     } else if (getCredentialUiState.currentScreenState
105         == GetScreenState.UNLOCKED_AUTH_ENTRIES_ONLY) {
106         EmptyAuthEntrySnackBarScreen(
107             authenticationEntryList =
108             getCredentialUiState.providerDisplayInfo.authenticationEntryList,
109             onCancel = viewModel::silentlyFinishActivity,
110             onLastLockedAuthEntryNotFound = viewModel::onLastLockedAuthEntryNotFoundError,
111             onLog = { viewModel.logUiEvent(it) },
112         )
113         viewModel.uiMetrics.log(GetCredentialEvent
114                 .CREDMAN_GET_CRED_SCREEN_UNLOCKED_AUTH_ENTRIES_ONLY)
115     } else {
116         ModalBottomSheet(
117             sheetContent = {
118                 // Hide the sheet content as opposed to the whole bottom sheet to maintain the scrim
119                 // background color even when the content should be hidden while waiting for
120                 // results from the provider app.
121                 when (viewModel.uiState.providerActivityState) {
122                     ProviderActivityState.NOT_APPLICABLE -> {
123                         if (getCredentialUiState.currentScreenState
124                             == GetScreenState.PRIMARY_SELECTION) {
125                             if (selectorUiImprovementsEnabled()) {
126                                 PrimarySelectionCardVImpl(
127                                     requestDisplayInfo = getCredentialUiState.requestDisplayInfo,
128                                     providerDisplayInfo = getCredentialUiState.providerDisplayInfo,
129                                     providerInfoList = getCredentialUiState.providerInfoList,
130                                     activeEntry = getCredentialUiState.activeEntry,
131                                     onEntrySelected = viewModel::getFlowOnEntrySelected,
132                                     onConfirm = viewModel::getFlowOnConfirmEntrySelected,
133                                     onMoreOptionSelected = viewModel::getFlowOnMoreOptionSelected,
134                                     onLog = { viewModel.logUiEvent(it) },
135                                 )
136                             } else {
137                                 PrimarySelectionCard(
138                                     requestDisplayInfo = getCredentialUiState.requestDisplayInfo,
139                                     providerDisplayInfo = getCredentialUiState.providerDisplayInfo,
140                                     providerInfoList = getCredentialUiState.providerInfoList,
141                                     activeEntry = getCredentialUiState.activeEntry,
142                                     onEntrySelected = viewModel::getFlowOnEntrySelected,
143                                     onConfirm = viewModel::getFlowOnConfirmEntrySelected,
144                                     onMoreOptionSelected = viewModel::getFlowOnMoreOptionSelected,
145                                     onLog = { viewModel.logUiEvent(it) },
146                                 )
147                             }
148                             viewModel.uiMetrics.log(GetCredentialEvent
149                                     .CREDMAN_GET_CRED_SCREEN_PRIMARY_SELECTION)
150                         } else if (credmanBiometricApiEnabled() && getCredentialUiState
151                                 .currentScreenState == GetScreenState.BIOMETRIC_SELECTION) {
152                             BiometricSelectionPage(
153                                 biometricEntry = getCredentialUiState.activeEntry,
154                                 onMoreOptionSelected = viewModel::getFlowOnMoreOptionOnlySelected,
155                                 onCancelFlowAndFinish = viewModel::onUserCancel,
156                                 onIllegalStateAndFinish = viewModel::onIllegalUiState,
157                                 requestDisplayInfo = getCredentialUiState.requestDisplayInfo,
158                                 providerInfoList = getCredentialUiState.providerInfoList,
159                                 providerDisplayInfo = getCredentialUiState.providerDisplayInfo,
160                                 onBiometricEntrySelected =
161                                 viewModel::getFlowOnEntrySelected,
162                                 fallbackToOriginalFlow =
163                                 viewModel::fallbackFromBiometricToNormalFlow,
164                                 getBiometricPromptState =
165                                 viewModel::getBiometricPromptStateStatus,
166                                 onBiometricPromptStateChange =
167                                 viewModel::onBiometricPromptStateChange,
168                                 getBiometricCancellationSignal =
169                                 viewModel::getBiometricCancellationSignal,
170                                 onLog = { viewModel.logUiEvent(it) },
171                             )
172                         } else if (credmanBiometricApiEnabled() &&
173                                 getCredentialUiState.currentScreenState
174                                 == GetScreenState.ALL_SIGN_IN_OPTIONS_ONLY) {
175                             AllSignInOptionCard(
176                                     providerInfoList = getCredentialUiState.providerInfoList,
177                                     providerDisplayInfo = getCredentialUiState.providerDisplayInfo,
178                                     onEntrySelected = viewModel::getFlowOnEntrySelected,
179                                     onBackButtonClicked = viewModel::onUserCancel,
180                                     onCancel = viewModel::onUserCancel,
181                                     onLog = { viewModel.logUiEvent(it) },
182                                     customTopBar = { MoreOptionTopAppBar(
183                                             text = stringResource(
184                                                     R.string.get_dialog_title_sign_in_options),
185                                             onNavigationIconClicked = viewModel::onUserCancel,
186                                             navigationIcon = Icons.Filled.Close,
187                                             navigationIconContentDescription =
188                                             stringResource(R.string.accessibility_close_button),
189                                             bottomPadding = 0.dp
190                                     ) }
191                             )
192                             viewModel.uiMetrics.log(GetCredentialEvent
193                                     .CREDMAN_GET_CRED_SCREEN_ALL_SIGN_IN_OPTIONS)
194                         } else {
195                             AllSignInOptionCard(
196                                 providerInfoList = getCredentialUiState.providerInfoList,
197                                 providerDisplayInfo = getCredentialUiState.providerDisplayInfo,
198                                 onEntrySelected = viewModel::getFlowOnEntrySelected,
199                                 onBackButtonClicked =
200                                 if (getCredentialUiState.isNoAccount)
201                                     viewModel::getFlowOnBackToHybridSnackBarScreen
202                                 else viewModel::getFlowOnBackToPrimarySelectionScreen,
203                                 onCancel = viewModel::onUserCancel,
204                                 onLog = { viewModel.logUiEvent(it) },
205                             )
206                             viewModel.uiMetrics.log(GetCredentialEvent
207                                     .CREDMAN_GET_CRED_SCREEN_ALL_SIGN_IN_OPTIONS)
208                         }
209                     }
210                     ProviderActivityState.READY_TO_LAUNCH -> {
211                         // This is a native bug from ModalBottomSheet. For now, use the temporary
212                         // solution of not having an empty state.
213                         if (viewModel.uiState.isAutoSelectFlow) {
214                             Divider(
215                                 thickness = Dp.Hairline, color = ModalBottomSheetDefaults.scrimColor
216                             )
217                         }
218                         // Launch only once per providerActivityState change so that the provider
219                         // UI will not be accidentally launched twice.
220                         LaunchedEffect(viewModel.uiState.providerActivityState) {
221                             viewModel.launchProviderUi(providerActivityLauncher)
222                         }
223                         viewModel.uiMetrics.log(GetCredentialEvent
224                                 .CREDMAN_GET_CRED_PROVIDER_ACTIVITY_READY_TO_LAUNCH)
225                     }
226                     ProviderActivityState.PENDING -> {
227                         if (viewModel.uiState.isAutoSelectFlow) {
228                             Divider(
229                                 thickness = Dp.Hairline, color = ModalBottomSheetDefaults.scrimColor
230                             )
231                         }
232                         // Hide our content when the provider activity is active.
233                         viewModel.uiMetrics.log(GetCredentialEvent
234                                 .CREDMAN_GET_CRED_PROVIDER_ACTIVITY_PENDING)
235                     }
236                 }
237             },
238             onDismiss = viewModel::onUserCancel,
239             isInitialRender = viewModel.uiState.isInitialRender,
240             isAutoSelectFlow = viewModel.uiState.isAutoSelectFlow,
241             onInitialRenderComplete = viewModel::onInitialRenderComplete,
242         )
243     }
244 }
245 
246 @Composable
BiometricSelectionPagenull247 internal fun BiometricSelectionPage(
248     biometricEntry: EntryInfo?,
249     onCancelFlowAndFinish: () -> Unit,
250     onIllegalStateAndFinish: (String) -> Unit,
251     onMoreOptionSelected: () -> Unit,
252     requestDisplayInfo: RequestDisplayInfo,
253     providerInfoList: List<ProviderInfo>,
254     providerDisplayInfo: ProviderDisplayInfo,
255     onBiometricEntrySelected: (
256         EntryInfo,
257         BiometricPrompt.AuthenticationResult?,
258         BiometricError?
259     ) -> Unit,
260     fallbackToOriginalFlow: (BiometricFlowType) -> Unit,
261     getBiometricPromptState: () -> BiometricPromptState,
262     onBiometricPromptStateChange: (BiometricPromptState) -> Unit,
263     getBiometricCancellationSignal: () -> CancellationSignal,
264     onLog: @Composable (UiEventEnum) -> Unit,
265 ) {
266     if (biometricEntry == null) {
267         fallbackToOriginalFlow(BiometricFlowType.GET)
268         return
269     }
270     val biometricFlowCalled = runBiometricFlowForGet(
271         biometricEntry = biometricEntry,
272         context = LocalContext.current,
273         openMoreOptionsPage = onMoreOptionSelected,
274         sendDataToProvider = onBiometricEntrySelected,
275         onCancelFlowAndFinish = onCancelFlowAndFinish,
276         onIllegalStateAndFinish = onIllegalStateAndFinish,
277         getBiometricPromptState = getBiometricPromptState,
278         onBiometricPromptStateChange = onBiometricPromptStateChange,
279         getRequestDisplayInfo = requestDisplayInfo,
280         getProviderInfoList = providerInfoList,
281         getProviderDisplayInfo = providerDisplayInfo,
282         onBiometricFailureFallback = fallbackToOriginalFlow,
283         getBiometricCancellationSignal = getBiometricCancellationSignal
284     )
285     if (biometricFlowCalled) {
286         onLog(GetCredentialEvent.CREDMAN_GET_CRED_BIOMETRIC_FLOW_LAUNCHED)
287     }
288 }
289 
290 /** Draws the primary credential selection page, used in Android U. */
291 // TODO(b/327518384) - remove after flag selectorUiImprovementsEnabled is enabled.
292 @Composable
PrimarySelectionCardnull293 fun PrimarySelectionCard(
294     requestDisplayInfo: RequestDisplayInfo,
295     providerDisplayInfo: ProviderDisplayInfo,
296     providerInfoList: List<ProviderInfo>,
297     activeEntry: EntryInfo?,
298     onEntrySelected: (EntryInfo) -> Unit,
299     onConfirm: () -> Unit,
300     onMoreOptionSelected: () -> Unit,
301     onLog: @Composable (UiEventEnum) -> Unit,
302 ) {
303     val showMoreForTruncatedEntry = remember { mutableStateOf(false) }
304     val sortedUserNameToCredentialEntryList =
305         providerDisplayInfo.sortedUserNameToCredentialEntryList
306     val authenticationEntryList = providerDisplayInfo.authenticationEntryList
307     SheetContainerCard {
308         val preferTopBrandingContent = requestDisplayInfo.preferTopBrandingContent
309         if (preferTopBrandingContent != null) {
310             item {
311                 HeadlineProviderIconAndName(
312                     preferTopBrandingContent.icon,
313                     preferTopBrandingContent.displayName
314                 )
315             }
316         } else {
317             // When only one provider (not counting the remote-only provider) exists, display that
318             // provider's icon + name up top.
319             val providersWithActualEntries = providerInfoList.filter {
320                 it.credentialEntryList.isNotEmpty() || it.authenticationEntryList.isNotEmpty()
321             }
322             if (providersWithActualEntries.size == 1) {
323                 // First should always work but just to be safe.
324                 val providerInfo = providersWithActualEntries.firstOrNull()
325                 if (providerInfo != null) {
326                     item {
327                         HeadlineProviderIconAndName(
328                             providerInfo.icon,
329                             providerInfo.displayName
330                         )
331                     }
332                 }
333             }
334         }
335 
336         val hasSingleEntry = (sortedUserNameToCredentialEntryList.size == 1 &&
337             authenticationEntryList.isEmpty()) || (sortedUserNameToCredentialEntryList.isEmpty() &&
338             authenticationEntryList.size == 1)
339         item {
340             if (requestDisplayInfo.preferIdentityDocUi) {
341                 HeadlineText(
342                     text = stringResource(
343                         if (hasSingleEntry) {
344                             R.string.get_dialog_title_use_info_on
345                         } else {
346                             R.string.get_dialog_title_choose_option_for
347                         },
348                         requestDisplayInfo.appName
349                     ),
350                 )
351             } else {
352                 HeadlineText(
353                     text = stringResource(
354                         if (hasSingleEntry) {
355                             val singleEntryType = sortedUserNameToCredentialEntryList.firstOrNull()
356                                 ?.sortedCredentialEntryList?.firstOrNull()?.credentialType
357                             generateDisplayTitleTextResCode(singleEntryType!!,
358                                 authenticationEntryList)
359                         } else {
360                             if (authenticationEntryList.isNotEmpty() ||
361                                 sortedUserNameToCredentialEntryList.any { perNameEntryList ->
362                                     perNameEntryList.sortedCredentialEntryList.any { entry ->
363                                         entry.credentialType != CredentialType.PASSWORD &&
364                                             entry.credentialType != CredentialType.PASSKEY
365                                     }
366                                 }
367                             )
368                                 R.string.get_dialog_title_choose_sign_in_for
369                             else
370                                 R.string.get_dialog_title_choose_saved_sign_in_for
371                         },
372                         requestDisplayInfo.appName
373                     ),
374                 )
375             }
376         }
377         item { Divider(thickness = 24.dp, color = Color.Transparent) }
378         item {
379             CredentialContainerCard {
380                 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
381                     val usernameForCredentialSize = sortedUserNameToCredentialEntryList.size
382                     val authenticationEntrySize = authenticationEntryList.size
383                     // If true, render a view more button for the single truncated entry on the
384                     // front page.
385                     // Show max 4 entries in this primary page
386                     if (usernameForCredentialSize + authenticationEntrySize <= 4) {
387                         sortedUserNameToCredentialEntryList.forEach {
388                             CredentialEntryRow(
389                                 credentialEntryInfo = it.sortedCredentialEntryList.first(),
390                                 onEntrySelected = onEntrySelected,
391                                 enforceOneLine = true,
392                                 onTextLayout = {
393                                     showMoreForTruncatedEntry.value = it.hasVisualOverflow
394                                 }
395                             )
396                         }
397                         authenticationEntryList.forEach {
398                             AuthenticationEntryRow(
399                                 authenticationEntryInfo = it,
400                                 onEntrySelected = onEntrySelected,
401                                 enforceOneLine = true,
402                             )
403                         }
404                     } else if (usernameForCredentialSize < 4) {
405                         sortedUserNameToCredentialEntryList.forEach {
406                             CredentialEntryRow(
407                                 credentialEntryInfo = it.sortedCredentialEntryList.first(),
408                                 onEntrySelected = onEntrySelected,
409                                 enforceOneLine = true,
410                             )
411                         }
412                         authenticationEntryList.take(4 - usernameForCredentialSize).forEach {
413                             AuthenticationEntryRow(
414                                 authenticationEntryInfo = it,
415                                 onEntrySelected = onEntrySelected,
416                                 enforceOneLine = true,
417                             )
418                         }
419                     } else {
420                         sortedUserNameToCredentialEntryList.take(4).forEach {
421                             CredentialEntryRow(
422                                 credentialEntryInfo = it.sortedCredentialEntryList.first(),
423                                 onEntrySelected = onEntrySelected,
424                                 enforceOneLine = true,
425                             )
426                         }
427                     }
428                 }
429             }
430         }
431         item { Divider(thickness = 24.dp, color = Color.Transparent) }
432         var totalEntriesCount = sortedUserNameToCredentialEntryList
433             .flatMap { it.sortedCredentialEntryList }.size + authenticationEntryList
434             .size + providerInfoList.flatMap { it.actionEntryList }.size
435         if (providerDisplayInfo.remoteEntry != null) totalEntriesCount += 1
436         // Row horizontalArrangement differs on only one actionButton(should place on most
437         // left)/only one confirmButton(should place on most right)/two buttons exist the same
438         // time(should be one on the left, one on the right)
439         item {
440             CtaButtonRow(
441                 leftButton = if (totalEntriesCount > 1) {
442                     {
443                         ActionButton(
444                             stringResource(R.string.get_dialog_title_sign_in_options),
445                             onMoreOptionSelected
446                         )
447                     }
448                 } else if (showMoreForTruncatedEntry.value) {
449                     {
450                         ActionButton(
451                             stringResource(R.string.button_label_view_more),
452                             onMoreOptionSelected
453                         )
454                     }
455                 } else null,
456                 rightButton = if (activeEntry != null) { // Only one sign-in options exist
457                     {
458                         ConfirmButton(
459                             stringResource(R.string.string_continue),
460                             onClick = onConfirm
461                         )
462                     }
463                 } else null,
464             )
465         }
466     }
467     onLog(GetCredentialEvent.CREDMAN_GET_CRED_PRIMARY_SELECTION_CARD)
468 }
469 
470 internal const val MAX_ENTRY_FOR_PRIMARY_PAGE = 4
471 
472 /** Draws the primary credential selection page, used starting from android V. */
473 @Composable
PrimarySelectionCardVImplnull474 fun PrimarySelectionCardVImpl(
475     requestDisplayInfo: RequestDisplayInfo,
476     providerDisplayInfo: ProviderDisplayInfo,
477     providerInfoList: List<ProviderInfo>,
478     activeEntry: EntryInfo?,
479     onEntrySelected: (EntryInfo) -> Unit,
480     onConfirm: () -> Unit,
481     onMoreOptionSelected: () -> Unit,
482     onLog: @Composable (UiEventEnum) -> Unit,
483 ) {
484     val showMoreForTruncatedEntry = remember { mutableStateOf(false) }
485     val sortedUserNameToCredentialEntryList =
486         providerDisplayInfo.sortedUserNameToCredentialEntryList
487     val authenticationEntryList = providerDisplayInfo.authenticationEntryList
488     // Show at most 4 entries (credential type or locked type) in this primary page
489     val primaryPageCredentialEntryList =
490         sortedUserNameToCredentialEntryList.take(MAX_ENTRY_FOR_PRIMARY_PAGE)
491     val primaryPageLockedEntryList = authenticationEntryList.take(
492         max(0, MAX_ENTRY_FOR_PRIMARY_PAGE - primaryPageCredentialEntryList.size)
493     )
494     SheetContainerCard {
495         val preferTopBrandingContent = requestDisplayInfo.preferTopBrandingContent
496         val singleProviderId = findSingleProviderIdForPrimaryPage(
497                 primaryPageCredentialEntryList,
498                 primaryPageLockedEntryList
499         )
500         if (preferTopBrandingContent != null) {
501             item {
502                 HeadlineProviderIconAndName(
503                     preferTopBrandingContent.icon,
504                     preferTopBrandingContent.displayName
505                 )
506             }
507         } else {
508             // When only one provider's entries will be displayed on the primary page, display that
509             // provider's icon + name up top.
510             if (singleProviderId != null) {
511                 // First should always work but just to be safe.
512                 val providerInfo = providerInfoList.firstOrNull { it.id == singleProviderId }
513                 if (providerInfo != null) {
514                     item {
515                         HeadlineProviderIconAndName(
516                             providerInfo.icon,
517                             providerInfo.displayName
518                         )
519                     }
520                 }
521             }
522         }
523 
524         val hasSingleEntry = primaryPageCredentialEntryList.size +
525                 primaryPageLockedEntryList.size == 1
526         val areAllPasswordsOnPrimaryScreen = primaryPageLockedEntryList.isEmpty() &&
527                 primaryPageCredentialEntryList.all {
528                     it.sortedCredentialEntryList.first().credentialType == CredentialType.PASSWORD
529                 }
530         val areAllPasskeysOnPrimaryScreen = primaryPageLockedEntryList.isEmpty() &&
531                 primaryPageCredentialEntryList.all {
532                     it.sortedCredentialEntryList.first().credentialType == CredentialType.PASSKEY
533                 }
534         item {
535             if (requestDisplayInfo.preferIdentityDocUi) {
536                 HeadlineText(
537                     text = stringResource(
538                         if (hasSingleEntry) {
539                             R.string.get_dialog_title_use_info_on
540                         } else {
541                             R.string.get_dialog_title_choose_option_for
542                         },
543                         requestDisplayInfo.appName
544                     ),
545                 )
546             } else {
547                 HeadlineText(
548                     text = stringResource(
549                         if (hasSingleEntry) {
550                             if (areAllPasskeysOnPrimaryScreen)
551                                 R.string.get_dialog_title_use_passkey_for
552                             else if (areAllPasswordsOnPrimaryScreen)
553                                 R.string.get_dialog_title_use_password_for
554                             else if (authenticationEntryList.isNotEmpty())
555                                 R.string.get_dialog_title_unlock_options_for
556                             else R.string.get_dialog_title_use_sign_in_for
557                         } else {
558                             if (areAllPasswordsOnPrimaryScreen)
559                                 R.string.get_dialog_title_choose_password_for
560                             else if (areAllPasskeysOnPrimaryScreen)
561                                 R.string.get_dialog_title_choose_passkey_for
562                             else
563                                 R.string.get_dialog_title_choose_sign_in_for
564                         },
565                         requestDisplayInfo.appName
566                     ),
567                 )
568             }
569         }
570         item { Divider(thickness = 24.dp, color = Color.Transparent) }
571         item {
572             CredentialContainerCard {
573                 Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
574                     primaryPageCredentialEntryList.forEach {
575                         val entry = it.sortedCredentialEntryList.first()
576                         CredentialEntryRow(
577                                 credentialEntryInfo = entry,
578                                 onEntrySelected = onEntrySelected,
579                                 enforceOneLine = true,
580                                 onTextLayout = {
581                                     showMoreForTruncatedEntry.value = it.hasVisualOverflow
582                                 },
583                                 hasSingleEntry = hasSingleEntry,
584                                 hasSingleProvider = singleProviderId != null,
585                                 shouldOverrideIcon = entry.isDefaultIconPreferredAsSingleProvider &&
586                                         (singleProviderId != null),
587                                 shouldRemoveTypeDisplayName = areAllPasswordsOnPrimaryScreen ||
588                                         areAllPasskeysOnPrimaryScreen
589                         )
590                     }
591                     primaryPageLockedEntryList.forEach {
592                         AuthenticationEntryRow(
593                                 authenticationEntryInfo = it,
594                                 onEntrySelected = onEntrySelected,
595                                 enforceOneLine = true,
596                         )
597                     }
598                 }
599             }
600         }
601         item { Divider(thickness = 24.dp, color = Color.Transparent) }
602         var totalEntriesCount = sortedUserNameToCredentialEntryList
603             .flatMap { it.sortedCredentialEntryList }.size + authenticationEntryList
604             .size + providerInfoList.flatMap { it.actionEntryList }.size
605         if (providerDisplayInfo.remoteEntry != null) totalEntriesCount += 1
606         // Row horizontalArrangement differs on only one actionButton(should place on most
607         // left)/only one confirmButton(should place on most right)/two buttons exist the same
608         // time(should be one on the left, one on the right)
609         item {
610             CtaButtonRow(
611                 leftButton = if (totalEntriesCount > 1) {
612                     {
613                         ActionButton(
614                             stringResource(R.string.get_dialog_title_sign_in_options),
615                             onMoreOptionSelected
616                         )
617                     }
618                 } else if (showMoreForTruncatedEntry.value) {
619                     {
620                         ActionButton(
621                             stringResource(R.string.button_label_view_more),
622                             onMoreOptionSelected
623                         )
624                     }
625                 } else null,
626                 rightButton = if (activeEntry != null) { // Only one sign-in options exist
627                     {
628                         ConfirmButton(
629                             stringResource(R.string.string_continue),
630                             onClick = onConfirm
631                         )
632                     }
633                 } else null,
634             )
635         }
636     }
637     onLog(GetCredentialEvent.CREDMAN_GET_CRED_PRIMARY_SELECTION_CARD)
638 }
639 
640 /**
641  * Attempt to find a single provider id, if it has supplied all the entries to be displayed on the
642  * front page; otherwise if multiple providers are found, return null.
643  */
findSingleProviderIdForPrimaryPagenull644 private fun findSingleProviderIdForPrimaryPage(
645     primaryPageCredentialEntryList: List<PerUserNameCredentialEntryList>,
646     primaryPageLockedEntryList: List<AuthenticationEntryInfo>
647 ): String? {
648     var providerId: String? = null
649     primaryPageCredentialEntryList.forEach {
650         val currProviderId = it.sortedCredentialEntryList.first().providerId
651         if (providerId == null) {
652             providerId = currProviderId
653         } else if (providerId != currProviderId) {
654             return null
655         }
656     }
657     primaryPageLockedEntryList.forEach {
658         val currProviderId = it.providerId
659         if (providerId == null) {
660             providerId = currProviderId
661         } else if (providerId != currProviderId) {
662             return null
663         }
664     }
665     return providerId
666 }
667 
668 /**
669  * Draws the secondary credential selection page, where all sign-in options are listed.
670  *
671  * By default, this card has 'back' navigation whereby user can navigate back to invoke
672  * [onBackButtonClicked]. However if a different top bar with possibly a different navigation
673  * is required, then the caller of this Composable can set a [customTopBar].
674  */
675 @Composable
AllSignInOptionCardnull676 fun AllSignInOptionCard(
677     providerInfoList: List<ProviderInfo>,
678     providerDisplayInfo: ProviderDisplayInfo,
679     onEntrySelected: (EntryInfo) -> Unit,
680     onBackButtonClicked: () -> Unit,
681     onCancel: () -> Unit,
682     onLog: @Composable (UiEventEnum) -> Unit,
683     customTopBar: (@Composable() () -> Unit)? = null
684 ) {
685     val sortedUserNameToCredentialEntryList =
686         providerDisplayInfo.sortedUserNameToCredentialEntryList
687     val authenticationEntryList = providerDisplayInfo.authenticationEntryList
688     SheetContainerCard(topAppBar = {
689         if (customTopBar != null) {
690             customTopBar()
691         } else {
692             MoreOptionTopAppBar(
693                     text = stringResource(R.string.get_dialog_title_sign_in_options),
694                     onNavigationIconClicked = onBackButtonClicked,
695                     bottomPadding = 0.dp,
696                     navigationIcon = Icons.Filled.ArrowBack,
697                     navigationIconContentDescription = stringResource(
698                             R.string.accessibility_back_arrow_button
699             ))
700         }
701     }) {
702         var isFirstSection = true
703         // For username
704         items(sortedUserNameToCredentialEntryList) { item ->
705             PerUserNameCredentials(
706                 perUserNameCredentialEntryList = item,
707                 onEntrySelected = onEntrySelected,
708                 isFirstSection = isFirstSection,
709             )
710             isFirstSection = false
711         }
712         // Locked password manager
713         if (authenticationEntryList.isNotEmpty()) {
714             item {
715                 LockedCredentials(
716                     authenticationEntryList = authenticationEntryList,
717                     onEntrySelected = onEntrySelected,
718                     isFirstSection = isFirstSection,
719                 )
720                 isFirstSection = false
721             }
722         }
723         // From another device
724         val remoteEntry = providerDisplayInfo.remoteEntry
725         if (remoteEntry != null) {
726             item {
727                 RemoteEntryCard(
728                     remoteEntry = remoteEntry,
729                     onEntrySelected = onEntrySelected,
730                     isFirstSection = isFirstSection,
731                 )
732                 isFirstSection = false
733             }
734         }
735         // Manage sign-ins (action chips)
736         item {
737             ActionChips(
738                 providerInfoList = providerInfoList,
739                 onEntrySelected = onEntrySelected,
740                 isFirstSection = isFirstSection,
741             )
742             isFirstSection = false
743         }
744     }
745     onLog(GetCredentialEvent.CREDMAN_GET_CRED_ALL_SIGN_IN_OPTION_CARD)
746 }
747 
748 @Composable
HeadlineProviderIconAndNamenull749 fun HeadlineProviderIconAndName(
750     icon: Drawable,
751     name: String,
752 ) {
753     HeadlineIcon(
754         bitmap = icon.toBitmap().asImageBitmap(),
755         tint = Color.Unspecified,
756     )
757     Divider(thickness = 4.dp, color = Color.Transparent)
758     LargeLabelTextOnSurfaceVariant(text = name)
759     Divider(thickness = 16.dp, color = Color.Transparent)
760 }
761 
762 @Composable
ActionChipsnull763 fun ActionChips(
764     providerInfoList: List<ProviderInfo>,
765     onEntrySelected: (EntryInfo) -> Unit,
766     isFirstSection: Boolean,
767 ) {
768     val actionChips = providerInfoList.flatMap { it.actionEntryList }
769     if (actionChips.isEmpty()) {
770         return
771     }
772 
773     CredentialListSectionHeader(
774         text = stringResource(R.string.get_dialog_heading_manage_sign_ins),
775         isFirstSection = isFirstSection,
776     )
777     CredentialContainerCard {
778         Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
779             actionChips.forEach {
780                 ActionEntryRow(it, onEntrySelected)
781             }
782         }
783     }
784 }
785 
786 @Composable
RemoteEntryCardnull787 fun RemoteEntryCard(
788     remoteEntry: RemoteEntryInfo,
789     onEntrySelected: (EntryInfo) -> Unit,
790     isFirstSection: Boolean,
791 ) {
792     CredentialListSectionHeader(
793         text = stringResource(R.string.get_dialog_heading_from_another_device),
794         isFirstSection = isFirstSection,
795     )
796     CredentialContainerCard {
797         Column(
798             modifier = Modifier.fillMaxWidth().wrapContentHeight(),
799             verticalArrangement = Arrangement.spacedBy(2.dp),
800         ) {
801             Entry(
802                 onClick = { onEntrySelected(remoteEntry) },
803                 iconImageVector = Icons.Outlined.QrCodeScanner,
804                 entryHeadlineText = stringResource(
805                     R.string.get_dialog_option_headline_use_a_different_device
806                 ),
807             )
808         }
809     }
810 }
811 
812 @Composable
LockedCredentialsnull813 fun LockedCredentials(
814     authenticationEntryList: List<AuthenticationEntryInfo>,
815     onEntrySelected: (EntryInfo) -> Unit,
816     isFirstSection: Boolean,
817 ) {
818     CredentialListSectionHeader(
819         text = stringResource(R.string.get_dialog_heading_locked_password_managers),
820         isFirstSection = isFirstSection,
821     )
822     CredentialContainerCard {
823         Column(
824             modifier = Modifier.fillMaxWidth().wrapContentHeight(),
825             verticalArrangement = Arrangement.spacedBy(2.dp),
826         ) {
827             authenticationEntryList.forEach {
828                 AuthenticationEntryRow(it, onEntrySelected)
829             }
830         }
831     }
832 }
833 
834 @Composable
PerUserNameCredentialsnull835 fun PerUserNameCredentials(
836     perUserNameCredentialEntryList: PerUserNameCredentialEntryList,
837     onEntrySelected: (EntryInfo) -> Unit,
838     isFirstSection: Boolean,
839 ) {
840     CredentialListSectionHeader(
841         text = stringResource(
842             R.string.get_dialog_heading_for_username, perUserNameCredentialEntryList.userName
843         ),
844         isFirstSection = isFirstSection,
845     )
846     CredentialContainerCard {
847         Column(
848             modifier = Modifier.fillMaxWidth().wrapContentHeight(),
849             verticalArrangement = Arrangement.spacedBy(2.dp),
850         ) {
851             perUserNameCredentialEntryList.sortedCredentialEntryList.forEach {
852                 CredentialEntryRow(it, onEntrySelected)
853             }
854         }
855     }
856 }
857 
858 @Composable
CredentialEntryRownull859 fun CredentialEntryRow(
860     credentialEntryInfo: CredentialEntryInfo,
861     onEntrySelected: (EntryInfo) -> Unit,
862     enforceOneLine: Boolean = false,
863     onTextLayout: (TextLayoutResult) -> Unit = {},
864     // Make optional since the secondary page doesn't care about this value.
865     hasSingleEntry: Boolean = false,
866     // For primary page only, if all display entries come from the same provider AND if that
867     // provider has opted in via isDefaultIconPreferredAsSingleProvider, then we override the
868     // display icon to the default icon for the given credential type.
869     shouldOverrideIcon: Boolean = false,
870     // For primary page only, if all entries come from the same provider, then remove that provider
871     // name from each entry, since that provider icon + name will be shown front and central at
872     // the top of the bottom sheet.
873     hasSingleProvider: Boolean = false,
874     // For primary page only, if all visible entrise are of the same type and that type is passkey
875     // or password, then set this bit to true to remove the type display name from each entry for
876     // simplification, since that info is mentioned in the title.
877     shouldRemoveTypeDisplayName: Boolean = false,
878 ) {
879     val (username, displayName) = if (credentialEntryInfo.credentialType == CredentialType.PASSKEY)
880         userAndDisplayNameForPasskey(
881             credentialEntryInfo.userName, credentialEntryInfo.displayName ?: "")
882     else Pair(credentialEntryInfo.userName, credentialEntryInfo.displayName)
883 
884     // For primary page, if
885     val overrideIcon: Painter? =
886         if (shouldOverrideIcon) {
887             when (credentialEntryInfo.credentialType) {
888                 CredentialType.PASSKEY -> painterResource(R.drawable.ic_passkey_24)
889                 CredentialType.PASSWORD -> painterResource(R.drawable.ic_password_24)
890                 else -> painterResource(R.drawable.ic_other_sign_in_24)
891             }
892         } else null
893 
894     Entry(
<lambda>null895         onClick = { onEntrySelected(credentialEntryInfo) },
896         iconImageBitmap =
897         if (overrideIcon == null) credentialEntryInfo.icon?.toBitmap()?.asImageBitmap() else null,
898         shouldApplyIconImageBitmapTint = credentialEntryInfo.shouldTintIcon,
899         // Fall back to iconPainter if iconImageBitmap isn't available
900         iconPainter =
901         if (overrideIcon != null) overrideIcon
902         else if (credentialEntryInfo.icon == null) painterResource(R.drawable.ic_other_sign_in_24)
903         else null,
904         entryHeadlineText = username,
905         entrySecondLineText = displayName,
906         entryThirdLineText =
907         (if (hasSingleEntry)
908             if (shouldRemoveTypeDisplayName) emptyList()
909             // Still show the type display name for all non-password/passkey types since it won't be
910             // mentioned in the bottom sheet heading.
911             else listOf(credentialEntryInfo.credentialTypeDisplayName)
912         else listOf(
913                 if (shouldRemoveTypeDisplayName) null
914                 else credentialEntryInfo.credentialTypeDisplayName,
915                 if (hasSingleProvider) null else credentialEntryInfo.providerDisplayName
<lambda>null916         )).filterNot{ it.isNullOrBlank() }.let { itemsToDisplay ->
917             if (itemsToDisplay.isEmpty()) null
918             else itemsToDisplay.joinToString(
919                 separator = stringResource(R.string.get_dialog_sign_in_type_username_separator)
920             )
921         },
922         enforceOneLine = enforceOneLine,
923         onTextLayout = onTextLayout,
924         affiliatedDomainText = credentialEntryInfo.affiliatedDomain,
925     )
926 }
927 
928 @Composable
AuthenticationEntryRownull929 fun AuthenticationEntryRow(
930     authenticationEntryInfo: AuthenticationEntryInfo,
931     onEntrySelected: (EntryInfo) -> Unit,
932     enforceOneLine: Boolean = false,
933 ) {
934     Entry(
935         onClick = if (authenticationEntryInfo.isUnlockedAndEmpty) {
936             {}
937         } // No-op
938         else {
939             { onEntrySelected(authenticationEntryInfo) }
940         },
941         iconImageBitmap = authenticationEntryInfo.icon.toBitmap().asImageBitmap(),
942         entryHeadlineText = authenticationEntryInfo.title,
943         entrySecondLineText = stringResource(
944             if (authenticationEntryInfo.isUnlockedAndEmpty)
945                 R.string.locked_credential_entry_label_subtext_no_sign_in
946             else R.string.locked_credential_entry_label_subtext_tap_to_unlock
947         ),
948         isLockedAuthEntry = !authenticationEntryInfo.isUnlockedAndEmpty,
949         enforceOneLine = enforceOneLine,
950     )
951 }
952 
953 @Composable
ActionEntryRownull954 fun ActionEntryRow(
955     actionEntryInfo: ActionEntryInfo,
956     onEntrySelected: (EntryInfo) -> Unit,
957 ) {
958     ActionEntry(
959         iconImageBitmap = actionEntryInfo.icon.toBitmap().asImageBitmap(),
960         entryHeadlineText = actionEntryInfo.title,
961         entrySecondLineText = actionEntryInfo.subTitle,
962         onClick = { onEntrySelected(actionEntryInfo) },
963     )
964 }
965 
966 @Composable
RemoteCredentialSnackBarScreennull967 fun RemoteCredentialSnackBarScreen(
968     onClick: (Boolean) -> Unit,
969     onCancel: () -> Unit,
970     onLog: @Composable (UiEventEnum) -> Unit,
971 ) {
972     Snackbar(
973         action = {
974             TextButton(
975                 modifier = Modifier.padding(top = 4.dp, bottom = 4.dp, start = 16.dp)
976                     .heightIn(min = 32.dp),
977                 onClick = { onClick(true) },
978                 contentPadding =
979                 PaddingValues(start = 0.dp, top = 6.dp, end = 0.dp, bottom = 6.dp),
980             ) {
981                 SnackbarActionText(text = stringResource(R.string.snackbar_action))
982             }
983         },
984         onDismiss = onCancel,
985         contentText = stringResource(R.string.get_dialog_use_saved_passkey_for),
986     )
987     onLog(GetCredentialEvent.CREDMAN_GET_CRED_REMOTE_CRED_SNACKBAR_SCREEN)
988 }
989 
990 @Composable
EmptyAuthEntrySnackBarScreennull991 fun EmptyAuthEntrySnackBarScreen(
992     authenticationEntryList: List<AuthenticationEntryInfo>,
993     onCancel: () -> Unit,
994     onLastLockedAuthEntryNotFound: () -> Unit,
995     onLog: @Composable (UiEventEnum) -> Unit,
996 ) {
997     val lastLocked = authenticationEntryList.firstOrNull({ it.isLastUnlocked })
998     if (lastLocked == null) {
999         onLastLockedAuthEntryNotFound()
1000         return
1001     }
1002 
1003     Snackbar(
1004         onDismiss = onCancel,
1005         contentText = stringResource(R.string.no_sign_in_info_in, lastLocked.providerDisplayName),
1006     )
1007     onLog(GetCredentialEvent.CREDMAN_GET_CRED_SCREEN_EMPTY_AUTH_SNACKBAR_SCREEN)
1008 }
1009