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