/* * Copyright (C) 2020 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.car.settings.applications; import static android.app.Activity.RESULT_OK; import static com.android.car.settings.applications.ApplicationsUtils.isKeepEnabledPackage; import static com.android.car.settings.applications.ApplicationsUtils.isProfileOrDeviceOwner; import static com.android.car.settings.common.ActionButtonsPreference.ActionButtons; import static com.android.car.settings.enterprise.ActionDisabledByAdminDialogFragment.DISABLED_BY_ADMIN_CONFIRM_DIALOG_TAG; import android.app.Activity; import android.app.ActivityManager; import android.app.admin.DevicePolicyManager; import android.car.drivingstate.CarUxRestrictions; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; import android.os.UserManager; import android.util.ArraySet; import android.view.View; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.car.settings.R; import com.android.car.settings.common.ActionButtonInfo; import com.android.car.settings.common.ActionButtonsPreference; import com.android.car.settings.common.ActivityResultCallback; import com.android.car.settings.common.ConfirmationDialogFragment; import com.android.car.settings.common.FragmentController; import com.android.car.settings.common.Logger; import com.android.car.settings.common.PreferenceController; import com.android.car.settings.enterprise.ActionDisabledByAdminDialogFragment; import com.android.car.settings.profiles.ProfileHelper; import com.android.settingslib.Utils; import com.android.settingslib.applications.ApplicationsState; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Set; /** * Shows actions associated with an application, like uninstall and forceStop. * *

To uninstall an app, it must not be: *

* *

For apps that cannot be uninstalled, a disable option is shown instead (or enable if the app * is already disabled). */ public class ApplicationActionButtonsPreferenceController extends PreferenceController implements ActivityResultCallback { private static final Logger LOG = new Logger( ApplicationActionButtonsPreferenceController.class); private static final List FORCE_STOP_RESTRICTIONS = Arrays.asList(UserManager.DISALLOW_APPS_CONTROL); private static final List UNINSTALL_RESTRICTIONS = Arrays.asList(UserManager.DISALLOW_UNINSTALL_APPS, UserManager.DISALLOW_APPS_CONTROL); @VisibleForTesting static final String DISABLE_CONFIRM_DIALOG_TAG = "com.android.car.settings.applications.DisableConfirmDialog"; @VisibleForTesting static final String FORCE_STOP_CONFIRM_DIALOG_TAG = "com.android.car.settings.applications.ForceStopConfirmDialog"; @VisibleForTesting static final int UNINSTALL_REQUEST_CODE = 10; private DevicePolicyManager mDpm; private PackageManager mPm; private UserManager mUserManager; private ProfileHelper mProfileHelper; private ApplicationsState.Session mSession; private ApplicationsState.AppEntry mAppEntry; private ApplicationsState mApplicationsState; private String mPackageName; private PackageInfo mPackageInfo; private String mRestriction; @VisibleForTesting final ConfirmationDialogFragment.ConfirmListener mForceStopConfirmListener = new ConfirmationDialogFragment.ConfirmListener() { @Override public void onConfirm(@Nullable Bundle arguments) { LOG.d("Stopping package " + mPackageName); getContext().getSystemService(ActivityManager.class) .forceStopPackage(mPackageName); int userId = UserHandle.getUserId(mAppEntry.info.uid); mApplicationsState.invalidatePackage(mPackageName, userId); } }; private final View.OnClickListener mForceStopClickListener = i -> { if (ignoreActionBecauseItsDisabledByAdmin(FORCE_STOP_RESTRICTIONS)) return; ConfirmationDialogFragment dialogFragment = new ConfirmationDialogFragment.Builder(getContext()) .setTitle(R.string.force_stop_dialog_title) .setMessage(R.string.force_stop_dialog_text) .setPositiveButton(android.R.string.ok, mForceStopConfirmListener) .setNegativeButton(android.R.string.cancel, /* rejectListener= */ null) .build(); getFragmentController().showDialog(dialogFragment, FORCE_STOP_CONFIRM_DIALOG_TAG); }; @VisibleForTesting final BroadcastReceiver mCheckKillProcessesReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { boolean enabled = getResultCode() != Activity.RESULT_CANCELED; LOG.d("Got broadcast response: Restart status for " + mPackageName + " " + enabled); updateForceStopButtonInner(enabled); } }; @VisibleForTesting final ConfirmationDialogFragment.ConfirmListener mDisableConfirmListener = new ConfirmationDialogFragment.ConfirmListener() { @Override public void onConfirm(@Nullable Bundle arguments) { mPm.setApplicationEnabledSetting(mPackageName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, /* flags= */ 0); updateUninstallButtonInner(false); } }; private final View.OnClickListener mDisableClickListener = i -> { ConfirmationDialogFragment dialogFragment = new ConfirmationDialogFragment.Builder(getContext()) .setMessage(getContext().getString(R.string.app_disable_dialog_text)) .setPositiveButton(R.string.app_disable_dialog_positive, mDisableConfirmListener) .setNegativeButton(android.R.string.cancel, /* rejectListener= */ null) .build(); getFragmentController().showDialog(dialogFragment, DISABLE_CONFIRM_DIALOG_TAG); }; private final View.OnClickListener mEnableClickListener = i -> { mPm.setApplicationEnabledSetting(mPackageName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, /* flags= */ 0); updateUninstallButtonInner(true); }; private final View.OnClickListener mUninstallClickListener = i -> { if (ignoreActionBecauseItsDisabledByAdmin(UNINSTALL_RESTRICTIONS)) return; Uri packageUri = Uri.parse("package:" + mPackageName); Intent uninstallIntent = new Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri); uninstallIntent.putExtra(Intent.EXTRA_RETURN_RESULT, true); getFragmentController().startActivityForResult(uninstallIntent, UNINSTALL_REQUEST_CODE, /* callback= */ this); }; private final ApplicationsState.Callbacks mApplicationStateCallbacks = new ApplicationsState.Callbacks() { @Override public void onRunningStateChanged(boolean running) { } @Override public void onPackageListChanged() { refreshUi(); } @Override public void onRebuildComplete(ArrayList apps) { } @Override public void onPackageIconChanged() { } @Override public void onPackageSizeChanged(String packageName) { } @Override public void onAllSizesComputed() { } @Override public void onLauncherInfoChanged() { } @Override public void onLoadEntriesCompleted() { } }; public ApplicationActionButtonsPreferenceController(Context context, String preferenceKey, FragmentController fragmentController, CarUxRestrictions uxRestrictions) { super(context, preferenceKey, fragmentController, uxRestrictions); mDpm = context.getSystemService(DevicePolicyManager.class); mPm = context.getPackageManager(); mUserManager = UserManager.get(context); mProfileHelper = ProfileHelper.getInstance(context); } @Override protected Class getPreferenceType() { return ActionButtonsPreference.class; } /** Sets the {@link ApplicationsState.AppEntry} which is used to load the app name and icon. */ public ApplicationActionButtonsPreferenceController setAppEntry( ApplicationsState.AppEntry appEntry) { mAppEntry = appEntry; return this; } /** Sets the {@link ApplicationsState} which is used to load the app name and icon. */ public ApplicationActionButtonsPreferenceController setAppState( ApplicationsState applicationsState) { mApplicationsState = applicationsState; return this; } /** * Set the packageName, which is used to perform actions on a particular package. */ public ApplicationActionButtonsPreferenceController setPackageName(String packageName) { mPackageName = packageName; return this; } @Override protected void checkInitialized() { if (mAppEntry == null || mApplicationsState == null || mPackageName == null) { throw new IllegalStateException( "AppEntry, AppState, and PackageName should be set before calling this " + "function"); } } @Override protected void onCreateInternal() { ConfirmationDialogFragment.resetListeners( (ConfirmationDialogFragment) getFragmentController().findDialogByTag( DISABLE_CONFIRM_DIALOG_TAG), mDisableConfirmListener, /* rejectListener= */ null, /* neutralListener= */ null); ConfirmationDialogFragment.resetListeners( (ConfirmationDialogFragment) getFragmentController().findDialogByTag( FORCE_STOP_CONFIRM_DIALOG_TAG), mForceStopConfirmListener, /* rejectListener= */ null, /* neutralListener= */ null); getPreference().getButton(ActionButtons.BUTTON2) .setText(R.string.force_stop) .setIcon(R.drawable.ic_warning) .setOnClickListener(mForceStopClickListener) .setEnabled(false); mSession = mApplicationsState.newSession(mApplicationStateCallbacks); } @Override protected void onStartInternal() { mSession.onResume(); } @Override protected void onStopInternal() { mSession.onPause(); } @Override protected void updateState(ActionButtonsPreference preference) { refreshAppEntry(); if (mAppEntry == null) { getFragmentController().goBack(); return; } updateForceStopButton(); updateUninstallButton(); } private void refreshAppEntry() { mAppEntry = mApplicationsState.getEntry(mPackageName, UserHandle.myUserId()); if (mAppEntry != null) { try { mPackageInfo = mPm.getPackageInfo(mPackageName, PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_ANY_USER | PackageManager.GET_SIGNATURES | PackageManager.GET_PERMISSIONS); } catch (PackageManager.NameNotFoundException e) { LOG.e("Exception when retrieving package:" + mPackageName, e); mPackageInfo = null; } } else { mPackageInfo = null; } } private void updateForceStopButton() { if (mDpm.packageHasActiveAdmins(mPackageName)) { updateForceStopButtonInner(/* enabled= */ false); } else if ((mAppEntry.info.flags & ApplicationInfo.FLAG_STOPPED) == 0) { // If the app isn't explicitly stopped, then always show the force stop button. updateForceStopButtonInner(/* enabled= */ true); } else { Intent intent = new Intent(Intent.ACTION_QUERY_PACKAGE_RESTART, Uri.fromParts("package", mPackageName, /* fragment= */ null)); intent.putExtra(Intent.EXTRA_PACKAGES, new String[]{mPackageName}); intent.putExtra(Intent.EXTRA_UID, mAppEntry.info.uid); intent.putExtra(Intent.EXTRA_USER_HANDLE, UserHandle.getUserId(mAppEntry.info.uid)); LOG.d("Sending broadcast to query restart status for " + mPackageName); getContext().sendOrderedBroadcastAsUser(intent, UserHandle.CURRENT, /* receiverPermission= */ null, mCheckKillProcessesReceiver, /* scheduler= */ null, Activity.RESULT_CANCELED, /* initialData= */ null, /* initialExtras= */ null); } } private void updateForceStopButtonInner(boolean enabled) { if (enabled) { Boolean shouldDisable = shouldDisableButtonBecauseOfUserRestriction("Force Stop", UserManager.DISALLOW_APPS_CONTROL); if (shouldDisable != null) { if (shouldDisable) { enabled = false; } else { mRestriction = UserManager.DISALLOW_APPS_CONTROL; } } } getPreference().getButton(ActionButtons.BUTTON2).setEnabled(enabled); } private void updateUninstallButtonInner(boolean isAppEnabled) { ActionButtonInfo uninstallButton = getPreference().getButton(ActionButtons.BUTTON1); if (isBundledApp()) { if (isAppEnabled) { uninstallButton.setText(R.string.disable_text).setIcon( R.drawable.ic_block).setOnClickListener(mDisableClickListener); } else { uninstallButton.setText(R.string.enable_text).setIcon( R.drawable.ic_check_circle).setOnClickListener(mEnableClickListener); } } else { uninstallButton.setText(R.string.uninstall_text).setIcon( R.drawable.ic_delete).setOnClickListener(mUninstallClickListener); } uninstallButton.setEnabled(!shouldDisableUninstallButton()); } private void updateUninstallButton() { updateUninstallButtonInner(isAppEnabled()); } private boolean shouldDisableUninstallButton() { if (shouldDisableUninstallForHomeApp()) { LOG.d("Uninstall disabled for home app"); return true; } if (isAppEnabled() && isKeepEnabledPackage(getContext(), mPackageName)) { LOG.d("Disable button disabled for keep enabled package"); return true; } if (Utils.isSystemPackage(getContext().getResources(), mPm, mPackageInfo)) { LOG.d("Uninstall disabled for system package"); return true; } if (mDpm.packageHasActiveAdmins(mPackageName)) { LOG.d("Uninstall disabled because package has active admins"); return true; } // We don't allow uninstalling profile/device owner on any profile because if it's a system // app, "uninstall" is actually "downgrade to the system version + disable", and // "downgrade" will clear data on all profiles. if (isProfileOrDeviceOwner(mPackageName, mDpm, mProfileHelper)) { LOG.d("Uninstall disabled because package is profile or device owner"); return true; } if (mDpm.isUninstallInQueue(mPackageName)) { LOG.d("Uninstall disabled because intent is already queued"); return true; } Boolean shouldDisable = shouldDisableButtonBecauseOfUserRestriction("Uninstall", UserManager.DISALLOW_APPS_CONTROL); if (shouldDisable != null) return shouldDisable; shouldDisable = shouldDisableButtonBecauseOfUserRestriction("Uninstall", UserManager.DISALLOW_UNINSTALL_APPS); if (shouldDisable != null) return shouldDisable; return false; } /** * Checks whether a button should be disabled because the user has the given restriction * (and whether the restriction was was set by a device admin). * * @param button action name (for logging purposes) * @param restriction user restriction * * @return {@code null} if the user doesn't have the restriction, {@value Boolean#TRUE} if it * should be disabled because of {@link UserManager} restrictions, or {@value Boolean#FALSE} if * should not be disabled because of {@link DevicePolicyManager} restrictions (in which case * {@link #mRestriction} is updated with the restriction). */ @Nullable private Boolean shouldDisableButtonBecauseOfUserRestriction(String button, String restriction) { if (!mUserManager.hasUserRestriction(restriction)) return null; UserHandle user = UserHandle.getUserHandleForUid(mAppEntry.info.uid); if (mUserManager.hasBaseUserRestriction(restriction, user)) { LOG.d(button + " disabled because " + user + " has " + restriction + " restriction"); return Boolean.TRUE; } LOG.d(button + " NOT disabled because " + user + " has " + restriction + " restriction but " + "it was set by a device admin (it will show a dialog explaining that instead)"); mRestriction = restriction; return Boolean.FALSE; } /** * Returns {@code true} if the package is a Home app that should not be uninstalled. We don't * risk downgrading bundled home apps because that can interfere with home-key resolution. We * can't allow removal of the only home app, and we don't want to allow removal of an * explicitly preferred home app. The user can go to Home settings and pick a different app, * after which we'll permit removal of the now-not-default app. */ private boolean shouldDisableUninstallForHomeApp() { Set homePackages = new ArraySet<>(); // Get list of "home" apps and trace through any meta-data references. List homeActivities = new ArrayList<>(); ComponentName currentDefaultHome = mPm.getHomeActivities(homeActivities); for (int i = 0; i < homeActivities.size(); i++) { ResolveInfo ri = homeActivities.get(i); String activityPkg = ri.activityInfo.packageName; homePackages.add(activityPkg); // Also make sure to include anything proxying for the home app. Bundle metadata = ri.activityInfo.metaData; if (metadata != null) { String metaPkg = metadata.getString(ActivityManager.META_HOME_ALTERNATE); if (signaturesMatch(metaPkg, activityPkg)) { homePackages.add(metaPkg); } } } if (homePackages.contains(mPackageName)) { if (isBundledApp()) { // Don't risk a downgrade. return true; } else if (currentDefaultHome == null) { // No preferred default. Permit uninstall only when there is more than one // candidate. return (homePackages.size() == 1); } else { // Explicit default home app. Forbid uninstall of that one, but permit it for // installed-but-inactive ones. return mPackageName.equals(currentDefaultHome.getPackageName()); } } else { // Not a home app. return false; } } private boolean signaturesMatch(String pkg1, String pkg2) { if (pkg1 != null && pkg2 != null) { try { int match = mPm.checkSignatures(pkg1, pkg2); if (match >= PackageManager.SIGNATURE_MATCH) { return true; } } catch (Exception e) { // e.g. package not found during lookup. Possibly bad input. // Just return false as this isn't a reason to crash given the use case. } } return false; } private boolean isBundledApp() { return (mAppEntry.info.flags & ApplicationInfo.FLAG_SYSTEM) != 0; } private boolean isAppEnabled() { return mAppEntry.info.enabled && !(mAppEntry.info.enabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED); } @Override public void processActivityResult(int requestCode, int resultCode, @Nullable Intent data) { if (requestCode == UNINSTALL_REQUEST_CODE) { if (resultCode == RESULT_OK) { getFragmentController().goBack(); } else { LOG.e("Uninstall failed with result " + resultCode); } } } private boolean ignoreActionBecauseItsDisabledByAdmin(List restrictions) { if (mRestriction == null || !restrictions.contains(mRestriction)) return false; LOG.d("Ignoring action because of " + mRestriction); getFragmentController().showDialog(ActionDisabledByAdminDialogFragment.newInstance( mRestriction, UserHandle.myUserId()), DISABLED_BY_ADMIN_CONFIRM_DIALOG_TAG); return true; } }