1 /*
2  * 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
18 
19 import android.app.Activity
20 import android.os.IBinder
21 import android.text.TextUtils
22 import android.util.Log
23 import androidx.activity.compose.ManagedActivityResultLauncher
24 import androidx.activity.result.ActivityResult
25 import androidx.activity.result.IntentSenderRequest
26 import androidx.compose.runtime.Composable
27 import androidx.compose.runtime.getValue
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.setValue
30 import androidx.lifecycle.ViewModel
31 import com.android.credentialmanager.common.BaseEntry
32 import com.android.credentialmanager.common.Constants
33 import com.android.credentialmanager.common.DialogState
34 import com.android.credentialmanager.common.ProviderActivityResult
35 import com.android.credentialmanager.common.ProviderActivityState
36 import com.android.credentialmanager.createflow.ActiveEntry
37 import com.android.credentialmanager.createflow.isFlowAutoSelectable
38 import com.android.credentialmanager.createflow.CreateCredentialUiState
39 import com.android.credentialmanager.createflow.CreateScreenState
40 import com.android.credentialmanager.getflow.GetCredentialUiState
41 import com.android.credentialmanager.getflow.GetScreenState
42 import com.android.credentialmanager.logging.LifecycleEvent
43 import com.android.credentialmanager.logging.UIMetrics
44 import com.android.internal.logging.UiEventLogger.UiEventEnum
45 
46 /** One and only one of create or get state can be active at any given time. */
47 data class UiState(
48     val createCredentialUiState: CreateCredentialUiState?,
49     val getCredentialUiState: GetCredentialUiState?,
50     val selectedEntry: BaseEntry? = null,
51     val providerActivityState: ProviderActivityState = ProviderActivityState.NOT_APPLICABLE,
52     val dialogState: DialogState = DialogState.ACTIVE,
53     // True if the UI has one and only one auto selectable entry. Its provider activity will be
54     // launched immediately, and canceling it will cancel the whole UI flow.
55     val isAutoSelectFlow: Boolean = false,
56     val cancelRequestState: CancelUiRequestState?,
57     val isInitialRender: Boolean,
58 )
59 
60 data class CancelUiRequestState(
61     val appDisplayName: String?,
62 )
63 
64 class CredentialSelectorViewModel(
65     private var credManRepo: CredentialManagerRepo,
66     private val userConfigRepo: UserConfigRepo,
67 ) : ViewModel() {
68     var uiState by mutableStateOf(credManRepo.initState())
69         private set
70 
71     var uiMetrics: UIMetrics = UIMetrics()
72 
73     init {
74         uiMetrics.logNormal(LifecycleEvent.CREDMAN_ACTIVITY_INIT,
75             credManRepo.requestInfo?.appPackageName)
76     }
77 
78     /**************************************************************************/
79     /*****                       Shared Callbacks                         *****/
80     /**************************************************************************/
81     fun onUserCancel() {
82         Log.d(Constants.LOG_TAG, "User cancelled, finishing the ui")
83         credManRepo.onUserCancel()
84         uiState = uiState.copy(dialogState = DialogState.COMPLETE)
85     }
86 
87     fun onInitialRenderComplete() {
88         uiState = uiState.copy(isInitialRender = false)
89     }
90 
91     fun onCancellationUiRequested(appDisplayName: String?) {
92         uiState = uiState.copy(cancelRequestState = CancelUiRequestState(appDisplayName))
93     }
94 
95     /** Close the activity and don't report anything to the backend.
96      *  Example use case is the no-auth-info snackbar where the activity should simply display the
97      *  UI and then be dismissed. */
98     fun silentlyFinishActivity() {
99         Log.d(Constants.LOG_TAG, "Silently finishing the ui")
100         uiState = uiState.copy(dialogState = DialogState.COMPLETE)
101     }
102 
103     fun onNewCredentialManagerRepo(credManRepo: CredentialManagerRepo) {
104         this.credManRepo = credManRepo
105         uiState = credManRepo.initState().copy(isInitialRender = false)
106 
107         if (this.credManRepo.requestInfo?.token != credManRepo.requestInfo?.token) {
108             this.uiMetrics.resetInstanceId()
109             this.uiMetrics.logNormal(LifecycleEvent.CREDMAN_ACTIVITY_NEW_REQUEST,
110                 credManRepo.requestInfo?.appPackageName)
111         }
112     }
113 
114     fun launchProviderUi(
115         launcher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>
116     ) {
117         val entry = uiState.selectedEntry
118         if (entry != null && entry.pendingIntent != null) {
119             Log.d(Constants.LOG_TAG, "Launching provider activity")
120             uiState = uiState.copy(providerActivityState = ProviderActivityState.PENDING)
121             val entryIntent = entry.fillInIntent
122             entryIntent?.putExtra(Constants.IS_AUTO_SELECTED_KEY, uiState.isAutoSelectFlow)
123             val intentSenderRequest = IntentSenderRequest.Builder(entry.pendingIntent)
124                 .setFillInIntent(entryIntent).build()
125             try {
126                 launcher.launch(intentSenderRequest)
127             } catch (e: Exception) {
128                 Log.w(Constants.LOG_TAG, "Failed to launch provider UI: $e")
129                 onInternalError()
130             }
131         } else {
132             Log.d(Constants.LOG_TAG, "No provider UI to launch")
133             onInternalError()
134         }
135     }
136 
137     fun onProviderActivityResult(providerActivityResult: ProviderActivityResult) {
138         val entry = uiState.selectedEntry
139         val resultCode = providerActivityResult.resultCode
140         val resultData = providerActivityResult.data
141         if (resultCode == Activity.RESULT_CANCELED) {
142             // Re-display the CredMan UI if the user canceled from the provider UI, or cancel
143             // the UI if this is the auto select flow.
144             if (uiState.isAutoSelectFlow) {
145                 Log.d(Constants.LOG_TAG, "The auto selected provider activity was cancelled," +
146                     " ending the credential manager activity.")
147                 onUserCancel()
148             } else {
149                 Log.d(Constants.LOG_TAG, "The provider activity was cancelled," +
150                     " re-displaying our UI.")
151                 uiState = uiState.copy(
152                     selectedEntry = null,
153                     providerActivityState = ProviderActivityState.NOT_APPLICABLE,
154                 )
155             }
156         } else {
157             if (entry != null) {
158                 Log.d(
159                     Constants.LOG_TAG, "Got provider activity result: {provider=" +
160                     "${entry.providerId}, key=${entry.entryKey}, subkey=${entry.entrySubkey}" +
161                     ", resultCode=$resultCode, resultData=$resultData}"
162                 )
163                 credManRepo.onOptionSelected(
164                     entry.providerId, entry.entryKey, entry.entrySubkey,
165                     resultCode, resultData,
166                 )
167                 if (entry.shouldTerminateUiUponSuccessfulProviderResult) {
168                     uiState = uiState.copy(dialogState = DialogState.COMPLETE)
169                 }
170             } else {
171                 Log.w(Constants.LOG_TAG,
172                     "Illegal state: received a provider result but found no matching entry.")
173                 onInternalError()
174             }
175         }
176     }
177 
178     fun onLastLockedAuthEntryNotFoundError() {
179         Log.d(Constants.LOG_TAG, "Unable to find the last unlocked entry")
180         onInternalError()
181     }
182 
183     fun onIllegalUiState(errorMessage: String) {
184         Log.w(Constants.LOG_TAG, errorMessage)
185         onInternalError()
186     }
187 
188     private fun onInternalError() {
189         Log.w(Constants.LOG_TAG, "UI closed due to illegal internal state")
190         this.uiMetrics.logNormal(LifecycleEvent.CREDMAN_ACTIVITY_INTERNAL_ERROR,
191             credManRepo.requestInfo?.appPackageName)
192         credManRepo.onParsingFailureCancel()
193         uiState = uiState.copy(dialogState = DialogState.COMPLETE)
194     }
195 
196     /** Return true if the current UI's request token matches the UI cancellation request token. */
197     fun shouldCancelCurrentUi(cancelRequestToken: IBinder): Boolean {
198         return credManRepo.requestInfo?.token?.equals(cancelRequestToken) ?: false
199     }
200 
201     /**************************************************************************/
202     /*****                      Get Flow Callbacks                        *****/
203     /**************************************************************************/
204     fun getFlowOnEntrySelected(entry: BaseEntry) {
205         Log.d(Constants.LOG_TAG, "credential selected: {provider=${entry.providerId}" +
206             ", key=${entry.entryKey}, subkey=${entry.entrySubkey}}")
207         uiState = if (entry.pendingIntent != null) {
208             uiState.copy(
209                 selectedEntry = entry,
210                 providerActivityState = ProviderActivityState.READY_TO_LAUNCH,
211             )
212         } else {
213             credManRepo.onOptionSelected(entry.providerId, entry.entryKey, entry.entrySubkey)
214             uiState.copy(dialogState = DialogState.COMPLETE)
215         }
216     }
217 
218     fun getFlowOnConfirmEntrySelected() {
219         val activeEntry = uiState.getCredentialUiState?.activeEntry
220         if (activeEntry != null) {
221             getFlowOnEntrySelected(activeEntry)
222         } else {
223             Log.d(Constants.LOG_TAG,
224                 "Illegal state: confirm is pressed but activeEntry isn't set.")
225             onInternalError()
226         }
227     }
228 
229     fun getFlowOnMoreOptionSelected() {
230         Log.d(Constants.LOG_TAG, "More Option selected")
231         uiState = uiState.copy(
232             getCredentialUiState = uiState.getCredentialUiState?.copy(
233                 currentScreenState = GetScreenState.ALL_SIGN_IN_OPTIONS
234             )
235         )
236     }
237 
238     fun getFlowOnMoreOptionOnSnackBarSelected(isNoAccount: Boolean) {
239         Log.d(Constants.LOG_TAG, "More Option on snackBar selected")
240         uiState = uiState.copy(
241             getCredentialUiState = uiState.getCredentialUiState?.copy(
242                 currentScreenState = GetScreenState.ALL_SIGN_IN_OPTIONS,
243                 isNoAccount = isNoAccount,
244             ),
245             isInitialRender = true,
246         )
247     }
248 
249     fun getFlowOnBackToHybridSnackBarScreen() {
250         uiState = uiState.copy(
251             getCredentialUiState = uiState.getCredentialUiState?.copy(
252                 currentScreenState = GetScreenState.REMOTE_ONLY
253             )
254         )
255     }
256 
257     fun getFlowOnBackToPrimarySelectionScreen() {
258         uiState = uiState.copy(
259             getCredentialUiState = uiState.getCredentialUiState?.copy(
260                 currentScreenState = GetScreenState.PRIMARY_SELECTION
261             )
262         )
263     }
264 
265     /**************************************************************************/
266     /*****                     Create Flow Callbacks                      *****/
267     /**************************************************************************/
268     fun createFlowOnConfirmIntro() {
269         userConfigRepo.setIsPasskeyFirstUse(false)
270         val prevUiState = uiState.createCredentialUiState
271         if (prevUiState == null) {
272             Log.d(Constants.LOG_TAG, "Encountered unexpected null create ui state")
273             onInternalError()
274             return
275         }
276         val newScreenState = CreateFlowUtils.toCreateScreenState(
277             createOptionSize = prevUiState.sortedCreateOptionsPairs.size,
278             isOnPasskeyIntroStateAlready = true,
279             requestDisplayInfo = prevUiState.requestDisplayInfo,
280             remoteEntry = prevUiState.remoteEntry,
281             isPasskeyFirstUse = true,
282         )
283         if (newScreenState == null) {
284             Log.d(Constants.LOG_TAG, "Unexpected: couldn't resolve new screen state")
285             onInternalError()
286             return
287         }
288         val newCreateCredentialUiState = prevUiState.copy(
289             currentScreenState = newScreenState,
290         )
291         val isFlowAutoSelectable = isFlowAutoSelectable(newCreateCredentialUiState)
292         uiState = uiState.copy(
293             createCredentialUiState = newCreateCredentialUiState,
294             isAutoSelectFlow = isFlowAutoSelectable,
295             providerActivityState =
296             if (isFlowAutoSelectable) ProviderActivityState.READY_TO_LAUNCH
297             else ProviderActivityState.NOT_APPLICABLE,
298             selectedEntry =
299             if (isFlowAutoSelectable) newCreateCredentialUiState.activeEntry?.activeEntryInfo
300             else null,
301         )
302     }
303 
304     fun createFlowOnMoreOptionsSelectedOnCreationSelection() {
305         uiState = uiState.copy(
306             createCredentialUiState = uiState.createCredentialUiState?.copy(
307                 currentScreenState = CreateScreenState.MORE_OPTIONS_SELECTION,
308             )
309         )
310     }
311 
312     fun createFlowOnBackCreationSelectionButtonSelected() {
313         uiState = uiState.copy(
314             createCredentialUiState = uiState.createCredentialUiState?.copy(
315                 currentScreenState = CreateScreenState.CREATION_OPTION_SELECTION,
316             )
317         )
318     }
319 
320     fun createFlowOnBackPasskeyIntroButtonSelected() {
321         uiState = uiState.copy(
322             createCredentialUiState = uiState.createCredentialUiState?.copy(
323                 currentScreenState = CreateScreenState.PASSKEY_INTRO,
324             )
325         )
326     }
327 
328     fun createFlowOnEntrySelectedFromMoreOptionScreen(activeEntry: ActiveEntry) {
329         uiState = uiState.copy(
330             createCredentialUiState = uiState.createCredentialUiState?.copy(
331                 currentScreenState =
332                 if (uiState.createCredentialUiState?.requestDisplayInfo?.userSetDefaultProviderIds
333                         ?.contains(activeEntry.activeProvider.id) ?: true ||
334                     !(uiState.createCredentialUiState?.foundCandidateFromUserDefaultProvider
335                     ?: false) ||
336                     !TextUtils.isEmpty(uiState.createCredentialUiState?.requestDisplayInfo
337                         ?.appPreferredDefaultProviderId))
338                     CreateScreenState.CREATION_OPTION_SELECTION
339                 else CreateScreenState.DEFAULT_PROVIDER_CONFIRMATION,
340                 activeEntry = activeEntry
341             )
342         )
343     }
344 
345     fun createFlowOnLaunchSettings() {
346         credManRepo.onSettingLaunchCancel()
347         uiState = uiState.copy(dialogState = DialogState.CANCELED_FOR_SETTINGS)
348     }
349 
350     fun createFlowOnLearnMore() {
351         uiState = uiState.copy(
352             createCredentialUiState = uiState.createCredentialUiState?.copy(
353                 currentScreenState = CreateScreenState.MORE_ABOUT_PASSKEYS_INTRO,
354             )
355         )
356     }
357 
358     fun createFlowOnUseOnceSelected() {
359         uiState = uiState.copy(
360             createCredentialUiState = uiState.createCredentialUiState?.copy(
361                 currentScreenState = CreateScreenState.CREATION_OPTION_SELECTION,
362             )
363         )
364     }
365 
366     fun createFlowOnEntrySelected(selectedEntry: BaseEntry) {
367         val providerId = selectedEntry.providerId
368         val entryKey = selectedEntry.entryKey
369         val entrySubkey = selectedEntry.entrySubkey
370         Log.d(
371             Constants.LOG_TAG, "Option selected for entry: " +
372             " {provider=$providerId, key=$entryKey, subkey=$entrySubkey")
373         if (selectedEntry.pendingIntent != null) {
374             uiState = uiState.copy(
375                 selectedEntry = selectedEntry,
376                 providerActivityState = ProviderActivityState.READY_TO_LAUNCH,
377             )
378         } else {
379             credManRepo.onOptionSelected(
380                 providerId,
381                 entryKey,
382                 entrySubkey
383             )
384             uiState = uiState.copy(dialogState = DialogState.COMPLETE)
385         }
386     }
387 
388     fun createFlowOnConfirmEntrySelected() {
389         val selectedEntry = uiState.createCredentialUiState?.activeEntry?.activeEntryInfo
390         if (selectedEntry != null) {
391             createFlowOnEntrySelected(selectedEntry)
392         } else {
393             Log.d(Constants.LOG_TAG,
394                 "Unexpected: confirm is pressed but no active entry exists.")
395             onInternalError()
396         }
397     }
398 
399     @Composable
400     fun logUiEvent(uiEventEnum: UiEventEnum) {
401         this.uiMetrics.log(uiEventEnum, credManRepo.requestInfo?.appPackageName)
402     }
403 }