Merge "[CredManUi] Support UI for app cancellation" into udc-dev

This commit is contained in:
Helen Qin
2023-04-03 16:47:52 +00:00
committed by Android (Google) Code Review
7 changed files with 142 additions and 27 deletions

View File

@@ -40,24 +40,50 @@ public final class CancelUiRequest implements Parcelable {
@NonNull @NonNull
private final IBinder mToken; 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. */ /** Returns the request token matching the user request that should be cancelled. */
@NonNull @NonNull
public IBinder getToken() { public IBinder getToken() {
return mToken; 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; mToken = token;
mShouldShowCancellationUi = shouldShowCancellationUi;
mAppPackageName = appPackageName;
} }
private CancelUiRequest(@NonNull Parcel in) { private CancelUiRequest(@NonNull Parcel in) {
mToken = in.readStrongBinder(); mToken = in.readStrongBinder();
AnnotationValidations.validate(NonNull.class, null, mToken); AnnotationValidations.validate(NonNull.class, null, mToken);
mShouldShowCancellationUi = in.readBoolean();
mAppPackageName = in.readString8();
AnnotationValidations.validate(NonNull.class, null, mAppPackageName);
} }
@Override @Override
public void writeToParcel(@NonNull Parcel dest, int flags) { public void writeToParcel(@NonNull Parcel dest, int flags) {
dest.writeStrongBinder(mToken); dest.writeStrongBinder(mToken);
dest.writeBoolean(mShouldShowCancellationUi);
dest.writeString8(mAppPackageName);
} }
@Override @Override

View File

@@ -72,7 +72,8 @@ public class IntentFactory {
* @hide * @hide
*/ */
@NonNull @NonNull
public static Intent createCancelUiIntent(@NonNull IBinder requestToken) { public static Intent createCancelUiIntent(@NonNull IBinder requestToken,
boolean shouldShowCancellationUi, @NonNull String appPackageName) {
Intent intent = new Intent(); Intent intent = new Intent();
ComponentName componentName = ComponentName componentName =
ComponentName.unflattenFromString( ComponentName.unflattenFromString(
@@ -81,7 +82,8 @@ public class IntentFactory {
com.android.internal.R.string com.android.internal.R.string
.config_credentialManagerDialogComponent)); .config_credentialManagerDialogComponent));
intent.setComponent(componentName); 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; return intent;
} }

View File

@@ -110,6 +110,11 @@ class CredentialManagerRepo(
ResultReceiver::class.java ResultReceiver::class.java
) )
val cancellationRequest = getCancelUiRequest(intent)
val cancelUiRequestState = cancellationRequest?.let {
CancelUiRequestState(getAppLabel(context.getPackageManager(), it.appPackageName))
}
initialUiState = when (requestInfo.type) { initialUiState = when (requestInfo.type) {
RequestInfo.TYPE_CREATE -> { RequestInfo.TYPE_CREATE -> {
val defaultProviderId = userConfigRepo.getDefaultProviderId() val defaultProviderId = userConfigRepo.getDefaultProviderId()
@@ -128,6 +133,7 @@ class CredentialManagerRepo(
isPasskeyFirstUse isPasskeyFirstUse
)!!, )!!,
getCredentialUiState = null, getCredentialUiState = null,
cancelRequestState = cancelUiRequestState
) )
} }
RequestInfo.TYPE_GET -> { RequestInfo.TYPE_GET -> {
@@ -142,6 +148,7 @@ class CredentialManagerRepo(
if (autoSelectEntry == null) ProviderActivityState.NOT_APPLICABLE if (autoSelectEntry == null) ProviderActivityState.NOT_APPLICABLE
else ProviderActivityState.READY_TO_LAUNCH, else ProviderActivityState.READY_TO_LAUNCH,
isAutoSelectFlow = autoSelectEntry != null, isAutoSelectFlow = autoSelectEntry != null,
cancelRequestState = cancelUiRequestState
) )
} }
else -> throw IllegalStateException("Unrecognized request type: ${requestInfo.type}") 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. */ /** Return the cancellation request if present. */
fun getCancelUiRequestToken(intent: Intent): IBinder? { fun getCancelUiRequest(intent: Intent): CancelUiRequest? {
return intent.extras?.getParcelable( return intent.extras?.getParcelable(
CancelUiRequest.EXTRA_CANCEL_UI_REQUEST, CancelUiRequest.EXTRA_CANCEL_UI_REQUEST,
CancelUiRequest::class.java CancelUiRequest::class.java
)?.token )
} }
} }

View File

