blob: 848c0b36b16ef0e3c3bed92f554a67fbb83890a0 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.permissioncontroller.permission.ui.v34;
import static android.Manifest.permission_group.LOCATION;
import static android.content.Intent.EXTRA_PERMISSION_GROUP_NAME;
import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS;
import static androidx.core.util.Preconditions.checkStringNotEmpty;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_ACCOUNT_MANAGEMENT;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_ADVERTISING;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_ANALYTICS;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_APP_FUNCTIONALITY;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_DEVELOPER_COMMUNICATIONS;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_FRAUD_PREVENTION_SECURITY;
import static com.android.permission.safetylabel.DataPurposeConstants.PURPOSE_PERSONALIZATION;
import static com.android.permissioncontroller.permission.ui.model.v34.PermissionRationaleViewModel.APP_PERMISSION_REQUEST_CODE;
import static com.android.permissioncontroller.permission.ui.v34.PermissionRationaleViewHandler.Result.CANCELLED;
import android.content.Intent;
import android.content.res.Resources;
import android.icu.lang.UCharacter;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.BulletSpan;
import android.text.style.ClickableSpan;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.core.util.Preconditions;
import com.android.permission.safetylabel.DataPurposeConstants.Purpose;
import com.android.permissioncontroller.Constants;
import com.android.permissioncontroller.DeviceUtils;
import com.android.permissioncontroller.R;
import com.android.permissioncontroller.permission.ui.SettingsActivity;
import com.android.permissioncontroller.permission.ui.handheld.v34.PermissionRationaleViewHandlerImpl;
import com.android.permissioncontroller.permission.ui.model.v34.PermissionRationaleViewModel;
import com.android.permissioncontroller.permission.ui.model.v34.PermissionRationaleViewModel.ActivityResultCallback;
import com.android.permissioncontroller.permission.ui.model.v34.PermissionRationaleViewModel.PermissionRationaleInfo;
import com.android.permissioncontroller.permission.ui.model.v34.PermissionRationaleViewModelFactory;
import com.android.permissioncontroller.permission.utils.KotlinUtils;
import com.android.permissioncontroller.permission.utils.Utils;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* An activity which displays runtime permission rationale on behalf of an app. This activity is
* based on GrantPermissionActivity to keep view behavior and theming consistent.
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
public class PermissionRationaleActivity extends SettingsActivity implements
PermissionRationaleViewHandler.ResultListener {
private static final String LOG_TAG = PermissionRationaleActivity.class.getSimpleName();
private static final String KEY_SESSION_ID = PermissionRationaleActivity.class.getName()
+ "_SESSION_ID";
/**
* [Annotation] key for span annotations replacement within the permission rationale purposes
* string resource
*/
public static final String ANNOTATION_ID_KEY = "id";
/**
* [Annotation] id value for span annotations replacement of link annotations within the
* permission rationale purposes string resource
*/
public static final String LINK_ANNOTATION_ID = "link";
/**
* [Annotation] id value for span annotations replacement of install source annotations within
* the permission rationale purposes string resource
*/
public static final String INSTALL_SOURCE_ANNOTATION_ID = "install_source";
/**
* [Annotation] id value for span annotations replacement of purpose list annotations within
* the permission rationale purposes string resource
*/
public static final String PURPOSE_LIST_ANNOTATION_ID = "purpose_list";
/**
* [Annotation] id value for span annotations replacement of permission name annotations within
* the permission rationale purposes string resource
*/
public static final String PERMISSION_NAME_ANNOTATION_ID = "permission_name";
/**
* key to the boolean if to show settings_section on the permission rationale dialog provide via
* intent extra
*/
public static final String EXTRA_SHOULD_SHOW_SETTINGS_SECTION =
"com.android.permissioncontroller.extra.SHOULD_SHOW_SETTINGS_SECTION";
/** Unique Id of a request. Inherited from GrantPermissionDialog if provide via intent extra */
private long mSessionId;
/** Package that shall have permissions granted */
private String mTargetPackage;
/** The permission group that initiated the permission rationale details activity */
private String mPermissionGroupName;
/** The permission rationale info resulting from the specified permission and group */
private PermissionRationaleInfo mPermissionRationaleInfo;
private PermissionRationaleViewHandler mViewHandler;
private PermissionRationaleViewModel mViewModel;
private float mOriginalDimAmount;
private View mRootView;
@Override
protected void onCreate(Bundle icicle) {
super.onCreate(icicle);
if (!KotlinUtils.INSTANCE.isPermissionRationaleEnabled()) {
Log.e(
LOG_TAG,
"Permission rationale feature disabled");
finishAfterTransition();
return;
}
if (icicle == null) {
mSessionId =
getIntent().getLongExtra(Constants.EXTRA_SESSION_ID, new Random().nextLong());
} else {
mSessionId = icicle.getLong(KEY_SESSION_ID);
}
getWindow().addSystemFlags(SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS);
mPermissionGroupName = getIntent().getStringExtra(EXTRA_PERMISSION_GROUP_NAME);
if (mPermissionGroupName == null) {
Log.e(
LOG_TAG,
"null EXTRA_PERMISSION_GROUP_NAME. Must be set for permission rationale");
finishAfterTransition();
return;
}
mTargetPackage = getIntent().getStringExtra(Intent.EXTRA_PACKAGE_NAME);
if (mTargetPackage == null) {
Log.e(LOG_TAG, "null EXTRA_PACKAGE_NAME. Must be set for permission rationale");
finishAfterTransition();
return;
}
setFinishOnTouchOutside(false);
setTitle(getTitleResIdForPermissionGroup(mPermissionGroupName));
if (DeviceUtils.isTelevision(this)
|| DeviceUtils.isWear(this)
|| DeviceUtils.isAuto(this)) {
finishAfterTransition();
} else {
boolean shouldShowSettingsSection =
getIntent().getBooleanExtra(EXTRA_SHOULD_SHOW_SETTINGS_SECTION, true);
mViewHandler = new PermissionRationaleViewHandlerImpl(this, this,
shouldShowSettingsSection);
}
PermissionRationaleViewModelFactory factory = new PermissionRationaleViewModelFactory(
getApplication(), mTargetPackage, mPermissionGroupName, mSessionId, icicle);
mViewModel = factory.create(PermissionRationaleViewModel.class);
mViewModel.getPermissionRationaleInfoLiveData()
.observe(this, this::onPermissionRationaleInfoLoad);
mRootView = mViewHandler.createView();
mRootView.setVisibility(View.GONE);
setContentView(mRootView);
Window window = getWindow();
WindowManager.LayoutParams layoutParams = window.getAttributes();
mOriginalDimAmount = layoutParams.dimAmount;
window.setAttributes(layoutParams);
if (getResources().getBoolean(R.bool.config_useWindowBlur)) {
java.util.function.Consumer<Boolean> blurEnabledListener = enabled -> {
mViewHandler.onBlurEnabledChanged(window, enabled);
};
mRootView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
window.getWindowManager().addCrossWindowBlurEnabledListener(
blurEnabledListener);
}
@Override
public void onViewDetachedFromWindow(View v) {
window.getWindowManager().removeCrossWindowBlurEnabledListener(
blurEnabledListener);
}
});
}
// Restore UI state after lifecycle events. This has to be before we show the first request,
// as the UI behaves differently for updates and initial creations.
if (icicle != null) {
mViewHandler.loadInstanceState(icicle);
} else {
// Do not show screen dim until data is loaded
window.setDimAmount(0f);
}
}
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
if (mViewHandler == null) {
return;
}
mViewHandler.saveInstanceState(outState);
outState.putLong(KEY_SESSION_ID, mSessionId);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
ActivityResultCallback callback = mViewModel.getActivityResultCallback();
if (callback == null || (requestCode != APP_PERMISSION_REQUEST_CODE)) {
return;
}
boolean shouldFinishActivity = callback.shouldFinishActivityForResult(data);
mViewModel.setActivityResultCallback(null);
if (shouldFinishActivity) {
setResultAndFinish(data);
}
}
private void setResultAndFinish(Intent result) {
setResult(RESULT_OK, result);
finishAfterTransition();
}
@Override
public void onBackPressed() {
if (mViewHandler == null) {
return;
}
mViewHandler.onBackPressed();
}
// LINT.IfChange(dispatchTouchEvent)
/**
* Used to dismiss dialog when tapping outside of dialog bounds
* Follows the same logic as GrantPermissionActivity
*/
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
View rootView = getWindow().getDecorView();
if (rootView.getTop() != 0) {
// We are animating the top view, need to compensate for that in motion events.
ev.setLocation(ev.getX(), ev.getY() - rootView.getTop());
}
final int x = (int) ev.getX();
final int y = (int) ev.getY();
if ((x < 0) || (y < 0) || (x > (rootView.getWidth())) || (y > (rootView.getHeight()))) {
if (MotionEvent.ACTION_DOWN == ev.getAction()) {
mViewHandler.onCancelled();
}
finishAfterTransition();
}
return super.dispatchTouchEvent(ev);
}
// LINT.ThenChange(GrantPermissionsActivity.java:dispatchTouchEvent)
@Override
public void onPermissionRationaleResult(@Nullable String groupName, int result) {
if (result == CANCELLED) {
finishAfterTransition();
}
}
private void onPermissionRationaleInfoLoad(PermissionRationaleInfo permissionRationaleInfo) {
if (!mViewModel.getPermissionRationaleInfoLiveData().isInitialized()) {
return;
}
if (permissionRationaleInfo == null) {
finishAfterTransition();
return;
}
mPermissionRationaleInfo = permissionRationaleInfo;
showPermissionRationale();
}
private void showPermissionRationale() {
@StringRes int titleResId = getTitleResIdForPermissionGroup(mPermissionGroupName);
setTitle(titleResId);
CharSequence title = getString(titleResId);
String installSourcePackageName = mPermissionRationaleInfo.getInstallSourcePackageName();
CharSequence installSourceLabel = mPermissionRationaleInfo.getInstallSourceLabel();
checkStringNotEmpty(installSourcePackageName,
"installSourcePackageName cannot be null or empty");
checkStringNotEmpty(installSourceLabel,
"installSourceLabel cannot be null or empty");
CharSequence dataSharingSourceMessage = createDataSharingSourceMessageWithSpans(
getText(R.string.permission_rationale_data_sharing_source_message),
installSourceLabel,
getLinkToAppStore(installSourcePackageName));
CharSequence purposeTitle =
getString(getPurposeTitleResIdForPermissionGroup(mPermissionGroupName));
// TODO(b/260144215): update ordering (enum ordering doesn't match expected ux ordering)
List<String> purposesList =
new ArrayList<>(mPermissionRationaleInfo.getPurposeSet().size());
for (@Purpose int purpose : mPermissionRationaleInfo.getPurposeSet()) {
purposesList.add(getStringForPurpose(purpose));
}
CharSequence purposeMessage =
createPurposeMessageWithBulletSpan(
getText(R.string.permission_rationale_purpose_message),
purposesList);
CharSequence learnMoreMessage =
setLink(
getText(R.string.permission_rationale_data_sharing_varies_message),
getLearnMoreLink()
);
String groupName = mPermissionRationaleInfo.getGroupName();
String permissionGroupLabel =
KotlinUtils.INSTANCE.getPermGroupLabel(this, groupName).toString();
CharSequence settingsMessage =
createSettingsMessageWithSpans(
getText(getSettingsMessageResIdForPermissionGroup(groupName)),
UCharacter.toLowerCase(permissionGroupLabel),
getLinkToSettings()
);
mViewHandler.updateUi(
groupName,
title,
dataSharingSourceMessage,
purposeTitle,
purposeMessage,
learnMoreMessage,
settingsMessage
);
getWindow().setDimAmount(mOriginalDimAmount);
if (mRootView.getVisibility() == View.GONE) {
InputMethodManager manager = getSystemService(InputMethodManager.class);
manager.hideSoftInputFromWindow(mRootView.getWindowToken(), 0);
mRootView.setVisibility(View.VISIBLE);
}
}
@StringRes
private int getTitleResIdForPermissionGroup(String permissionGroupName) {
if (LOCATION.equals(permissionGroupName)) {
return R.string.permission_rationale_location_title;
}
String exceptionString =
String.format("Permission Rationale does not support %s", permissionGroupName);
throw new IllegalArgumentException(exceptionString);
}
@StringRes
private int getPurposeTitleResIdForPermissionGroup(String permissionGroupName) {
if (LOCATION.equals(permissionGroupName)) {
return R.string.permission_rationale_location_purpose_title;
}
String exceptionString =
String.format("Permission Rationale does not support %s", permissionGroupName);
throw new IllegalArgumentException(exceptionString);
}
/**
* Returns permission settings message string resource id for the given permission group.
*
* <p> Supported permission groups: LOCATION
*
* @param permissionGroupName permission group for which to get a message string id
* @throws IllegalArgumentException if passing unsupported permission group
*/
@StringRes
private int getSettingsMessageResIdForPermissionGroup(String permissionGroupName) {
Preconditions.checkArgument(LOCATION.equals(permissionGroupName),
"Permission Rationale does not support %s", permissionGroupName);
return R.string.permission_rationale_permission_settings_message;
}
private String getStringForPurpose(@Purpose int purpose) {
switch (purpose) {
case PURPOSE_APP_FUNCTIONALITY:
return getString(R.string.permission_rationale_purpose_app_functionality);
case PURPOSE_ANALYTICS:
return getString(R.string.permission_rationale_purpose_analytics);
case PURPOSE_DEVELOPER_COMMUNICATIONS:
return getString(R.string.permission_rationale_purpose_developer_communications);
case PURPOSE_FRAUD_PREVENTION_SECURITY:
return getString(R.string.permission_rationale_purpose_fraud_prevention_security);
case PURPOSE_ADVERTISING:
return getString(R.string.permission_rationale_purpose_advertising);
case PURPOSE_PERSONALIZATION:
return getString(R.string.permission_rationale_purpose_personalization);
case PURPOSE_ACCOUNT_MANAGEMENT:
return getString(R.string.permission_rationale_purpose_account_management);
default:
throw new IllegalArgumentException("Invalid purpose: " + purpose);
}
}
private CharSequence createDataSharingSourceMessageWithSpans(
CharSequence baseText,
CharSequence installSourceLabel,
ClickableSpan link) {
CharSequence updatedText =
replaceSpan(baseText, INSTALL_SOURCE_ANNOTATION_ID, installSourceLabel);
updatedText = setLink(updatedText, link);
return updatedText;
}
private CharSequence createPurposeMessageWithBulletSpan(
CharSequence baseText,
List<String> purposesList) {
Resources res = getResources();
final int bulletSize =
res.getDimensionPixelSize(R.dimen.permission_rationale_purpose_list_bullet_radius);
final int bulletIndent =
res.getDimensionPixelSize(R.dimen.permission_rationale_purpose_list_bullet_indent);
final int bulletColor =
getColor(Utils.getColorResId(this, android.R.attr.textColorSecondary));
String purposesString = TextUtils.join("\n", purposesList);
SpannableStringBuilder purposesSpan = SpannableStringBuilder.valueOf(purposesString);
int spanStart = 0;
for (int i = 0; i < purposesList.size(); i++) {
final int length = purposesList.get(i).length();
purposesSpan.setSpan(new BulletSpan(bulletIndent, bulletColor, bulletSize),
spanStart, spanStart + length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spanStart += length + 1;
}
CharSequence updatedText = replaceSpan(baseText, PURPOSE_LIST_ANNOTATION_ID, purposesSpan);
return updatedText;
}
private CharSequence createSettingsMessageWithSpans(
CharSequence baseText,
CharSequence permissionName,
ClickableSpan link) {
CharSequence updatedText =
replaceSpan(baseText, PERMISSION_NAME_ANNOTATION_ID, permissionName);
updatedText = setLink(updatedText, link);
return updatedText;
}
private CharSequence replaceSpan(
CharSequence baseText,
String annotationId,
CharSequence replacementText) {
SpannableStringBuilder text = SpannableStringBuilder.valueOf(baseText);
Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class);
for (android.text.Annotation annotation : annotations) {
if (!annotation.getKey().equals(ANNOTATION_ID_KEY)
|| !annotation.getValue().equals(annotationId)) {
continue;
}
int spanStart = text.getSpanStart(annotation);
int spanEnd = text.getSpanEnd(annotation);
text.removeSpan(annotation);
text.replace(spanStart, spanEnd, replacementText);
break;
}
return text;
}
private CharSequence setLink(CharSequence baseText, ClickableSpan link) {
SpannableStringBuilder text = SpannableStringBuilder.valueOf(baseText);
Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class);
for (android.text.Annotation annotation : annotations) {
if (!annotation.getKey().equals(ANNOTATION_ID_KEY)
|| !annotation.getValue().equals(LINK_ANNOTATION_ID)) {
continue;
}
int spanStart = text.getSpanStart(annotation);
int spanEnd = text.getSpanEnd(annotation);
text.removeSpan(annotation);
text.setSpan(link, spanStart, spanEnd, 0);
break;
}
return text;
}
private ClickableSpan getLinkToAppStore(String installSourcePackageName) {
boolean canLinkToAppStore = mViewModel
.canLinkToAppStore(PermissionRationaleActivity.this, installSourcePackageName);
if (!canLinkToAppStore) {
return null;
}
return new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
// TODO(b/259961958): metrics for click events
mViewModel.sendToAppStore(PermissionRationaleActivity.this,
installSourcePackageName);
}
};
}
private ClickableSpan getLinkToSettings() {
return new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
// TODO(b/259961958): metrics for click events
mViewModel.sendToSettingsForPermissionGroup(PermissionRationaleActivity.this,
mPermissionGroupName);
}
};
}
private ClickableSpan getLearnMoreLink() {
return new ClickableSpan() {
@Override
public void onClick(@NonNull View widget) {
// TODO(b/259961958): metrics for click events
mViewModel.sendToLearnMore(PermissionRationaleActivity.this);
}
};
}
}