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 }