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

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/22422193

Change-Id: Ie2d7b7ded3fa5ad6f094f9fd80bed058dd194deb
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Helen Qin
2023-04-03 17:18:16 +00:00
committed by Automerger Merge Worker
7 changed files with 142 additions and 27 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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
)
}
}

View File

@@ -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,
)
}
}

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
// 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. */

View File

@@ -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? {

View File

@@ -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()
}
}
}