Add confirmation dialog for unarchival if app only possesses weak
permissions.

Test: PackageInstallerArchiveTest
Bug: 305902395

Change-Id: I9f3bb5bf1ba6c0ed5164ac8be644287ee95251d9
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();