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 }