diff options
7 files changed, 142 insertions, 27 deletions
diff --git a/core/java/android/credentials/ui/CancelUiRequest.java b/core/java/android/credentials/ui/CancelUiRequest.java index 6bd9de481a79..d4c249e58c8a 100644 --- a/core/java/android/credentials/ui/CancelUiRequest.java +++ b/core/java/android/credentials/ui/CancelUiRequest.java @@ -40,24 +40,50 @@ public final class CancelUiRequest implements Parcelable { @NonNull private final IBinder mToken; + private final boolean mShouldShowCancellationUi; + + @NonNull + private final String mAppPackageName; + /** Returns the request token matching the user request that should be cancelled. */ @NonNull public IBinder getToken() { return mToken; } - public CancelUiRequest(@NonNull IBinder token) { + @NonNull + public String getAppPackageName() { + return mAppPackageName; + } + + /** + * Returns whether the UI should render a cancellation UI upon the request. If false, the UI + * will be silently cancelled. + */ + public boolean shouldShowCancellationUi() { + return mShouldShowCancellationUi; + } + + public CancelUiRequest(@NonNull IBinder token, boolean shouldShowCancellationUi, + @NonNull String appPackageName) { mToken = token; + mShouldShowCancellationUi = shouldShowCancellationUi; + mAppPackageName = appPackageName; } private CancelUiRequest(@NonNull Parcel in) { mToken = in.readStrongBinder(); AnnotationValidations.validate(NonNull.class, null, mToken); + mShouldShowCancellationUi = in.readBoolean(); + mAppPackageName = in.readString8(); + AnnotationValidations.validate(NonNull.class, null, mAppPackageName); } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeStrongBinder(mToken); + dest.writeBoolean(mShouldShowCancellationUi); + dest.writeString8(mAppPackageName); } @Override diff --git a/core/java/android/credentials/ui/IntentFactory.java b/core/java/android/credentials/ui/IntentFactory.java index dcfef56f86a4..5e8372d68eb2 100644 --- a/core/java/android/credentials/ui/IntentFactory.java +++ b/core/java/android/credentials/ui/IntentFactory.java @@ -72,7 +72,8 @@ public class IntentFactory { * @hide */ @NonNull - public static Intent createCancelUiIntent(@NonNull IBinder requestToken) { + public static Intent createCancelUiIntent(@NonNull IBinder requestToken, + boolean shouldShowCancellationUi, @NonNull String appPackageName) { Intent intent = new Intent(); ComponentName componentName = ComponentName.unflattenFromString( @@ -81,7 +82,8 @@ public class IntentFactory { com.android.internal.R.string .config_credentialManagerDialogComponent)); intent.setComponent(componentName); - intent.putExtra(CancelUiRequest.EXTRA_CANCEL_UI_REQUEST, new CancelUiRequest(requestToken)); + intent.putExtra(CancelUiRequest.EXTRA_CANCEL_UI_REQUEST, + new CancelUiRequest(requestToken, shouldShowCancellationUi, appPackageName)); return intent; } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt index 28f9453a48a2..452455c9838c 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialManagerRepo.kt @@ -110,6 +110,11 @@ class CredentialManagerRepo( ResultReceiver::class.java ) + val cancellationRequest = getCancelUiRequest(intent) + val cancelUiRequestState = cancellationRequest?.let { + CancelUiRequestState(getAppLabel(context.getPackageManager(), it.appPackageName)) + } + initialUiState = when (requestInfo.type) { RequestInfo.TYPE_CREATE -> { val defaultProviderId = userConfigRepo.getDefaultProviderId() @@ -128,6 +133,7 @@ class CredentialManagerRepo( isPasskeyFirstUse )!!, getCredentialUiState = null, + cancelRequestState = cancelUiRequestState ) } RequestInfo.TYPE_GET -> { @@ -142,6 +148,7 @@ class CredentialManagerRepo( if (autoSelectEntry == null) ProviderActivityState.NOT_APPLICABLE else ProviderActivityState.READY_TO_LAUNCH, isAutoSelectFlow = autoSelectEntry != null, + cancelRequestState = cancelUiRequestState ) } else -> throw IllegalStateException("Unrecognized request type: ${requestInfo.type}") @@ -238,12 +245,12 @@ class CredentialManagerRepo( } } - /** Return the request token whose UI should be cancelled, or null otherwise. */ - fun getCancelUiRequestToken(intent: Intent): IBinder? { + /** Return the cancellation request if present. */ + fun getCancelUiRequest(intent: Intent): CancelUiRequest? { return intent.extras?.getParcelable( CancelUiRequest.EXTRA_CANCEL_UI_REQUEST, CancelUiRequest::class.java - )?.token + ) } } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorActivity.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorActivity.kt index 5d72424c8f8a..2efe1bee43cc 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorActivity.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorActivity.kt @@ -30,11 +30,13 @@ import androidx.activity.viewModels import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import com.android.credentialmanager.common.Constants import com.android.credentialmanager.common.DialogState import com.android.credentialmanager.common.ProviderActivityResult import com.android.credentialmanager.common.StartBalIntentSenderForResultContract +import com.android.credentialmanager.common.ui.Snackbar import com.android.credentialmanager.createflow.CreateCredentialScreen import com.android.credentialmanager.createflow.hasContentToDisplay import com.android.credentialmanager.getflow.GetCredentialScreen @@ -49,10 +51,9 @@ class CredentialSelectorActivity : ComponentActivity() { super.onCreate(savedInstanceState) Log.d(Constants.LOG_TAG, "Creating new CredentialSelectorActivity") try { - if (CredentialManagerRepo.getCancelUiRequestToken(intent) != null) { - Log.d( - Constants.LOG_TAG, "Received UI cancellation intent; cancelling the activity.") - this.finish() + val (isCancellationRequest, shouldShowCancellationUi, _) = + maybeCancelUIUponRequest(intent) + if (isCancellationRequest && !shouldShowCancellationUi) { return } val userConfigRepo = UserConfigRepo(this) @@ -75,14 +76,15 @@ class CredentialSelectorActivity : ComponentActivity() { setIntent(intent) Log.d(Constants.LOG_TAG, "Existing activity received new intent") try { - val cancelUiRequestToken = CredentialManagerRepo.getCancelUiRequestToken(intent) val viewModel: CredentialSelectorViewModel by viewModels() - if (cancelUiRequestToken != null && - viewModel.shouldCancelCurrentUi(cancelUiRequestToken)) { - Log.d( - Constants.LOG_TAG, "Received UI cancellation intent; cancelling the activity.") - this.finish() - return + val (isCancellationRequest, shouldShowCancellationUi, appDisplayName) = + maybeCancelUIUponRequest(intent, viewModel) + if (isCancellationRequest) { + if (shouldShowCancellationUi) { + viewModel.onCancellationUiRequested(appDisplayName) + } else { + return + } } else { val userConfigRepo = UserConfigRepo(this) val credManRepo = CredentialManagerRepo(this, intent, userConfigRepo) @@ -93,11 +95,41 @@ class CredentialSelectorActivity : ComponentActivity() { } } + /** + * Cancels the UI activity if requested by the backend. Different from the other finishing + * helpers, this does not report anything back to the Credential Manager service backend. + * + * Can potentially show a transient snackbar before finishing, if the request specifies so. + * + * Returns <isCancellationRequest, shouldShowCancellationUi, appDisplayName>. + */ + private fun maybeCancelUIUponRequest( + intent: Intent, + viewModel: CredentialSelectorViewModel? = null + ): Triple<Boolean, Boolean, String?> { + val cancelUiRequest = CredentialManagerRepo.getCancelUiRequest(intent) + ?: return Triple(false, false, null) + if (viewModel != null && !viewModel.shouldCancelCurrentUi(cancelUiRequest.token)) { + // Cancellation was for a different request, don't cancel the current UI. + return Triple(false, false, null) + } + val shouldShowCancellationUi = cancelUiRequest.shouldShowCancellationUi() + Log.d( + Constants.LOG_TAG, "Received UI cancellation intent. Should show cancellation" + + " ui = $shouldShowCancellationUi") + val appDisplayName = getAppLabel(packageManager, cancelUiRequest.appPackageName) + if (!shouldShowCancellationUi) { + this.finish() + } + return Triple(true, shouldShowCancellationUi, appDisplayName) + } + + @ExperimentalMaterialApi @Composable - fun CredentialManagerBottomSheet( + private fun CredentialManagerBottomSheet( credManRepo: CredentialManagerRepo, - userConfigRepo: UserConfigRepo + userConfigRepo: UserConfigRepo, ) { val viewModel: CredentialSelectorViewModel = viewModel { CredentialSelectorViewModel(credManRepo, userConfigRepo) @@ -113,7 +145,17 @@ class CredentialSelectorActivity : ComponentActivity() { val createCredentialUiState = viewModel.uiState.createCredentialUiState val getCredentialUiState = viewModel.uiState.getCredentialUiState - if (createCredentialUiState != null && hasContentToDisplay(createCredentialUiState)) { + val cancelRequestState = viewModel.uiState.cancelRequestState + if (cancelRequestState != null) { + if (cancelRequestState.appDisplayName == null) { + Log.d(Constants.LOG_TAG, "Received UI cancel request with an invalid package name.") + this.finish() + return + } else { + UiCancellationScreen(cancelRequestState.appDisplayName) + } + } else if ( + createCredentialUiState != null && hasContentToDisplay(createCredentialUiState)) { CreateCredentialScreen( viewModel = viewModel, createCredentialUiState = createCredentialUiState, @@ -122,15 +164,15 @@ class CredentialSelectorActivity : ComponentActivity() { } else if (getCredentialUiState != null && hasContentToDisplay(getCredentialUiState)) { if (isFallbackScreen(getCredentialUiState)) { GetGenericCredentialScreen( - viewModel = viewModel, - getCredentialUiState = getCredentialUiState, - providerActivityLauncher = launcher + viewModel = viewModel, + getCredentialUiState = getCredentialUiState, + providerActivityLauncher = launcher ) } else { GetCredentialScreen( - viewModel = viewModel, - getCredentialUiState = getCredentialUiState, - providerActivityLauncher = launcher + viewModel = viewModel, + getCredentialUiState = getCredentialUiState, + providerActivityLauncher = launcher ) } } else { @@ -172,4 +214,13 @@ class CredentialSelectorActivity : ComponentActivity() { ) this.finish() } + + @Composable + private fun UiCancellationScreen(appDisplayName: String) { + Snackbar( + contentText = stringResource(R.string.request_cancelled_by, appDisplayName), + onDismiss = { this@CredentialSelectorActivity.finish() }, + dismissOnTimeout = true, + ) + } } diff --git a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt index e49e3f165cfc..7eb3bf46b493 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/CredentialSelectorViewModel.kt @@ -51,6 +51,11 @@ data class UiState( // True if the UI has one and only one auto selectable entry. Its provider activity will be // launched immediately, and canceling it will cancel the whole UI flow. val isAutoSelectFlow: Boolean = false, + val cancelRequestState: CancelUiRequestState?, +) + +data class CancelUiRequestState( + val appDisplayName: String?, ) class CredentialSelectorViewModel( @@ -76,6 +81,10 @@ class CredentialSelectorViewModel( uiState = uiState.copy(dialogState = DialogState.COMPLETE) } + fun onCancellationUiRequested(appDisplayName: String?) { + uiState = uiState.copy(cancelRequestState = CancelUiRequestState(appDisplayName)) + } + /** Close the activity and don't report anything to the backend. * Example use case is the no-auth-info snackbar where the activity should simply display the * UI and then be dismissed. */ diff --git a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt index 783cf3b47344..43da9807231b 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/DataConverter.kt @@ -64,7 +64,7 @@ import androidx.credentials.provider.RemoteEntry import org.json.JSONObject // TODO: remove all !! checks -private fun getAppLabel( +fun getAppLabel( pm: PackageManager, appPackageName: String ): String? { diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/ui/SnackBar.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/SnackBar.kt index 514ff90be8d7..dfff3d694877 100644 --- a/packages/CredentialManager/src/com/android/credentialmanager/common/ui/SnackBar.kt +++ b/packages/CredentialManager/src/com/android/credentialmanager/common/ui/SnackBar.kt @@ -30,20 +30,24 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalAccessibilityManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.credentialmanager.R import com.android.credentialmanager.common.material.Scrim import com.android.credentialmanager.ui.theme.Shapes +import kotlinx.coroutines.delay @Composable fun Snackbar( contentText: String, action: (@Composable () -> Unit)? = null, onDismiss: () -> Unit, + dismissOnTimeout: Boolean = false, ) { BoxWithConstraints { Box(Modifier.fillMaxSize()) { @@ -89,4 +93,20 @@ fun Snackbar( } } } + val accessibilityManager = LocalAccessibilityManager.current + LaunchedEffect(true) { + if (dismissOnTimeout) { + // Same as SnackbarDuration.Short + val originalDuration = 4000L + val duration = if (accessibilityManager == null) originalDuration else + accessibilityManager.calculateRecommendedTimeoutMillis( + originalDuration, + containsIcons = true, + containsText = true, + containsControls = action != null, + ) + delay(duration) + onDismiss() + } + } }
\ No newline at end of file |