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.getflow 18 19 import android.app.PendingIntent 20 import android.content.Intent 21 import android.graphics.drawable.Drawable 22 import com.android.credentialmanager.common.BaseEntry 23 import com.android.credentialmanager.common.CredentialType 24 import com.android.internal.util.Preconditions 25 26 import java.time.Instant 27 28 data class GetCredentialUiState( 29 val providerInfoList: List<ProviderInfo>, 30 val requestDisplayInfo: RequestDisplayInfo, 31 val providerDisplayInfo: ProviderDisplayInfo = toProviderDisplayInfo(providerInfoList), 32 val currentScreenState: GetScreenState = toGetScreenState(providerDisplayInfo), 33 val activeEntry: BaseEntry? = toActiveEntry(providerDisplayInfo), 34 val isNoAccount: Boolean = false, 35 ) 36 37 internal fun hasContentToDisplay(state: GetCredentialUiState): Boolean { 38 return state.providerDisplayInfo.sortedUserNameToCredentialEntryList.isNotEmpty() || 39 state.providerDisplayInfo.authenticationEntryList.isNotEmpty() || 40 (state.providerDisplayInfo.remoteEntry != null && 41 !state.requestDisplayInfo.preferImmediatelyAvailableCredentials) 42 } 43 44 internal fun findAutoSelectEntry(providerDisplayInfo: ProviderDisplayInfo): CredentialEntryInfo? { 45 if (providerDisplayInfo.authenticationEntryList.isNotEmpty()) { 46 return null 47 } 48 if (providerDisplayInfo.sortedUserNameToCredentialEntryList.size == 1) { 49 val entryList = providerDisplayInfo.sortedUserNameToCredentialEntryList.firstOrNull() 50 ?: return null 51 if (entryList.sortedCredentialEntryList.size == 1) { 52 val entry = entryList.sortedCredentialEntryList.firstOrNull() ?: return null 53 if (entry.isAutoSelectable) { 54 return entry 55 } 56 } 57 } 58 return null 59 } 60 61 data class ProviderInfo( 62 /** 63 * Unique id (component name) of this provider. 64 * Not for display purpose - [displayName] should be used for ui rendering. 65 */ 66 val id: String, 67 val icon: Drawable, 68 val displayName: String, 69 val credentialEntryList: List<CredentialEntryInfo>, 70 val authenticationEntryList: List<AuthenticationEntryInfo>, 71 val remoteEntry: RemoteEntryInfo?, 72 val actionEntryList: List<ActionEntryInfo>, 73 ) 74 75 /** Display-centric data structure derived from the [ProviderInfo]. This abstraction is not grouping 76 * by the provider id but instead focuses on structures convenient for display purposes. */ 77 data class ProviderDisplayInfo( 78 /** 79 * The credential entries grouped by userName, derived from all entries of the [providerInfoList]. 80 * Note that the list order matters to the display order. 81 */ 82 val sortedUserNameToCredentialEntryList: List<PerUserNameCredentialEntryList>, 83 val authenticationEntryList: List<AuthenticationEntryInfo>, 84 val remoteEntry: RemoteEntryInfo? 85 ) 86 87 class CredentialEntryInfo( 88 providerId: String, 89 entryKey: String, 90 entrySubkey: String, 91 pendingIntent: PendingIntent?, 92 fillInIntent: Intent?, 93 /** Type of this credential used for sorting. Not localized so must not be directly displayed. */ 94 val credentialType: CredentialType, 95 /** Localized type value of this credential used for display purpose. */ 96 val credentialTypeDisplayName: String, 97 val providerDisplayName: String, 98 val userName: String, 99 val displayName: String?, 100 val icon: Drawable?, 101 val shouldTintIcon: Boolean, 102 val lastUsedTimeMillis: Instant?, 103 val isAutoSelectable: Boolean, 104 ) : BaseEntry( 105 providerId, 106 entryKey, 107 entrySubkey, 108 pendingIntent, 109 fillInIntent, 110 shouldTerminateUiUponSuccessfulProviderResult = true, 111 ) 112 113 class AuthenticationEntryInfo( 114 providerId: String, 115 entryKey: String, 116 entrySubkey: String, 117 pendingIntent: PendingIntent?, 118 fillInIntent: Intent?, 119 val title: String, 120 val providerDisplayName: String, 121 val icon: Drawable, 122 // The entry had been unlocked and turned out to be empty. Used to determine whether to 123 // show "Tap to unlock" or "No sign-in info" for this entry. 124 val isUnlockedAndEmpty: Boolean, 125 // True if the entry was the last one unlocked. Used to show the no sign-in info snackbar. 126 val isLastUnlocked: Boolean, 127 ) : BaseEntry( 128 providerId, 129 entryKey, entrySubkey, 130 pendingIntent, 131 fillInIntent, 132 shouldTerminateUiUponSuccessfulProviderResult = false, 133 ) 134 135 class RemoteEntryInfo( 136 providerId: String, 137 entryKey: String, 138 entrySubkey: String, 139 pendingIntent: PendingIntent?, 140 fillInIntent: Intent?, 141 ) : BaseEntry( 142 providerId, 143 entryKey, 144 entrySubkey, 145 pendingIntent, 146 fillInIntent, 147 shouldTerminateUiUponSuccessfulProviderResult = true, 148 ) 149 150 class ActionEntryInfo( 151 providerId: String, 152 entryKey: String, 153 entrySubkey: String, 154 pendingIntent: PendingIntent?, 155 fillInIntent: Intent?, 156 val title: String, 157 val icon: Drawable, 158 val subTitle: String?, 159 ) : BaseEntry( 160 providerId, 161 entryKey, 162 entrySubkey, 163 pendingIntent, 164 fillInIntent, 165 shouldTerminateUiUponSuccessfulProviderResult = true, 166 ) 167 168 data class RequestDisplayInfo( 169 val appName: String, 170 val preferImmediatelyAvailableCredentials: Boolean, 171 val preferIdentityDocUi: Boolean, 172 // A top level branding icon + display name preferred by the app. 173 val preferTopBrandingContent: TopBrandingContent?, 174 ) 175 176 data class TopBrandingContent( 177 val icon: Drawable, 178 val displayName: String, 179 ) 180 181 /** 182 * @property userName the userName that groups all the entries in this list 183 * @property sortedCredentialEntryList the credential entries associated with the [userName] sorted 184 * by last used timestamps and then by credential types 185 */ 186 data class PerUserNameCredentialEntryList( 187 val userName: String, 188 val sortedCredentialEntryList: List<CredentialEntryInfo>, 189 ) 190 191 /** The name of the current screen. */ 192 enum class GetScreenState { 193 /** The primary credential selection page. */ 194 PRIMARY_SELECTION, 195 196 /** The secondary credential selection page, where all sign-in options are listed. */ 197 ALL_SIGN_IN_OPTIONS, 198 199 /** The snackbar only page when there's no account but only a remoteEntry. */ 200 REMOTE_ONLY, 201 202 /** The snackbar when there are only auth entries and all of them turn out to be empty. */ 203 UNLOCKED_AUTH_ENTRIES_ONLY, 204 } 205 206 // IMPORTANT: new invocation should be mindful that this method will throw if more than 1 remote 207 // entry exists 208 private fun toProviderDisplayInfo( 209 providerInfoList: List<ProviderInfo> 210 ): ProviderDisplayInfo { 211 val userNameToCredentialEntryMap = mutableMapOf<String, MutableList<CredentialEntryInfo>>() 212 val authenticationEntryList = mutableListOf<AuthenticationEntryInfo>() 213 val remoteEntryList = mutableListOf<RemoteEntryInfo>() 214 providerInfoList.forEach { providerInfo -> 215 authenticationEntryList.addAll(providerInfo.authenticationEntryList) 216 if (providerInfo.remoteEntry != null) { 217 remoteEntryList.add(providerInfo.remoteEntry) 218 } 219 // There can only be at most one remote entry 220 Preconditions.checkState(remoteEntryList.size <= 1) 221 222 providerInfo.credentialEntryList.forEach { 223 userNameToCredentialEntryMap.compute( 224 it.userName 225 ) { _, v -> 226 if (v == null) { 227 mutableListOf(it) 228 } else { 229 v.add(it) 230 v 231 } 232 } 233 } 234 } 235 236 // Compose sortedUserNameToCredentialEntryList 237 val comparator = CredentialEntryInfoComparatorByTypeThenTimestamp() 238 // Sort per username 239 userNameToCredentialEntryMap.values.forEach { 240 it.sortWith(comparator) 241 } 242 // Transform to list of PerUserNameCredentialEntryLists and then sort across usernames 243 val sortedUserNameToCredentialEntryList = userNameToCredentialEntryMap.map { 244 PerUserNameCredentialEntryList(it.key, it.value) 245 }.sortedWith( 246 compareByDescending { it.sortedCredentialEntryList.first().lastUsedTimeMillis } 247 ) 248 249 return ProviderDisplayInfo( 250 sortedUserNameToCredentialEntryList = sortedUserNameToCredentialEntryList, 251 authenticationEntryList = authenticationEntryList, 252 remoteEntry = remoteEntryList.getOrNull(0), 253 ) 254 } 255 256 private fun toActiveEntry( 257 providerDisplayInfo: ProviderDisplayInfo, 258 ): BaseEntry? { 259 val sortedUserNameToCredentialEntryList = 260 providerDisplayInfo.sortedUserNameToCredentialEntryList 261 val authenticationEntryList = providerDisplayInfo.authenticationEntryList 262 var activeEntry: BaseEntry? = null 263 if (sortedUserNameToCredentialEntryList 264 .size == 1 && authenticationEntryList.isEmpty() 265 ) { 266 activeEntry = sortedUserNameToCredentialEntryList.first().sortedCredentialEntryList.first() 267 } else if ( 268 sortedUserNameToCredentialEntryList 269 .isEmpty() && authenticationEntryList.size == 1 270 ) { 271 activeEntry = authenticationEntryList.first() 272 } 273 return activeEntry 274 } 275 276 private fun toGetScreenState( 277 providerDisplayInfo: ProviderDisplayInfo 278 ): GetScreenState { 279 return if (providerDisplayInfo.sortedUserNameToCredentialEntryList.isEmpty() && 280 providerDisplayInfo.remoteEntry == null && 281 providerDisplayInfo.authenticationEntryList.all { it.isUnlockedAndEmpty }) 282 GetScreenState.UNLOCKED_AUTH_ENTRIES_ONLY 283 else if (providerDisplayInfo.sortedUserNameToCredentialEntryList.isEmpty() && 284 providerDisplayInfo.authenticationEntryList.isEmpty() && 285 providerDisplayInfo.remoteEntry != null) 286 GetScreenState.REMOTE_ONLY 287 else GetScreenState.PRIMARY_SELECTION 288 } 289 290 internal class CredentialEntryInfoComparatorByTypeThenTimestamp : Comparator<CredentialEntryInfo> { 291 override fun compare(p0: CredentialEntryInfo, p1: CredentialEntryInfo): Int { 292 // First prefer passkey type for its security benefits 293 if (p0.credentialType != p1.credentialType) { 294 if (CredentialType.PASSKEY == p0.credentialType) { 295 return -1 296 } else if (CredentialType.PASSKEY == p1.credentialType) { 297 return 1 298 } 299 } 300 301 // Then order by last used timestamp 302 if (p0.lastUsedTimeMillis != null && p1.lastUsedTimeMillis != null) { 303 if (p0.lastUsedTimeMillis < p1.lastUsedTimeMillis) { 304 return 1 305 } else if (p0.lastUsedTimeMillis > p1.lastUsedTimeMillis) { 306 return -1 307 } 308 } else if (p0.lastUsedTimeMillis != null) { 309 return -1 310 } else if (p1.lastUsedTimeMillis != null) { 311 return 1 312 } 313 return 0 314 } 315 }