Pre-commit install approval (2/n)

Introduce a new mechanism to allow developers requesting the approval
from users for an install before committing the session and without
any of the APK file.

Bug: 242677131
Test: atest CtsPackageInstallTestCases
Change-Id: I38029d9dbf28f03b81e4988f1f864e552129c957
This commit is contained in:
Jackal Guo
2022-08-24 16:55:15 +08:00
parent ea765a3f42
commit b0ff86efa9
5 changed files with 267 additions and 14 deletions

View File

@@ -11595,6 +11595,7 @@ package android.content.pm {
field public static final String ACTION_SESSION_UPDATED = "android.content.pm.action.SESSION_UPDATED";
field public static final String EXTRA_OTHER_PACKAGE_NAME = "android.content.pm.extra.OTHER_PACKAGE_NAME";
field public static final String EXTRA_PACKAGE_NAME = "android.content.pm.extra.PACKAGE_NAME";
field public static final String EXTRA_PRE_APPROVAL = "android.content.pm.extra.PRE_APPROVAL";
field public static final String EXTRA_SESSION = "android.content.pm.extra.SESSION";
field public static final String EXTRA_SESSION_ID = "android.content.pm.extra.SESSION_ID";
field public static final String EXTRA_STATUS = "android.content.pm.extra.STATUS";
@@ -11651,6 +11652,7 @@ package android.content.pm {
method public void removeChildSessionId(int);
method public void removeSplit(@NonNull String) throws java.io.IOException;
method public void requestChecksums(@NonNull String, int, @NonNull java.util.List<java.security.cert.Certificate>, @NonNull java.util.concurrent.Executor, @NonNull android.content.pm.PackageManager.OnChecksumsReadyListener) throws java.security.cert.CertificateEncodingException, java.io.FileNotFoundException;
method public void requestUserPreapproval(@NonNull android.content.pm.PackageInstaller.PreapprovalDetails, @NonNull android.content.IntentSender);
method @Deprecated public void setChecksums(@NonNull String, @NonNull java.util.List<android.content.pm.Checksum>, @Nullable byte[]) throws java.io.IOException;
method public void setStagingProgress(float);
method public void transfer(@NonNull String) throws android.content.pm.PackageManager.NameNotFoundException;

View File

@@ -20,6 +20,7 @@ import android.content.pm.Checksum;
import android.content.pm.DataLoaderParamsParcel;
import android.content.pm.IOnChecksumsReadyListener;
import android.content.pm.IPackageInstallObserver2;
import android.content.pm.PackageInstaller;
import android.content.IntentSender;
import android.os.ParcelFileDescriptor;
@@ -58,4 +59,6 @@ interface IPackageInstallerSession {
boolean isStaged();
int getInstallFlags();
void requestUserPreapproval(in PackageInstaller.PreapprovalDetails details, in IntentSender statusReceiver);
}

View File

@@ -170,6 +170,10 @@ public class PackageInstaller {
/** {@hide} */
public static final String ACTION_CONFIRM_INSTALL = "android.content.pm.action.CONFIRM_INSTALL";
/** @hide */
public static final String ACTION_CONFIRM_PRE_APPROVAL =
"android.content.pm.action.CONFIRM_PRE_APPROVAL";
/**
* An integer session ID that an operation is working with.
*
@@ -206,6 +210,17 @@ public class PackageInstaller {
*/
public static final String EXTRA_STATUS = "android.content.pm.extra.STATUS";
/**
* Indicate if the status is for a pre-approval request.
*
* If callers use the same {@link IntentSender} for both
* {@link Session#requestUserPreapproval(PreapprovalDetails, IntentSender)} and
* {@link Session#commit(IntentSender)}, they can use this to differentiate between them.
*
* @see Intent#getBooleanExtra(String, boolean)
*/
public static final String EXTRA_PRE_APPROVAL = "android.content.pm.extra.PRE_APPROVAL";
/**
* Detailed string representation of the status, including raw details that
* are useful for debugging.
@@ -1667,6 +1682,41 @@ public class PackageInstaller {
e.rethrowFromSystemServer();
}
}
/**
* Attempt to request the approval before committing this session.
*
* For installers that have been granted the
* {@link android.Manifest.permission#REQUEST_INSTALL_PACKAGES REQUEST_INSTALL_PACKAGES}
* permission, they can request the approval from users before
* {@link Session#commit(IntentSender)} is called. This may require user intervention as
* well. The result of the request will be reported through the given callback.
*
* @param details the adequate context to this session for requesting the approval from
* users prior to commit.
* @param statusReceiver called when the state of the session changes.
* Intents sent to this receiver contain
* {@link #EXTRA_STATUS}. Refer to the individual
* status codes on how to handle them.
*
* @throws IllegalArgumentException when {@link PreapprovalDetails} is {@code null}.
* @throws IllegalArgumentException if {@link IntentSender} is {@code null}.
* @throws IllegalStateException if called on a multi-package session (no matter
* the parent session or any of the children sessions).
* @throws IllegalStateException if called again after this method has been called on
* this session.
* @throws SecurityException when the caller does not own this session.
*/
public void requestUserPreapproval(@NonNull PreapprovalDetails details,
@NonNull IntentSender statusReceiver) {
Preconditions.checkArgument(details != null, "preapprovalDetails cannot be null.");
Preconditions.checkArgument(statusReceiver != null, "statusReceiver cannot be null.");
try {
mSession.requestUserPreapproval(details, statusReceiver);
} catch (RemoteException e) {
e.rethrowFromSystemServer();
}
}
}
/**
@@ -2631,6 +2681,9 @@ public class PackageInstaller {
/** {@hide} */
public int installerUid;
/** @hide */
public boolean isPreapprovalRequested;
/** {@hide} */
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
public SessionInfo() {
@@ -2678,6 +2731,7 @@ public class PackageInstaller {
mSessionErrorCode = source.readInt();
mSessionErrorMessage = source.readString();
isCommitted = source.readBoolean();
isPreapprovalRequested = source.readBoolean();
rollbackDataPolicy = source.readInt();
createdMillis = source.readLong();
requireUserAction = source.readInt();
@@ -3257,6 +3311,7 @@ public class PackageInstaller {
dest.writeInt(mSessionErrorCode);
dest.writeString(mSessionErrorMessage);
dest.writeBoolean(isCommitted);
dest.writeBoolean(isPreapprovalRequested);
dest.writeInt(rollbackDataPolicy);
dest.writeLong(createdMillis);
dest.writeInt(requireUserAction);

View File

@@ -81,9 +81,11 @@ import android.content.pm.InstallationFile;
import android.content.pm.InstallationFileParcel;
import android.content.pm.PackageInfo;
import android.content.pm.PackageInstaller;
import android.content.pm.PackageInstaller.PreapprovalDetails;
import android.content.pm.PackageInstaller.SessionInfo;
import android.content.pm.PackageInstaller.SessionParams;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.PackageInfoFlags;
import android.content.pm.PackageManagerInternal;
import android.content.pm.SigningDetails;
import android.content.pm.dex.DexMetadataHelper;
@@ -92,8 +94,13 @@ import android.content.pm.parsing.ApkLiteParseUtils;
import android.content.pm.parsing.PackageLite;
import android.content.pm.parsing.result.ParseResult;
import android.content.pm.parsing.result.ParseTypeImpl;
import android.content.res.ApkAssets;
import android.content.res.AssetManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.icu.util.ULocale;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
@@ -193,6 +200,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
private static final int MSG_INSTALL = 3;
private static final int MSG_ON_PACKAGE_INSTALLED = 4;
private static final int MSG_SESSION_VALIDATION_FAILURE = 5;
private static final int MSG_PRE_APPROVAL_REQUEST = 6;
/** XML constants used for persisting a session */
static final String TAG_SESSION = "session";
@@ -360,6 +368,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
@GuardedBy("mLock")
private boolean mShouldBeSealed = false;
private final AtomicBoolean mPreapprovalRequested = new AtomicBoolean(false);
private final AtomicBoolean mCommitted = new AtomicBoolean(false);
/**
@@ -387,6 +396,9 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
@GuardedBy("mLock")
private IntentSender mRemoteStatusReceiver;
@GuardedBy("mLock")
private PreapprovalDetails mPreapprovalDetails;
/** Fields derived from commit parsing */
@GuardedBy("mLock")
private String mPackageName;
@@ -740,11 +752,12 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
final Bundle extras = (Bundle) args.arg3;
final IntentSender statusReceiver = (IntentSender) args.arg4;
final int returnCode = args.argi1;
final boolean isPreapproval = args.argi2 == 1;
args.recycle();
sendOnPackageInstalled(mContext, statusReceiver, sessionId,
isInstallerDeviceOwnerOrAffiliatedProfileOwner(), userId,
packageName, returnCode, message, extras);
packageName, returnCode, isPreapproval, message, extras);
break;
case MSG_SESSION_VALIDATION_FAILURE:
@@ -752,6 +765,9 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
final String detailMessage = (String) msg.obj;
onSessionValidationFailure(error, detailMessage);
break;
case MSG_PRE_APPROVAL_REQUEST:
handlePreapprovalRequest();
break;
}
return true;
@@ -779,10 +795,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
*/
private boolean isInstallerDeviceOwnerOrAffiliatedProfileOwner() {
assertNotLocked("isInstallerDeviceOwnerOrAffiliatedProfileOwner");
// It is safe to access mInstallerUid and mInstallSource without lock
// because they are immutable after sealing.
assertSealed("isInstallerDeviceOwnerOrAffiliatedProfileOwner");
if (userId != UserHandle.getUserId(mInstallerUid)) {
if (userId != UserHandle.getUserId(getInstallerUid())) {
return false;
}
DevicePolicyManagerInternal dpmi =
@@ -1032,17 +1045,21 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
info.progress = progress;
info.sealed = mSealed;
info.isCommitted = mCommitted.get();
info.isPreapprovalRequested = isPreapprovalRequested();
info.active = mActiveCount.get() > 0;
info.mode = params.mode;
info.installReason = params.installReason;
info.installScenario = params.installScenario;
info.sizeBytes = params.sizeBytes;
info.appPackageName = mPackageName != null ? mPackageName : params.appPackageName;
info.appPackageName = mPreapprovalDetails != null ? mPreapprovalDetails.getPackageName()
: mPackageName != null ? mPackageName : params.appPackageName;
if (includeIcon) {
info.appIcon = params.appIcon;
info.appIcon = mPreapprovalDetails != null && mPreapprovalDetails.getIcon() != null
? mPreapprovalDetails.getIcon() : params.appIcon;
}
info.appLabel = params.appLabel;
info.appLabel =
mPreapprovalDetails != null ? mPreapprovalDetails.getLabel() : params.appLabel;
info.installLocation = params.installLocation;
if (!scrubData) {
@@ -1086,6 +1103,11 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
}
}
/** @hide */
boolean isPreapprovalRequested() {
return mPreapprovalRequested.get();
}
/** {@hide} */
boolean isCommitted() {
return mCommitted.get();
@@ -1121,6 +1143,14 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
}
}
@GuardedBy("mLock")
private void assertPreparedAndNotPreapprovalRequestedLocked(String cookie) {
assertPreparedAndNotSealedLocked(cookie);
if (isPreapprovalRequested()) {
throw new IllegalStateException(cookie + " not allowed after requesting");
}
}
@GuardedBy("mLock")
private void assertPreparedAndNotSealedLocked(String cookie) {
assertPreparedAndNotCommittedOrDestroyedLocked(cookie);
@@ -1708,6 +1738,20 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
}
}
@WorkerThread
private void handlePreapprovalRequest() {
/**
* Stops the process if the session needs user action. When the user answers the yes,
* {@link #setPermissionsResult(boolean)} is called and then
* {@link #MSG_PRE_APPROVAL_REQUEST} is handled to come back here to check again.
*/
if (sendPendingUserActionIntentIfNeeded()) {
return;
}
dispatchSessionPreappoved();
}
private final class FileSystemConnector extends
IPackageInstallerSessionFileSystemConnector.Stub {
final Set<String> mAddedFiles = new ArraySet<>();
@@ -2115,7 +2159,11 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
*/
@WorkerThread
private boolean sendPendingUserActionIntentIfNeeded() {
assertNotChild("PackageInstallerSession#sendPendingUserActionIntentIfNeeded");
// To support pre-approval request of atomic install, we allow child session to handle
// the result by itself since it has the status receiver.
if (isCommitted()) {
assertNotChild("PackageInstallerSession#sendPendingUserActionIntentIfNeeded");
}
final IntentSender statusReceiver = getRemoteStatusReceiver();
return sessionContains(s -> checkUserActionRequirement(s, statusReceiver));
@@ -2420,7 +2468,10 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
// User needs to confirm installation;
// give installer an intent they can use to involve
// user.
final Intent intent = new Intent(PackageInstaller.ACTION_CONFIRM_INSTALL);
final boolean isPreapproval = isPreapprovalRequested() && !isCommitted();
final Intent intent = new Intent(
isPreapproval ? PackageInstaller.ACTION_CONFIRM_PRE_APPROVAL
: PackageInstaller.ACTION_CONFIRM_INSTALL);
intent.setPackage(mPm.getPackageInstallerPackageName());
intent.putExtra(PackageInstaller.EXTRA_SESSION_ID, sessionId);
@@ -3009,6 +3060,9 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
}
}
}
assertPreapprovalDetailsConsistentIfNeededLocked(packageLite, pkgInfo);
if (packageLite.isUseEmbeddedDex()) {
for (File file : mResolvedStagedFiles) {
if (file.getName().endsWith(".apk")
@@ -3253,6 +3307,78 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
}
}
@GuardedBy("mLock")
private void assertPreapprovalDetailsConsistentIfNeededLocked(@NonNull PackageLite packageLite,
@Nullable PackageInfo info) throws PackageManagerException {
if (mPreapprovalDetails == null || !isPreapprovalRequested()) {
return;
}
if (!TextUtils.equals(mPackageName, mPreapprovalDetails.getPackageName())) {
throw new PackageManagerException(INSTALL_FAILED_INTERNAL_ERROR,
mPreapprovalDetails + " inconsistent with " + mPackageName);
}
// In case the app label in PreapprovalDetails from different locale in split APK,
// we check all APK files to find the app label.
final PackageInfo packageInfo =
info != null ? info : mContext.getPackageManager().getPackageArchiveInfo(
packageLite.getPath(), PackageInfoFlags.of(0));
if (packageInfo == null) {
throw new PackageManagerException(INSTALL_FAILED_INVALID_APK,
"Failure to obtain package info.");
}
final List<String> filePaths = packageLite.getAllApkPaths();
final String appLabel = mPreapprovalDetails.getLabel();
final ULocale appLocale = mPreapprovalDetails.getLocale();
final ApplicationInfo appInfo = packageInfo.applicationInfo;
boolean appLabelMatched = false;
for (int i = filePaths.size() - 1; i >= 0 && !appLabelMatched; i--) {
appLabelMatched |= TextUtils.equals(getAppLabel(filePaths.get(i), appLocale, appInfo),
appLabel);
}
if (!appLabelMatched) {
throw new PackageManagerException(INSTALL_FAILED_INTERNAL_ERROR,
mPreapprovalDetails + " inconsistent with app label");
}
}
private CharSequence getAppLabel(String path, ULocale locale, ApplicationInfo appInfo)
throws PackageManagerException {
final Resources pRes = mContext.getResources();
final AssetManager assetManager = new AssetManager();
final Configuration config = new Configuration(pRes.getConfiguration());
final ApkAssets apkAssets;
try {
apkAssets = ApkAssets.loadFromPath(path);
} catch (IOException e) {
throw new PackageManagerException(INSTALL_FAILED_INVALID_APK,
"Failure to get resources from package archive " + path);
}
assetManager.setApkAssets(new ApkAssets[]{apkAssets}, false /* invalidateCaches */);
config.setLocale(locale.toLocale());
final Resources res = new Resources(assetManager, pRes.getDisplayMetrics(), config);
return tryLoadingAppLabel(res, appInfo);
}
private CharSequence tryLoadingAppLabel(@NonNull Resources res, @NonNull ApplicationInfo info) {
CharSequence label = null;
// Try to load the label from the package's resources. If an app has not explicitly
// specified any label, just use the package name.
if (info.labelRes != 0) {
try {
label = res.getText(info.labelRes);
} catch (Resources.NotFoundException ignore) {
}
}
if (label == null) {
label = (info.nonLocalizedLabel != null)
? info.nonLocalizedLabel : info.packageName;
}
return label;
}
private SigningDetails unsafeGetCertsWithoutVerification(String path)
throws PackageManagerException {
final ParseTypeImpl input = ParseTypeImpl.forDefaultParsing();
@@ -3469,11 +3595,13 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
}
void setPermissionsResult(boolean accepted) {
if (!isSealed()) {
if (!isSealed() && !isPreapprovalRequested()) {
throw new SecurityException("Must be sealed to accept permissions");
}
PackageInstallerSession root = hasParentSessionId()
// To support pre-approval request of atomic install, we allow child session to handle
// the result by itself since it has the status receiver.
final PackageInstallerSession root = hasParentSessionId() && isCommitted()
? mSessionProvider.getSession(getParentSessionId()) : this;
if (accepted) {
@@ -3481,7 +3609,8 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
synchronized (mLock) {
mPermissionsManuallyAccepted = true;
}
root.mHandler.obtainMessage(MSG_INSTALL).sendToTarget();
root.mHandler.obtainMessage(
isCommitted() ? MSG_INSTALL : MSG_PRE_APPROVAL_REQUEST).sendToTarget();
} else {
root.destroy();
root.dispatchSessionFinished(INSTALL_FAILED_ABORTED, "User rejected permissions", null);
@@ -4096,10 +4225,68 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
args.arg3 = extras;
args.arg4 = statusReceiver;
args.argi1 = returnCode;
args.argi2 = isPreapprovalRequested() && !isCommitted() ? 1 : 0;
mHandler.obtainMessage(MSG_ON_PACKAGE_INSTALLED, args).sendToTarget();
}
}
private void dispatchSessionPreappoved() {
final IntentSender target = getRemoteStatusReceiver();
final Intent intent = new Intent();
intent.putExtra(PackageInstaller.EXTRA_SESSION_ID, sessionId);
intent.putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_SUCCESS);
intent.putExtra(PackageInstaller.EXTRA_PRE_APPROVAL, true);
try {
target.sendIntent(mContext, 0 /* code */, intent, null /* onFinished */,
null /* handler */);
} catch (IntentSender.SendIntentException ignored) {
}
}
@Override
public void requestUserPreapproval(@NonNull PreapprovalDetails details,
@NonNull IntentSender statusReceiver) {
validatePreapprovalRequest(details, statusReceiver);
dispatchPreapprovalRequest();
}
/**
* Validates whether the necessary information (e.g., PreapprovalDetails) are provided.
*/
private void validatePreapprovalRequest(@NonNull PreapprovalDetails details,
@NonNull IntentSender statusReceiver) {
assertCallerIsOwnerOrRoot();
if (isMultiPackage()) {
throw new IllegalStateException(
"Session " + sessionId + " is a parent of multi-package session and "
+ "requestUserPreapproval on the parent session isn't supported.");
}
synchronized (mLock) {
assertPreparedAndNotSealedLocked("request of session " + sessionId);
mPreapprovalDetails = details;
setRemoteStatusReceiver(statusReceiver);
}
}
private void dispatchPreapprovalRequest() {
synchronized (mLock) {
assertPreparedAndNotPreapprovalRequestedLocked("dispatchPreapprovalRequest");
}
// Mark this session are pre-approval requested, and ready to progress to the next phase.
markAsPreapprovalRequested();
mHandler.obtainMessage(MSG_PRE_APPROVAL_REQUEST).sendToTarget();
}
/**
* Marks this session as pre-approval requested, and prevents further related modification.
*/
private void markAsPreapprovalRequested() {
mPreapprovalRequested.set(true);
}
void setSessionReady() {
synchronized (mLock) {
// Do not allow destroyed/failed session to change state
@@ -4265,6 +4452,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
pw.printPair("mClientProgress", clientProgress);
pw.printPair("mProgress", progress);
pw.printPair("mCommitted", mCommitted);
pw.printPair("mPreapprovalRequested", mPreapprovalRequested);
pw.printPair("mSealed", mSealed);
pw.printPair("mPermissionsManuallyAccepted", mPermissionsManuallyAccepted);
pw.printPair("mStageDirInUse", mStageDirInUse);
@@ -4282,6 +4470,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
pw.printPair("mSessionReady", mSessionReady);
pw.printPair("mSessionErrorCode", mSessionErrorCode);
pw.printPair("mSessionErrorMessage", mSessionErrorMessage);
pw.printPair("mPreapprovalDetails", mPreapprovalDetails);
pw.println();
pw.decreaseIndent();
@@ -4295,6 +4484,8 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
final Intent fillIn = new Intent();
fillIn.putExtra(PackageInstaller.EXTRA_SESSION_ID, sessionId);
fillIn.putExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_PENDING_USER_ACTION);
fillIn.putExtra(PackageInstaller.EXTRA_PRE_APPROVAL,
PackageInstaller.ACTION_CONFIRM_PRE_APPROVAL.equals(intent.getAction()));
fillIn.putExtra(Intent.EXTRA_INTENT, intent);
try {
target.sendIntent(context, 0, fillIn, null, null);
@@ -4307,7 +4498,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
*/
private static void sendOnPackageInstalled(Context context, IntentSender target, int sessionId,
boolean showNotification, int userId, String basePackageName, int returnCode,
String msg, Bundle extras) {
boolean isPreapproval, String msg, Bundle extras) {
if (INSTALL_SUCCEEDED == returnCode && showNotification) {
boolean update = (extras != null) && extras.getBoolean(Intent.EXTRA_REPLACING);
Notification notification = PackageInstallerService.buildSuccessNotification(context,
@@ -4330,6 +4521,7 @@ public class PackageInstallerSession extends IPackageInstallerSession.Stub {
fillIn.putExtra(PackageInstaller.EXTRA_STATUS_MESSAGE,
PackageManager.installStatusToString(returnCode, msg));
fillIn.putExtra(PackageInstaller.EXTRA_LEGACY_STATUS, returnCode);
fillIn.putExtra(PackageInstaller.EXTRA_PRE_APPROVAL, isPreapproval);
if (extras != null) {
final String existing = extras.getString(
PackageManager.EXTRA_FAILURE_EXISTING_PACKAGE);

View File

@@ -325,6 +325,7 @@ public class PackageInstallerSessionTest {
actual.getSessionErrorMessage());
assertEquals(expected.isPrepared(), actual.isPrepared());
assertEquals(expected.isCommitted(), actual.isCommitted());
assertEquals(expected.isPreapprovalRequested(), actual.isPreapprovalRequested());
assertEquals(expected.createdMillis, actual.createdMillis);
assertEquals(expected.isSealed(), actual.isSealed());
assertEquals(expected.isMultiPackage(), actual.isMultiPackage());