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