Merge "Add confirmation dialog for unarchival if app only possesses weak permissions." into main
diff --git a/packages/PackageInstaller/AndroidManifest.xml b/packages/PackageInstaller/AndroidManifest.xml
index 6e47689..0d1c9b0 100644
--- a/packages/PackageInstaller/AndroidManifest.xml
+++ b/packages/PackageInstaller/AndroidManifest.xml
@@ -181,6 +181,18 @@
<receiver android:name="androidx.profileinstaller.ProfileInstallReceiver"
tools:node="remove" />
+
+ <activity android:name=".UnarchiveActivity"
+ android:configChanges="orientation|keyboardHidden|screenSize"
+ android:theme="@style/Theme.AlertDialogActivity.NoActionBar"
+ android:excludeFromRecents="true"
+ android:noHistory="true"
+ android:exported="true">
+ <intent-filter android:priority="1">
+ <action android:name="android.intent.action.UNARCHIVE_DIALOG" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
</application>
</manifest>
diff --git a/packages/PackageInstaller/res/values/strings.xml b/packages/PackageInstaller/res/values/strings.xml
index 4eaa39b..0a2e880 100644
--- a/packages/PackageInstaller/res/values/strings.xml
+++ b/packages/PackageInstaller/res/values/strings.xml
@@ -257,4 +257,14 @@
<!-- Notification shown in status bar when an application is successfully installed.
[CHAR LIMIT=50] -->
<string name="notification_installation_success_status">Successfully installed \u201c<xliff:g id="appname" example="Package Installer">%1$s</xliff:g>\u201d</string>
+
+ <!-- The title of a dialog which asks the user to restore (i.e. re-install, re-download) an app
+ after parts of the app have been previously moved into the cloud for temporary storage.
+ "installername" is the app that will facilitate the download of the app. [CHAR LIMIT=50] -->
+ <string name="unarchive_application_title">Restore <xliff:g id="appname" example="Bird Game">%1$s</xliff:g> from <xliff:g id="installername" example="App Store">%1$s</xliff:g>?</string>
+ <!-- After the user confirms the dialog, a download will start. [CHAR LIMIT=none] -->
+ <string name="unarchive_body_text">This app will begin to download in the background</string>
+ <!-- The action to restore (i.e. re-install, re-download) an app after parts of the app have been previously moved
+ into the cloud for temporary storage. [CHAR LIMIT=15] -->
+ <string name="restore">Restore</string>
</resources>
diff --git a/packages/PackageInstaller/src/com/android/packageinstaller/UnarchiveActivity.java b/packages/PackageInstaller/src/com/android/packageinstaller/UnarchiveActivity.java
new file mode 100644
index 0000000..754437e
--- /dev/null
+++ b/packages/PackageInstaller/src/com/android/packageinstaller/UnarchiveActivity.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2023 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.packageinstaller;
+
+import static android.Manifest.permission;
+import static android.content.pm.PackageManager.GET_PERMISSIONS;
+import static android.content.pm.PackageManager.MATCH_ARCHIVED_PACKAGES;
+
+import android.app.Activity;
+import android.app.DialogFragment;
+import android.app.Fragment;
+import android.app.FragmentTransaction;
+import android.content.IntentSender;
+import android.content.pm.PackageInstaller;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.os.Process;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Objects;
+
+public class UnarchiveActivity extends Activity {
+
+ public static final String EXTRA_UNARCHIVE_INTENT_SENDER =
+ "android.content.pm.extra.UNARCHIVE_INTENT_SENDER";
+ static final String APP_TITLE = "com.android.packageinstaller.unarchive.app_title";
+ static final String INSTALLER_TITLE = "com.android.packageinstaller.unarchive.installer_title";
+
+ private static final String TAG = "UnarchiveActivity";
+
+ private String mPackageName;
+ private IntentSender mIntentSender;
+
+ @Override
+ public void onCreate(Bundle icicle) {
+ super.onCreate(null);
+
+ int callingUid = getLaunchedFromUid();
+ if (callingUid == Process.INVALID_UID) {
+ // Cannot reach Package/ActivityManager. Aborting uninstall.
+ Log.e(TAG, "Could not determine the launching uid.");
+
+ setResult(Activity.RESULT_FIRST_USER);
+ finish();
+ return;
+ }
+
+ String callingPackage = getPackageNameForUid(callingUid);
+ if (callingPackage == null) {
+ Log.e(TAG, "Package not found for originating uid " + callingUid);
+ setResult(Activity.RESULT_FIRST_USER);
+ finish();
+ return;
+ }
+
+ // We don't check the AppOpsManager here for REQUEST_INSTALL_PACKAGES because the requester
+ // is not the source of the installation.
+ boolean hasRequestInstallPermission = Arrays.asList(getRequestedPermissions(callingPackage))
+ .contains(permission.REQUEST_INSTALL_PACKAGES);
+ boolean hasInstallPermission = getBaseContext().checkPermission(permission.INSTALL_PACKAGES,
+ 0 /* random value for pid */, callingUid) != PackageManager.PERMISSION_GRANTED;
+ if (!hasRequestInstallPermission && !hasInstallPermission) {
+ Log.e(TAG, "Uid " + callingUid + " does not have "
+ + permission.REQUEST_INSTALL_PACKAGES + " or "
+ + permission.INSTALL_PACKAGES);
+ setResult(Activity.RESULT_FIRST_USER);
+ finish();
+ return;
+ }
+
+ Bundle extras = getIntent().getExtras();
+ mPackageName = extras.getString(PackageInstaller.EXTRA_PACKAGE_NAME);
+ mIntentSender = extras.getParcelable(EXTRA_UNARCHIVE_INTENT_SENDER, IntentSender.class);
+ Objects.requireNonNull(mPackageName);
+ Objects.requireNonNull(mIntentSender);
+
+ PackageManager pm = getPackageManager();
+ try {
+ String appTitle = pm.getApplicationInfo(mPackageName,
+ PackageManager.ApplicationInfoFlags.of(
+ MATCH_ARCHIVED_PACKAGES)).loadLabel(pm).toString();
+ // TODO(ag/25387215) Get the real installer title here after fixing getInstallSource for
+ // archived apps.
+ showDialogFragment(appTitle, "installerTitle");
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.e(TAG, "Invalid packageName: " + e.getMessage());
+ }
+ }
+
+ @Nullable
+ private String[] getRequestedPermissions(String callingPackage) {
+ String[] requestedPermissions = null;
+ try {
+ requestedPermissions = getPackageManager()
+ .getPackageInfo(callingPackage, GET_PERMISSIONS).requestedPermissions;
+ } catch (PackageManager.NameNotFoundException e) {
+ // Should be unreachable because we've just fetched the packageName above.
+ Log.e(TAG, "Package not found for " + callingPackage);
+ }
+ return requestedPermissions;
+ }
+
+ void startUnarchive() {
+ try {
+ getPackageManager().getPackageInstaller().requestUnarchive(mPackageName, mIntentSender);
+ } catch (PackageManager.NameNotFoundException | IOException e) {
+ Log.e(TAG, "RequestUnarchive failed with %s." + e.getMessage());
+ }
+ }
+
+ private void showDialogFragment(String appTitle, String installerAppTitle) {
+ FragmentTransaction ft = getFragmentManager().beginTransaction();
+ Fragment prev = getFragmentManager().findFragmentByTag("dialog");
+ if (prev != null) {
+ ft.remove(prev);
+ }
+
+ Bundle args = new Bundle();
+ args.putString(APP_TITLE, appTitle);
+ args.putString(INSTALLER_TITLE, installerAppTitle);
+ DialogFragment fragment = new UnarchiveFragment();
+ fragment.setArguments(args);
+ fragment.show(ft, "dialog");
+ }
+
+ private String getPackageNameForUid(int sourceUid) {
+ String[] packagesForUid = getPackageManager().getPackagesForUid(sourceUid);
+ if (packagesForUid == null) {
+ return null;
+ }
+ return packagesForUid[0];
+ }
+}
diff --git a/packages/PackageInstaller/src/com/android/packageinstaller/UnarchiveFragment.java b/packages/PackageInstaller/src/com/android/packageinstaller/UnarchiveFragment.java
new file mode 100644
index 0000000..6ccbc4c
--- /dev/null
+++ b/packages/PackageInstaller/src/com/android/packageinstaller/UnarchiveFragment.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2023 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.packageinstaller;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.DialogInterface;
+import android.os.Bundle;
+
+public class UnarchiveFragment extends DialogFragment implements
+ DialogInterface.OnClickListener {
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ String appTitle = getArguments().getString(UnarchiveActivity.APP_TITLE);
+
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity());
+
+ dialogBuilder.setTitle(
+ String.format(getContext().getString(R.string.unarchive_application_title),
+ appTitle));
+ dialogBuilder.setMessage(R.string.unarchive_body_text);
+
+ dialogBuilder.setPositiveButton(R.string.restore, this);
+ dialogBuilder.setNegativeButton(android.R.string.cancel, this);
+
+ return dialogBuilder.create();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (which == Dialog.BUTTON_POSITIVE) {
+ ((UnarchiveActivity) getActivity()).startUnarchive();
+ }
+ }
+
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ super.onDismiss(dialog);
+ if (isAdded()) {
+ getActivity().finish();
+ }
+ }
+}
diff --git a/services/core/java/com/android/server/pm/PackageArchiver.java b/services/core/java/com/android/server/pm/PackageArchiver.java
index d2a4c27..968be5c 100644
--- a/services/core/java/com/android/server/pm/PackageArchiver.java
+++ b/services/core/java/com/android/server/pm/PackageArchiver.java
@@ -95,6 +95,9 @@
private static final String TAG = "PackageArchiverService";
+ public static final String EXTRA_UNARCHIVE_INTENT_SENDER =
+ "android.content.pm.extra.UNARCHIVE_INTENT_SENDER";
+
/**
* The maximum time granted for an app store to start a foreground service when unarchival
* is requested.
@@ -104,6 +107,8 @@
private static final String ARCHIVE_ICONS_DIR = "package_archiver";
+ private static final String ACTION_UNARCHIVE_DIALOG = "android.intent.action.UNARCHIVE_DIALOG";
+
private final Context mContext;
private final PackageManagerService mPm;
@@ -403,11 +408,12 @@
}
snapshot.enforceCrossUserPermission(binderUid, userId, true, true,
"unarchiveApp");
- verifyInstallPermissions();
PackageStateInternal ps;
+ PackageStateInternal callerPs;
try {
ps = getPackageState(packageName, snapshot, binderUid, userId);
+ callerPs = getPackageState(callerPackageName, snapshot, binderUid, userId);
verifyArchived(ps, userId);
} catch (PackageManager.NameNotFoundException e) {
throw new ParcelableException(e);
@@ -420,12 +426,32 @@
packageName)));
}
- // TODO(b/305902395) Introduce a confirmation dialog if the requestor only holds
- // REQUEST_INSTALL permission.
+ boolean hasInstallPackages = mContext.checkCallingOrSelfPermission(
+ Manifest.permission.INSTALL_PACKAGES)
+ == PackageManager.PERMISSION_GRANTED;
+ // We don't check the AppOpsManager here for REQUEST_INSTALL_PACKAGES because the requester
+ // is not the source of the installation.
+ boolean hasRequestInstallPackages = callerPs.getAndroidPackage().getRequestedPermissions()
+ .contains(android.Manifest.permission.REQUEST_INSTALL_PACKAGES);
+ if (!hasInstallPackages && !hasRequestInstallPackages) {
+ throw new SecurityException("You need the com.android.permission.INSTALL_PACKAGES "
+ + "or com.android.permission.REQUEST_INSTALL_PACKAGES permission to request "
+ + "an unarchival.");
+ }
+
+ if (!hasInstallPackages) {
+ requestUnarchiveConfirmation(packageName, statusReceiver);
+ return;
+ }
+
+ // TODO(b/311709794) Check that the responsible installer has INSTALL_PACKAGES or
+ // OPSTR_REQUEST_INSTALL_PACKAGES too. Edge case: In reality this should always be the case,
+ // unless a user has disabled the permission after archiving an app.
+
int draftSessionId;
try {
- draftSessionId = createDraftSession(packageName, installerPackage, statusReceiver,
- userId);
+ draftSessionId = Binder.withCleanCallingIdentity(() ->
+ createDraftSession(packageName, installerPackage, statusReceiver, userId));
} catch (RuntimeException e) {
if (e.getCause() instanceof IOException) {
throw ExceptionUtils.wrap((IOException) e.getCause());
@@ -438,15 +464,17 @@
() -> unarchiveInternal(packageName, userHandle, installerPackage, draftSessionId));
}
- private void verifyInstallPermissions() {
- if (mContext.checkCallingOrSelfPermission(Manifest.permission.INSTALL_PACKAGES)
- != PackageManager.PERMISSION_GRANTED && mContext.checkCallingOrSelfPermission(
- Manifest.permission.REQUEST_INSTALL_PACKAGES)
- != PackageManager.PERMISSION_GRANTED) {
- throw new SecurityException("You need the com.android.permission.INSTALL_PACKAGES "
- + "or com.android.permission.REQUEST_INSTALL_PACKAGES permission to request "
- + "an unarchival.");
- }
+ private void requestUnarchiveConfirmation(String packageName, IntentSender statusReceiver) {
+ final Intent dialogIntent = new Intent(ACTION_UNARCHIVE_DIALOG);
+ dialogIntent.putExtra(EXTRA_UNARCHIVE_INTENT_SENDER, statusReceiver);
+ dialogIntent.putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, packageName);
+
+ final Intent broadcastIntent = new Intent();
+ broadcastIntent.putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, packageName);
+ broadcastIntent.putExtra(PackageInstaller.EXTRA_UNARCHIVE_STATUS,
+ PackageInstaller.STATUS_PENDING_USER_ACTION);
+ broadcastIntent.putExtra(Intent.EXTRA_INTENT, dialogIntent);
+ sendIntent(statusReceiver, packageName, /* message= */ "", broadcastIntent);
}
private void verifyUninstallPermissions() {
@@ -461,7 +489,7 @@
}
private int createDraftSession(String packageName, String installerPackage,
- IntentSender statusReceiver, int userId) {
+ IntentSender statusReceiver, int userId) throws IOException {
PackageInstaller.SessionParams sessionParams = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
sessionParams.setAppPackageName(packageName);
@@ -477,12 +505,11 @@
return existingSessionId;
}
- int sessionId = Binder.withCleanCallingIdentity(
- () -> mPm.mInstallerService.createSessionInternal(
- sessionParams,
- installerPackage, mContext.getAttributionTag(),
- installerUid,
- userId));
+ int sessionId = mPm.mInstallerService.createSessionInternal(
+ sessionParams,
+ installerPackage, mContext.getAttributionTag(),
+ installerUid,
+ userId);
// TODO(b/297358628) Also cleanup sessions upon device restart.
mPm.mHandler.postDelayed(() -> mPm.mInstallerService.cleanupDraftIfUnclaimed(sessionId),
getUnarchiveForegroundTimeout());
@@ -692,20 +719,25 @@
String message) {
Slog.d(TAG, TextUtils.formatSimple("Failed to archive %s with message %s", packageName,
message));
- final Intent fillIn = new Intent();
- fillIn.putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, packageName);
- fillIn.putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
- fillIn.putExtra(PackageInstaller.EXTRA_STATUS_MESSAGE, message);
+ final Intent intent = new Intent();
+ intent.putExtra(PackageInstaller.EXTRA_PACKAGE_NAME, packageName);
+ intent.putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
+ intent.putExtra(PackageInstaller.EXTRA_STATUS_MESSAGE, message);
+ sendIntent(statusReceiver, packageName, message, intent);
+ }
+
+ private void sendIntent(IntentSender statusReceiver, String packageName, String message,
+ Intent intent) {
try {
final BroadcastOptions options = BroadcastOptions.makeBasic();
options.setPendingIntentBackgroundActivityStartMode(
MODE_BACKGROUND_ACTIVITY_START_DENIED);
- statusReceiver.sendIntent(mContext, 0, fillIn, /* onFinished= */ null,
+ statusReceiver.sendIntent(mContext, 0, intent, /* onFinished= */ null,
/* handler= */ null, /* requiredPermission= */ null, options.toBundle());
} catch (IntentSender.SendIntentException e) {
Slog.e(
TAG,
- TextUtils.formatSimple("Failed to send failure status for %s with message %s",
+ TextUtils.formatSimple("Failed to send status for %s with message %s",
packageName, message),
e);
}
diff --git a/services/core/java/com/android/server/pm/PackageManagerShellCommand.java b/services/core/java/com/android/server/pm/PackageManagerShellCommand.java
index f992bd8..fc66203 100644
--- a/services/core/java/com/android/server/pm/PackageManagerShellCommand.java
+++ b/services/core/java/com/android/server/pm/PackageManagerShellCommand.java
@@ -4693,7 +4693,7 @@
try {
mInterface.getPackageInstaller().requestUnarchive(packageName,
- /* callerPackageName= */ "", receiver.getIntentSender(),
+ mContext.getPackageName(), receiver.getIntentSender(),
new UserHandle(translatedUserId));
} catch (Exception e) {
pw.println("Failure [" + e.getMessage() + "]");
diff --git a/services/tests/mockingservicestests/src/com/android/server/pm/PackageArchiverTest.java b/services/tests/mockingservicestests/src/com/android/server/pm/PackageArchiverTest.java
index 18a2acc..733a433 100644
--- a/services/tests/mockingservicestests/src/com/android/server/pm/PackageArchiverTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/pm/PackageArchiverTest.java
@@ -65,6 +65,7 @@
import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;
+import com.android.server.pm.pkg.AndroidPackage;
import com.android.server.pm.pkg.ArchiveState;
import com.android.server.pm.pkg.PackageStateInternal;
import com.android.server.pm.pkg.PackageUserStateImpl;
@@ -81,6 +82,7 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import java.util.Set;
@SmallTest
@Presubmit
@@ -114,6 +116,8 @@
@Mock
private PackageStateInternal mPackageState;
@Mock
+ private PackageStateInternal mCallerPackageState;
+ @Mock
private Bitmap mIcon;
private final InstallSource mInstallSource =
@@ -155,6 +159,11 @@
mPackageState);
when(mComputer.getPackageStateFiltered(eq(INSTALLER_PACKAGE), anyInt(),
anyInt())).thenReturn(mock(PackageStateInternal.class));
+ when(mComputer.getPackageStateFiltered(eq(CALLER_PACKAGE), anyInt(), anyInt())).thenReturn(
+ mCallerPackageState);
+ AndroidPackage androidPackage = mock(AndroidPackage.class);
+ when(mCallerPackageState.getAndroidPackage()).thenReturn(androidPackage);
+ when(androidPackage.getRequestedPermissions()).thenReturn(Set.of());
when(mPackageState.getPackageName()).thenReturn(PACKAGE);
when(mPackageState.getInstallSource()).thenReturn(mInstallSource);
mPackageSetting = createBasicPackageSetting();