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.hardware.biometrics.BiometricPrompt
21 import android.hardware.biometrics.BiometricPrompt.AuthenticationResult
22 import android.os.CancellationSignal
23 import android.os.IBinder
24 import android.text.TextUtils
25 import android.util.Log
26 import androidx.activity.compose.ManagedActivityResultLauncher
27 import androidx.activity.result.ActivityResult
28 import androidx.activity.result.IntentSenderRequest
29 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.getValue
31 import androidx.compose.runtime.mutableStateOf
32 import androidx.compose.runtime.setValue
33 import androidx.lifecycle.ViewModel
34 import com.android.credentialmanager.common.BiometricError
35 import com.android.credentialmanager.common.BiometricFlowType
36 import com.android.credentialmanager.common.BiometricPromptState
37 import com.android.credentialmanager.common.BiometricResult
38 import com.android.credentialmanager.common.BiometricState
39 import com.android.credentialmanager.model.EntryInfo
40 import com.android.credentialmanager.common.Constants
41 import com.android.credentialmanager.common.DialogState
42 import com.android.credentialmanager.common.ProviderActivityResult
43 import com.android.credentialmanager.common.ProviderActivityState
44 import com.android.credentialmanager.createflow.ActiveEntry
45 import com.android.credentialmanager.createflow.CreateCredentialUiState
46 import com.android.credentialmanager.createflow.CreateScreenState
47 import com.android.credentialmanager.createflow.isBiometricFlow
48 import com.android.credentialmanager.getflow.GetCredentialUiState
49 import com.android.credentialmanager.getflow.GetScreenState
50 import com.android.credentialmanager.logging.LifecycleEvent
51 import com.android.credentialmanager.logging.UIMetrics
52 import com.android.internal.logging.UiEventLogger.UiEventEnum
53 
54 /** One and only one of create or get state can be active at any given time. */
55 data class UiState(
56     val createCredentialUiState: CreateCredentialUiState?,
57     val getCredentialUiState: GetCredentialUiState?,
58     val selectedEntry: EntryInfo? = null,
59     val providerActivityState: ProviderActivityState = ProviderActivityState.NOT_APPLICABLE,
60     val dialogState: DialogState = DialogState.ACTIVE,
61     // True if the UI has one and only one auto selectable entry. Its provider activity will be
62     // launched immediately, and canceling it will cancel the whole UI flow.
63     val isAutoSelectFlow: Boolean = false,
64     val cancelRequestState: CancelUiRequestState?,
65     val isInitialRender: Boolean,
66     val biometricState: BiometricState = BiometricState()
67 )
68 
69 data class CancelUiRequestState(
70     val appDisplayName: String?,
71 )
72 
73 class CredentialSelectorViewModel(
74     private var credManRepo: CredentialManagerRepo,
75 ) : ViewModel() {
76     var uiState by mutableStateOf(credManRepo.initState())
77         private set
78 
79     var uiMetrics: UIMetrics = UIMetrics()
80 
<lambda>null81     init {
82         uiMetrics.logNormal(LifecycleEvent.CREDMAN_ACTIVITY_INIT,
83             credManRepo.requestInfo?.packageName)
84     }
85 
86     /**************************************************************************/
87     /*****                       Shared Callbacks                         *****/
88     /**************************************************************************/
onUserCancelnull89     fun onUserCancel() {
90         Log.d(Constants.LOG_TAG, "User cancelled, finishing the ui")
91         credManRepo.onUserCancel()
92         uiState = uiState.copy(dialogState = DialogState.COMPLETE)
93     }
94 
onInitialRenderCompletenull95     fun onInitialRenderComplete() {
96         uiState = uiState.copy(isInitialRender = false)
97     }
98 
onCancellationUiRequestednull99     fun onCancellationUiRequested(appDisplayName: String?) {
100         uiState = uiState.copy(cancelRequestState = CancelUiRequestState(appDisplayName))
101     }
102 
103     /** Close the activity and don't report anything to the backend.
104      *  Example use case is the no-auth-info snackbar where the activity should simply display the
105      *  UI and then be dismissed. */
silentlyFinishActivitynull106     fun silentlyFinishActivity() {
107         Log.d(Constants.LOG_TAG, "Silently finishing the ui")
108         uiState = uiState.copy(dialogState = DialogState.COMPLETE)
109     }
110 
onNewCredentialManagerReponull111     fun onNewCredentialManagerRepo(credManRepo: CredentialManagerRepo) {
112         this.credManRepo = credManRepo
113         uiState = credManRepo.initState().copy(isInitialRender = false)
114 
115         if (this.credManRepo.requestInfo?.token != credManRepo.requestInfo?.token) {
116             this.uiMetrics.resetInstanceId()
117             this.uiMetrics.logNormal(LifecycleEvent.CREDMAN_ACTIVITY_NEW_REQUEST,
118                 credManRepo.requestInfo?.packageName)
119         }
120     }
121 
launchProviderUinull122     fun launchProviderUi(
123         launcher: ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>
124     ) {
125         val entry = uiState.selectedEntry
126         val biometricState = uiState.biometricState
127         val pendingIntent = entry?.pendingIntent
128         if (pendingIntent != null) {
129             Log.d(Constants.LOG_TAG, "Launching provider activity")
130             uiState = uiState.copy(providerActivityState = ProviderActivityState.PENDING)
131             val entryIntent = entry.fillInIntent
132             entryIntent?.putExtra(Constants.IS_AUTO_SELECTED_KEY, uiState.isAutoSelectFlow)
133             if (biometricState.biometricResult != null || biometricState.biometricError != null) {
134                 if (uiState.isAutoSelectFlow) {
135                     Log.w(Constants.LOG_TAG, "Unexpected biometric result exists when " +
136                             "autoSelect is preferred.")
137                 }
138                 // TODO(b/333445754) : Change the fm option to false in qpr after discussion
139                 if (biometricState.biometricResult != null) {
140                     entryIntent?.putExtra(Constants.BIOMETRIC_AUTH_RESULT,
141                         biometricState.biometricResult.biometricAuthenticationResult
142                             .authenticationType)
143                     entryIntent?.putExtra(Constants.BIOMETRIC_FRAMEWORK_OPTION, true)
144                 } else if (biometricState.biometricError != null){
145                     entryIntent?.putExtra(Constants.BIOMETRIC_AUTH_ERROR_CODE,
146                         biometricState.biometricError.errorCode)
147                     entryIntent?.putExtra(Constants.BIOMETRIC_AUTH_ERROR_MESSAGE,
148                         biometricState.biometricError.errorMessage)
149                     entryIntent?.putExtra(Constants.BIOMETRIC_FRAMEWORK_OPTION, true)
150                 }
151             }
152             val intentSenderRequest = IntentSenderRequest.Builder(pendingIntent)
153                 .setFillInIntent(entryIntent).build()
154             try {
155                 launcher.launch(intentSenderRequest)
156             } catch (e: Exception) {
157                 Log.w(Constants.LOG_TAG, "Failed to launch provider UI: $e")
158                 onInternalError()
159             }
160         } else {
161             Log.d(Constants.LOG_TAG, "No provider UI to launch")
162             onInternalError()
163         }
164     }
165 
onProviderActivityResultnull166     fun onProviderActivityResult(providerActivityResult: ProviderActivityResult) {
167         val entry = uiState.selectedEntry
168         val resultCode = providerActivityResult.resultCode
169         val resultData = providerActivityResult.data
170         if (resultCode == Activity.RESULT_CANCELED) {
171             // Re-display the CredMan UI if the user canceled from the provider UI, or cancel
172             // the UI if this is the auto select flow.
173             if (uiState.isAutoSelectFlow) {
174                 Log.d(Constants.LOG_TAG, "The auto selected provider activity was cancelled," +
175                     " ending the credential manager activity.")
176                 onUserCancel()
177             } else {
178                 Log.d(Constants.LOG_TAG, "The provider activity was cancelled," +
179                             " re-displaying our UI.")
180                 resetUiStateForReLaunch()
181             }
182         } else {
183             if (entry != null) {
184                 Log.d(
185                     Constants.LOG_TAG, "Got provider activity result: {provider=" +
186                     "${entry.providerId}, key=${entry.entryKey}, subkey=${entry.entrySubkey}" +
187                     ", resultCode=$resultCode, resultData=$resultData}"
188                 )
189                 credManRepo.onOptionSelected(
190                     entry.providerId, entry.entryKey, entry.entrySubkey,
191                     resultCode, resultData,
192                 )
193                 if (entry.shouldTerminateUiUponSuccessfulProviderResult) {
194                     uiState = uiState.copy(dialogState = DialogState.COMPLETE)
195                 }
196             } else {
197                 Log.w(Constants.LOG_TAG,
198                     "Illegal state: received a provider result but found no matching entry.")
199                 onInternalError()
200             }
201         }
202     }
203 
204     // Resets UI states for any situation that re-launches the UI
resetUiStateForReLaunchnull205     private fun resetUiStateForReLaunch() {
206         onBiometricPromptStateChange(BiometricPromptState.INACTIVE)
207         uiState = uiState.copy(
208             selectedEntry = null,
209             providerActivityState = ProviderActivityState.NOT_APPLICABLE,
210         )
211     }
212 
onLastLockedAuthEntryNotFoundErrornull213     fun onLastLockedAuthEntryNotFoundError() {
214         Log.d(Constants.LOG_TAG, "Unable to find the last unlocked entry")
215         onInternalError()
216     }
217 
onIllegalUiStatenull218     fun onIllegalUiState(errorMessage: String) {
219         Log.w(Constants.LOG_TAG, errorMessage)
220         onInternalError()
221     }
222 
onInternalErrornull223     private fun onInternalError() {
224         Log.w(Constants.LOG_TAG, "UI closed due to illegal internal state")
225         this.uiMetrics.logNormal(LifecycleEvent.CREDMAN_ACTIVITY_INTERNAL_ERROR,
226             credManRepo.requestInfo?.packageName)
227         credManRepo.onParsingFailureCancel()
228         uiState = uiState.copy(dialogState = DialogState.COMPLETE)
229     }
230 
231     /** Return true if the current UI's request token matches the UI cancellation request token. */
shouldCancelCurrentUinull232     fun shouldCancelCurrentUi(cancelRequestToken: IBinder): Boolean {
233         return credManRepo.requestInfo?.token?.equals(cancelRequestToken) ?: false
234     }
235 
236     /**************************************************************************/
237     /*****                      Get Flow Callbacks                        *****/
238     /**************************************************************************/
getFlowOnEntrySelectednull239     fun getFlowOnEntrySelected(
240         entry: EntryInfo,
241         authResult: BiometricPrompt.AuthenticationResult? = null,
242         authError: BiometricError? = null,
243     ) {
244         if (authError == null) {
245             Log.d(Constants.LOG_TAG, "credential selected: {provider=${entry.providerId}" +
246                         ", key=${entry.entryKey}, subkey=${entry.entrySubkey}}")
247         } else {
248                 Log.d(Constants.LOG_TAG, "Biometric flow error: ${authError.errorCode} " +
249                         "propagating to provider, message: ${authError.errorMessage}.")
250         }
251         uiState = if (entry.pendingIntent != null) {
252             uiState.copy(
253                 selectedEntry = entry,
254                 providerActivityState = ProviderActivityState.READY_TO_LAUNCH,
255                 biometricState = if (authResult == null && authError == null)
256                     uiState.biometricState else if (authResult != null) uiState
257                     .biometricState.copy(biometricResult = BiometricResult(
258                             biometricAuthenticationResult = authResult)) else uiState
259                     .biometricState.copy(biometricError = authError)
260             )
261         } else {
262             credManRepo.onOptionSelected(entry.providerId, entry.entryKey, entry.entrySubkey)
263             uiState.copy(dialogState = DialogState.COMPLETE)
264         }
265     }
266 
getFlowOnConfirmEntrySelectednull267     fun getFlowOnConfirmEntrySelected() {
268         val activeEntry = uiState.getCredentialUiState?.activeEntry
269         if (activeEntry != null) {
270             getFlowOnEntrySelected(activeEntry)
271         } else {
272             Log.d(Constants.LOG_TAG,
273                 "Illegal state: confirm is pressed but activeEntry isn't set.")
274             onInternalError()
275         }
276     }
277 
getFlowOnMoreOptionSelectednull278     fun getFlowOnMoreOptionSelected() {
279         Log.d(Constants.LOG_TAG, "More Option selected")
280         uiState = uiState.copy(
281             getCredentialUiState = uiState.getCredentialUiState?.copy(
282                 currentScreenState = GetScreenState.ALL_SIGN_IN_OPTIONS
283             )
284         )
285     }
286 
getFlowOnMoreOptionOnlySelectednull287     fun getFlowOnMoreOptionOnlySelected() {
288         Log.d(Constants.LOG_TAG, "More Option Only selected")
289         uiState = uiState.copy(
290                 getCredentialUiState = uiState.getCredentialUiState?.copy(
291                         currentScreenState = GetScreenState.ALL_SIGN_IN_OPTIONS_ONLY
292                 )
293         )
294     }
295 
getFlowOnMoreOptionOnSnackBarSelectednull296     fun getFlowOnMoreOptionOnSnackBarSelected(isNoAccount: Boolean) {
297         Log.d(Constants.LOG_TAG, "More Option on snackBar selected")
298         uiState = uiState.copy(
299             getCredentialUiState = uiState.getCredentialUiState?.copy(
300                 currentScreenState = GetScreenState.ALL_SIGN_IN_OPTIONS,
301                 isNoAccount = isNoAccount,
302             ),
303             isInitialRender = true,
304         )
305     }
306 
getFlowOnBackToHybridSnackBarScreennull307     fun getFlowOnBackToHybridSnackBarScreen() {
308         uiState = uiState.copy(
309             getCredentialUiState = uiState.getCredentialUiState?.copy(
310                 currentScreenState = GetScreenState.REMOTE_ONLY
311             )
312         )
313     }
314 
getFlowOnBackToPrimarySelectionScreennull315     fun getFlowOnBackToPrimarySelectionScreen() {
316         uiState = uiState.copy(
317             getCredentialUiState = uiState.getCredentialUiState?.copy(
318                 currentScreenState = GetScreenState.PRIMARY_SELECTION
319             )
320         )
321     }
322 
323     /**************************************************************************/
324     /*****                     Create Flow Callbacks                      *****/
325     /**************************************************************************/
createFlowOnMoreOptionsSelectedOnCreationSelectionnull326     fun createFlowOnMoreOptionsSelectedOnCreationSelection() {
327         uiState = uiState.copy(
328             createCredentialUiState = uiState.createCredentialUiState?.copy(
329                 currentScreenState = CreateScreenState.MORE_OPTIONS_SELECTION,
330             )
331         )
332     }
333 
createFlowOnMoreOptionsOnlySelectedOnCreationSelectionnull334     fun createFlowOnMoreOptionsOnlySelectedOnCreationSelection() {
335         uiState = uiState.copy(
336                 createCredentialUiState = uiState.createCredentialUiState?.copy(
337                         currentScreenState = CreateScreenState.MORE_OPTIONS_SELECTION_ONLY,
338                 )
339         )
340     }
341 
createFlowOnBackCreationSelectionButtonSelectednull342     fun createFlowOnBackCreationSelectionButtonSelected() {
343         uiState = uiState.copy(
344             createCredentialUiState = uiState.createCredentialUiState?.copy(
345                 currentScreenState = CreateScreenState.CREATION_OPTION_SELECTION,
346             )
347         )
348     }
349 
createFlowOnEntrySelectedFromMoreOptionScreennull350     fun createFlowOnEntrySelectedFromMoreOptionScreen(activeEntry: ActiveEntry) {
351         val isBiometricFlow = isBiometricFlow(activeEntry = activeEntry, isAutoSelectFlow = false)
352         if (isBiometricFlow) {
353             // This atomically ensures that the only edge case that *restarts* the biometric flow
354             // doesn't risk a configuration change bug on the more options page during create.
355             // Namely, it's atomic in that it happens only on a tap, and it is not possible to
356             // reproduce a tap and a rotation at the same time. However, even if it were, it would
357             // just be an alternate way to jump back into the biometric selection flow after this
358             // reset, and thus, the state machine is maintained.
359             onBiometricPromptStateChange(BiometricPromptState.INACTIVE)
360         }
361         uiState = uiState.copy(
362             createCredentialUiState = uiState.createCredentialUiState?.copy(
363                 currentScreenState =
364                 // An autoselect flow never makes it to the more options screen
365                 if (isBiometricFlow) {
366                     CreateScreenState.BIOMETRIC_SELECTION
367                 } else if (
368                     uiState.createCredentialUiState?.requestDisplayInfo?.userSetDefaultProviderIds
369                         ?.contains(activeEntry.activeProvider.id) ?: true ||
370                     !(uiState.createCredentialUiState?.foundCandidateFromUserDefaultProvider
371                     ?: false) ||
372                     !TextUtils.isEmpty(uiState.createCredentialUiState?.requestDisplayInfo
373                         ?.appPreferredDefaultProviderId))
374                     CreateScreenState.CREATION_OPTION_SELECTION
375                 else CreateScreenState.DEFAULT_PROVIDER_CONFIRMATION,
376                 activeEntry = activeEntry
377             )
378         )
379     }
380 
createFlowOnLaunchSettingsnull381     fun createFlowOnLaunchSettings() {
382         credManRepo.onSettingLaunchCancel()
383         uiState = uiState.copy(dialogState = DialogState.CANCELED_FOR_SETTINGS)
384     }
385 
createFlowOnUseOnceSelectednull386     fun createFlowOnUseOnceSelected() {
387         uiState = uiState.copy(
388             createCredentialUiState = uiState.createCredentialUiState?.copy(
389                 currentScreenState = CreateScreenState.CREATION_OPTION_SELECTION,
390             )
391         )
392     }
393 
createFlowOnEntrySelectednull394     fun createFlowOnEntrySelected(
395         selectedEntry: EntryInfo,
396         authResult: AuthenticationResult? = null,
397         authError: BiometricError? = null,
398     ) {
399         val providerId = selectedEntry.providerId
400         val entryKey = selectedEntry.entryKey
401         val entrySubkey = selectedEntry.entrySubkey
402         if (authError == null) {
403             Log.d(
404                 Constants.LOG_TAG, "Option selected for entry: " +
405                         " {provider=$providerId, key=$entryKey, subkey=$entrySubkey"
406             )
407         } else {
408             Log.d(Constants.LOG_TAG, "Biometric flow error: ${authError.errorCode} " +
409                     "propagating to provider, message: ${authError.errorMessage}.")
410         }
411         if (selectedEntry.pendingIntent != null) {
412             uiState = uiState.copy(
413                 selectedEntry = selectedEntry,
414                 providerActivityState = ProviderActivityState.READY_TO_LAUNCH,
415                 biometricState = if (authResult == null && authError == null)
416                     uiState.biometricState else if (authResult != null) uiState
417                     .biometricState.copy(biometricResult = BiometricResult(
418                         biometricAuthenticationResult = authResult)) else uiState
419                     .biometricState.copy(biometricError = authError)
420             )
421         } else {
422             credManRepo.onOptionSelected(
423                 providerId,
424                 entryKey,
425                 entrySubkey
426             )
427             uiState = uiState.copy(dialogState = DialogState.COMPLETE)
428         }
429     }
430 
createFlowOnConfirmEntrySelectednull431     fun createFlowOnConfirmEntrySelected() {
432         val selectedEntry = uiState.createCredentialUiState?.activeEntry?.activeEntryInfo
433         if (selectedEntry != null) {
434             createFlowOnEntrySelected(selectedEntry)
435         } else {
436             Log.d(Constants.LOG_TAG,
437                 "Unexpected: confirm is pressed but no active entry exists.")
438             onInternalError()
439         }
440     }
441 
442     /**************************************************************************/
443     /*****                     Biometric Flow Callbacks                   *****/
444     /**************************************************************************/
445 
446     /**
447      * Cancels the biometric prompt's cancellation signal. Should only be called when the credential
448      * manager ui receives a developer cancellation signal. If the prompt is already done, we do
449      * not allow a cancellation, given the UI cancellation will be caught by the backend. We also
450      * set the biometricStatus to CANCELED, so that only in this case, we do *not* propagate the
451      * ERROR_CANCELED when a developer cancellation signal is the root cause.
452      */
onDeveloperCancellationReceivedForBiometricPromptnull453     fun onDeveloperCancellationReceivedForBiometricPrompt() {
454         val biometricCancellationSignal = uiState.biometricState.biometricCancellationSignal
455         if (!biometricCancellationSignal.isCanceled && uiState.biometricState.biometricStatus
456             != BiometricPromptState.COMPLETE) {
457             uiState = uiState.copy(
458                 biometricState = uiState.biometricState.copy(
459                     biometricStatus = BiometricPromptState.CANCELED
460                 )
461             )
462             biometricCancellationSignal.cancel()
463         }
464     }
465 
466     /**
467      * Retrieve the biometric prompt's cancellation signal (e.g. to pass into the 'authenticate'
468      * API).
469      */
getBiometricCancellationSignalnull470     fun getBiometricCancellationSignal(): CancellationSignal =
471         uiState.biometricState.biometricCancellationSignal
472 
473     /**
474      * This allows falling back from the biometric prompt screen to the normal get flow by applying
475      * a reset to all necessary states involved in the fallback.
476      */
477     fun fallbackFromBiometricToNormalFlow(biometricFlowType: BiometricFlowType) {
478         onBiometricPromptStateChange(BiometricPromptState.INACTIVE)
479         when (biometricFlowType) {
480             BiometricFlowType.GET -> getFlowOnBackToPrimarySelectionScreen()
481             BiometricFlowType.CREATE -> createFlowOnUseOnceSelected()
482         }
483     }
484 
485     /**
486      * This method can be used to change the [BiometricPromptState] according to the necessity.
487      * For example, if resetting, one might use [BiometricPromptState.INACTIVE], but if the flow
488      * has just launched, to avoid configuration errors, one can use
489      * [BiometricPromptState.PENDING].
490      */
onBiometricPromptStateChangenull491     fun onBiometricPromptStateChange(biometricPromptState: BiometricPromptState) {
492         uiState = uiState.copy(
493             biometricState = uiState.biometricState.copy(
494                 biometricStatus = biometricPromptState
495             )
496         )
497     }
498 
499     /**
500      * This returns the present biometric prompt state's status.
501      */
getBiometricPromptStateStatusnull502     fun getBiometricPromptStateStatus(): BiometricPromptState =
503         uiState.biometricState.biometricStatus
504 
505     /**************************************************************************/
506     /*****                     Misc. Callbacks/Logs                       *****/
507     /**************************************************************************/
508 
509     @Composable
510     fun logUiEvent(uiEventEnum: UiEventEnum) {
511         this.uiMetrics.log(uiEventEnum, credManRepo.requestInfo?.packageName)
512     }
513 }
514