@@ -30,11 +30,13 @@ import androidx.activity.viewModels
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.credentialmanager.common.Constants import com.android.credentialmanager.common.Constants
import com.android.credentialmanager.common.DialogState import com.android.credentialmanager.common.DialogState
import com.android.credentialmanager.common.ProviderActivityResult import com.android.credentialmanager.common.ProviderActivityResult
import com.android.credentialmanager.common.StartBalIntentSenderForResultContract import com.android.credentialmanager.common.StartBalIntentSenderForResultContract
import com.android.credentialmanager.common.ui.Snackbar
import com.android.credentialmanager.createflow.CreateCredentialScreen import com.android.credentialmanager.createflow.CreateCredentialScreen
import com.android.credentialmanager.createflow.hasContentToDisplay import com.android.credentialmanager.createflow.hasContentToDisplay
import com.android.credentialmanager.getflow.GetCredentialScreen import com.android.credentialmanager.getflow.GetCredentialScreen
@@ -49,10 +51,9 @@ class CredentialSelectorActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(Constants.LOG_TAG, "Creating new CredentialSelectorActivity") Log.d(Constants.LOG_TAG, "Creating new CredentialSelectorActivity")
try { try {
if (CredentialManagerRepo.getCancelUiRequestToken(intent) != null) { val (isCancellationRequest, shouldShowCancellationUi, _) =
Log.d( maybeCancelUIUponRequest(intent)
Constants.LOG_TAG, "Received UI cancellation intent; cancelling the activity.") if (isCancellationRequest && !shouldShowCancellationUi) {
this.finish()
return return
} }
val userConfigRepo = UserConfigRepo(this) val userConfigRepo = UserConfigRepo(this)
@@ -75,14 +76,15 @@ class CredentialSelectorActivity : ComponentActivity() {
setIntent(intent) setIntent(intent)
Log.d(Constants.LOG_TAG, "Existing activity received new intent") Log.d(Constants.LOG_TAG, "Existing activity received new intent")
try { try {
val cancelUiRequestToken = CredentialManagerRepo.getCancelUiRequestToken(intent)
val viewModel: CredentialSelectorViewModel by viewModels() val viewModel: CredentialSelectorViewModel by viewModels()
if (cancelUiRequestToken != null && val (isCancellationRequest, shouldShowCancellationUi, appDisplayName) =
viewModel.shouldCancelCurrentUi(cancelUiRequestToken)) { maybeCancelUIUponRequest(intent, viewModel)
Log.d( if (isCancellationRequest) {
Constants.LOG_TAG, "Received UI cancellation intent; cancelling the activity.") if (shouldShowCancellationUi) {
this.finish() viewModel.onCancellationUiRequested(appDisplayName)
} else {
return return
}
} else { } else {
val userConfigRepo = UserConfigRepo(this) val userConfigRepo = UserConfigRepo(this)
val credManRepo = CredentialManagerRepo(this, intent, userConfigRepo) 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 @ExperimentalMaterialApi
@Composable @Composable
fun CredentialManagerBottomSheet( private fun CredentialManagerBottomSheet(
credManRepo: CredentialManagerRepo, credManRepo: CredentialManagerRepo,
userConfigRepo: UserConfigRepo userConfigRepo: UserConfigRepo,
) { ) {
val viewModel: CredentialSelectorViewModel = viewModel { val viewModel: CredentialSelectorViewModel = viewModel {
CredentialSelectorViewModel(credManRepo, userConfigRepo) CredentialSelectorViewModel(credManRepo, userConfigRepo)
@@ -113,7 +145,17 @@ class CredentialSelectorActivity : ComponentActivity() {
val createCredentialUiState = viewModel.uiState.createCredentialUiState val createCredentialUiState = viewModel.uiState.createCredentialUiState
val getCredentialUiState = viewModel.uiState.getCredentialUiState 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( CreateCredentialScreen(
viewModel = viewModel, viewModel = viewModel,
createCredentialUiState = createCredentialUiState, createCredentialUiState = createCredentialUiState,
@@ -172,4 +214,13 @@ class CredentialSelectorActivity : ComponentActivity() {
) )
this.finish() this.finish()
} }
@Composable
private fun UiCancellationScreen(appDisplayName: String) {
Snackbar(
contentText = stringResource(R.string.request_cancelled_by, appDisplayName),
onDismiss = { this@CredentialSelectorActivity.finish() },
dismissOnTimeout = true,
)
}
} }

View File

@@ -51,6 +51,11 @@ data class UiState(
// True if the UI has one and only one auto selectable entry. Its provider activity will be // 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. // launched immediately, and canceling it will cancel the whole UI flow.
val isAutoSelectFlow: Boolean = false, val isAutoSelectFlow: Boolean = false,
val cancelRequestState: CancelUiRequestState?,
)
data class CancelUiRequestState(
val appDisplayName: String?,
) )
class CredentialSelectorViewModel( class CredentialSelectorViewModel(
@@ -76,6 +81,10 @@ class CredentialSelectorViewModel(
uiState = uiState.copy(dialogState = DialogState.COMPLETE) 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. /** 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 * Example use case is the no-auth-info snackbar where the activity should simply display the
* UI and then be dismissed. */ * UI and then be dismissed. */

View File

@@ -64,7 +64,7 @@ import androidx.credentials.provider.RemoteEntry
import org.json.JSONObject import org.json.JSONObject
// TODO: remove all !! checks // TODO: remove all !! checks
private fun getAppLabel( fun getAppLabel(
pm: PackageManager, pm: PackageManager,
appPackageName: String appPackageName: String
): String? { ): String? {

View File

@@ -30,20 +30,24 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.android.credentialmanager.R import com.android.credentialmanager.R
import com.android.credentialmanager.common.material.Scrim import com.android.credentialmanager.common.material.Scrim
import com.android.credentialmanager.ui.theme.Shapes import com.android.credentialmanager.ui.theme.Shapes
import kotlinx.coroutines.delay
@Composable @Composable
fun Snackbar( fun Snackbar(
contentText: String, contentText: String,
action: (@Composable () -> Unit)? = null, action: (@Composable () -> Unit)? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
dismissOnTimeout: Boolean = false,
) { ) {
BoxWithConstraints { BoxWithConstraints {
Box(Modifier.fillMaxSize()) { 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()
}
}
} }