ART services: optimize package - Implement PrimaryDexOptimizer.

This CL contains the basic implementation of app compilation.

Bug: 229268202
Test: atest ArtServiceTests
Ignore-AOSP-First: ART Services.
Change-Id: Ib5940b16c8f6b7f650584d2b770e7fbd40cb75ca
diff --git a/artd/artd.cc b/artd/artd.cc
index 2cdca3c..1cd2655 100644
--- a/artd/artd.cc
+++ b/artd/artd.cc
@@ -149,18 +149,18 @@
   return compiler_filter;
 }
 
-OatFileAssistant::DexOptTrigger DexOptTriggerFromAidl(int8_t aidl_value) {
+OatFileAssistant::DexOptTrigger DexOptTriggerFromAidl(int32_t aidl_value) {
   OatFileAssistant::DexOptTrigger trigger{};
-  if ((aidl_value & static_cast<int8_t>(DexoptTrigger::COMPILER_FILTER_IS_BETTER)) != 0) {
+  if ((aidl_value & static_cast<int32_t>(DexoptTrigger::COMPILER_FILTER_IS_BETTER)) != 0) {
     trigger.targetFilterIsBetter = true;
   }
-  if ((aidl_value & static_cast<int8_t>(DexoptTrigger::COMPILER_FILTER_IS_SAME)) != 0) {
+  if ((aidl_value & static_cast<int32_t>(DexoptTrigger::COMPILER_FILTER_IS_SAME)) != 0) {
     trigger.targetFilterIsSame = true;
   }
-  if ((aidl_value & static_cast<int8_t>(DexoptTrigger::COMPILER_FILTER_IS_WORSE)) != 0) {
+  if ((aidl_value & static_cast<int32_t>(DexoptTrigger::COMPILER_FILTER_IS_WORSE)) != 0) {
     trigger.targetFilterIsWorse = true;
   }
-  if ((aidl_value & static_cast<int8_t>(DexoptTrigger::PRIMARY_BOOT_IMAGE_BECOMES_USABLE)) != 0) {
+  if ((aidl_value & static_cast<int32_t>(DexoptTrigger::PRIMARY_BOOT_IMAGE_BECOMES_USABLE)) != 0) {
     trigger.primaryBootImageBecomesUsable = true;
   }
   return trigger;
@@ -331,7 +331,7 @@
                                          const std::string& in_instructionSet,
                                          const std::string& in_classLoaderContext,
                                          const std::string& in_compilerFilter,
-                                         int8_t in_dexoptTrigger,
+                                         int32_t in_dexoptTrigger,
                                          GetDexoptNeededResult* _aidl_return) {
   Result<OatFileAssistantContext*> ofa_context = GetOatFileAssistantContext();
   if (!ofa_context.ok()) {
diff --git a/artd/artd.h b/artd/artd.h
index 54d6776..18ffa3c 100644
--- a/artd/artd.h
+++ b/artd/artd.h
@@ -58,7 +58,7 @@
       const std::string& in_instructionSet,
       const std::string& in_classLoaderContext,
       const std::string& in_compilerFilter,
-      int8_t in_dexoptTrigger,
+      int32_t in_dexoptTrigger,
       aidl::com::android::server::art::GetDexoptNeededResult* _aidl_return) override;
 
   ndk::ScopedAStatus dexopt(
diff --git a/artd/binder/com/android/server/art/DexoptTrigger.aidl b/artd/binder/com/android/server/art/DexoptTrigger.aidl
index a160f22..58a9ec8 100644
--- a/artd/binder/com/android/server/art/DexoptTrigger.aidl
+++ b/artd/binder/com/android/server/art/DexoptTrigger.aidl
@@ -25,6 +25,7 @@
  *
  * @hide
  */
+@Backing(type="int")
 enum DexoptTrigger {
     COMPILER_FILTER_IS_BETTER = 1 << 0,
     COMPILER_FILTER_IS_SAME = 1 << 1,
diff --git a/artd/binder/com/android/server/art/IArtd.aidl b/artd/binder/com/android/server/art/IArtd.aidl
index 6750d9c..47c1ade 100644
--- a/artd/binder/com/android/server/art/IArtd.aidl
+++ b/artd/binder/com/android/server/art/IArtd.aidl
@@ -46,7 +46,7 @@
     com.android.server.art.GetDexoptNeededResult getDexoptNeeded(
             @utf8InCpp String dexFile, @utf8InCpp String instructionSet,
             @utf8InCpp String classLoaderContext, @utf8InCpp String compilerFilter,
-            byte dexoptTrigger);
+            int dexoptTrigger);
 
     /**
      * Dexopts a dex file for the given instruction set. Returns true on success, or false if
diff --git a/libartservice/service/java/com/android/server/art/AidlUtils.java b/libartservice/service/java/com/android/server/art/AidlUtils.java
new file mode 100644
index 0000000..447c3d6
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/AidlUtils.java
@@ -0,0 +1,80 @@
+/*
+ * 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.server.art;
+
+import static com.android.server.art.OutputArtifacts.PermissionSettings;
+import static com.android.server.art.OutputArtifacts.PermissionSettings.SeContext;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+/** @hide */
+public final class AidlUtils {
+    private AidlUtils() {}
+
+    @NonNull
+    public static ArtifactsPath buildArtifactsPath(
+            @NonNull String dexPath, @NonNull String isa, boolean isInDalvikCache) {
+        var artifactsPath = new ArtifactsPath();
+        artifactsPath.dexPath = dexPath;
+        artifactsPath.isa = isa;
+        artifactsPath.isInDalvikCache = isInDalvikCache;
+        return artifactsPath;
+    }
+
+    @NonNull
+    public static FsPermission buildFsPermission(
+            int uid, int gid, boolean isOtherReadable, boolean isOtherExecutable) {
+        var fsPermission = new FsPermission();
+        fsPermission.uid = uid;
+        fsPermission.gid = gid;
+        fsPermission.isOtherReadable = isOtherReadable;
+        fsPermission.isOtherExecutable = isOtherExecutable;
+        return fsPermission;
+    }
+
+    @NonNull
+    public static FsPermission buildFsPermission(int uid, int gid, boolean isOtherReadable) {
+        return buildFsPermission(uid, gid, isOtherReadable, false /* isOtherExecutable */);
+    }
+
+    @NonNull
+    public static DexMetadataPath buildDexMetadataPath(@NonNull String dexPath) {
+        var dexMetadataPath = new DexMetadataPath();
+        dexMetadataPath.dexPath = dexPath;
+        return dexMetadataPath;
+    }
+
+    @NonNull
+    public static PermissionSettings buildPermissionSettings(@NonNull FsPermission dirFsPermission,
+            @NonNull FsPermission fileFsPermission, @Nullable SeContext seContext) {
+        var permissionSettings = new PermissionSettings();
+        permissionSettings.dirFsPermission = dirFsPermission;
+        permissionSettings.fileFsPermission = fileFsPermission;
+        permissionSettings.seContext = seContext;
+        return permissionSettings;
+    }
+
+    @NonNull
+    public static OutputArtifacts buildOutputArtifacts(@NonNull String dexPath, @NonNull String isa,
+            boolean isInDalvikCache, @NonNull PermissionSettings permissionSettings) {
+        var outputArtifacts = new OutputArtifacts();
+        outputArtifacts.artifactsPath = buildArtifactsPath(dexPath, isa, isInDalvikCache);
+        outputArtifacts.permissionSettings = permissionSettings;
+        return outputArtifacts;
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index 9b2e9f8..cf083cc 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -142,8 +142,9 @@
                         continue;
                     }
                     for (String isa : Utils.getAllIsas(pkgState)) {
-                        freedBytes += mInjector.getArtd().deleteArtifacts(
-                                Utils.buildArtifactsPath(dexInfo.dexPath(), isa, isInDalvikCache));
+                        freedBytes +=
+                                mInjector.getArtd().deleteArtifacts(AidlUtils.buildArtifactsPath(
+                                        dexInfo.dexPath(), isa, isInDalvikCache));
                     }
                 }
             }
@@ -236,7 +237,11 @@
         PackageState pkgState = getPackageStateOrThrow(snapshot, packageName);
         AndroidPackageApi pkg = getPackageOrThrow(pkgState);
 
-        return mInjector.getDexOptHelper().dexopt(snapshot, pkgState, pkg, options);
+        try {
+            return mInjector.getDexOptHelper().dexopt(snapshot, pkgState, pkg, options);
+        } catch (RemoteException e) {
+            throw new IllegalStateException("An error occurred when calling artd", e);
+        }
     }
 
     private PackageState getPackageStateOrThrow(
@@ -252,7 +257,8 @@
     private AndroidPackageApi getPackageOrThrow(@NonNull PackageState pkgState) {
         AndroidPackageApi pkg = pkgState.getAndroidPackage();
         if (pkg == null) {
-            throw new IllegalStateException("Unable to get package " + pkgState.getPackageName());
+            throw new IllegalArgumentException(
+                    "Unable to get package " + pkgState.getPackageName());
         }
         return pkg;
     }
diff --git a/libartservice/service/java/com/android/server/art/DexOptHelper.java b/libartservice/service/java/com/android/server/art/DexOptHelper.java
index a5cee0b..64060ca 100644
--- a/libartservice/service/java/com/android/server/art/DexOptHelper.java
+++ b/libartservice/service/java/com/android/server/art/DexOptHelper.java
@@ -24,6 +24,7 @@
 import android.content.Context;
 import android.os.Binder;
 import android.os.PowerManager;
+import android.os.RemoteException;
 import android.os.WorkSource;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -71,7 +72,7 @@
     @NonNull
     public OptimizeResult dexopt(@NonNull PackageDataSnapshot snapshot,
             @NonNull PackageState pkgState, @NonNull AndroidPackageApi pkg,
-            @NonNull OptimizeOptions options) {
+            @NonNull OptimizeOptions options) throws RemoteException {
         List<DexFileOptimizeResult> results = new ArrayList<>();
         Supplier<OptimizeResult> createResult = ()
                 -> new OptimizeResult(pkgState.getPackageName(), options.getCompilerFilter(),
diff --git a/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java b/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
index 7315040..1336889 100644
--- a/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
+++ b/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
@@ -16,17 +16,32 @@
 
 package com.android.server.art;
 
+import static com.android.server.art.GetDexoptNeededResult.ArtifactsLocation;
+import static com.android.server.art.OutputArtifacts.PermissionSettings;
+import static com.android.server.art.OutputArtifacts.PermissionSettings.SeContext;
+import static com.android.server.art.PrimaryDexUtils.DetailedPrimaryDexInfo;
 import static com.android.server.art.model.OptimizeResult.DexFileOptimizeResult;
 
+import android.R;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.os.SystemProperties;
+import android.os.UserHandle;
+import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.art.model.OptimizeOptions;
+import com.android.server.art.model.OptimizeResult;
 import com.android.server.art.wrapper.AndroidPackageApi;
 import com.android.server.art.wrapper.PackageState;
 
+import dalvik.system.DexFile;
+
+import java.util.ArrayList;
 import java.util.List;
 
 /** @hide */
@@ -50,8 +65,237 @@
      */
     @NonNull
     public List<DexFileOptimizeResult> dexopt(@NonNull PackageState pkgState,
+            @NonNull AndroidPackageApi pkg, @NonNull OptimizeOptions options)
+            throws RemoteException {
+        List<DexFileOptimizeResult> results = new ArrayList<>();
+
+        String targetCompilerFilter = adjustCompilerFilter(
+                pkgState, pkg, options.getCompilerFilter(), options.getReason());
+        if (targetCompilerFilter.equals(OptimizeOptions.COMPILER_FILTER_NOOP)) {
+            return results;
+        }
+
+        boolean isInDalvikCache = Utils.isInDalvikCache(pkgState);
+
+        for (DetailedPrimaryDexInfo dexInfo : PrimaryDexUtils.getDetailedDexInfo(pkgState, pkg)) {
+            try {
+                if (!dexInfo.hasCode()) {
+                    continue;
+                }
+
+                // TODO(jiakaiz): Support optimizing a single split.
+
+                String compilerFilter = targetCompilerFilter;
+
+                if (DexFile.isProfileGuidedCompilerFilter(compilerFilter)) {
+                    throw new UnsupportedOperationException(
+                            "Profile-guided compilation is not implemented");
+                }
+                PermissionSettings permissionSettings =
+                        getPermissionSettings(pkgState, pkg, true /* canBePublic */);
+
+                DexoptOptions dexoptOptions = getDexoptOptions(pkgState, pkg, options);
+
+                for (String isa : Utils.getAllIsas(pkgState)) {
+                    @OptimizeResult.OptimizeStatus int status = OptimizeResult.OPTIMIZE_SKIPPED;
+                    try {
+                        GetDexoptNeededResult getDexoptNeededResult = getDexoptNeeded(dexInfo, isa,
+                                compilerFilter, options.getShouldDowngrade(), options.getForce());
+
+                        if (!getDexoptNeededResult.isDexoptNeeded) {
+                            continue;
+                        }
+
+                        ProfilePath inputProfile = null;
+
+                        status = dexoptFile(dexInfo, isa, isInDalvikCache, compilerFilter,
+                                inputProfile, getDexoptNeededResult, permissionSettings,
+                                options.getPriorityClass(), dexoptOptions);
+                    } catch (ServiceSpecificException e) {
+                        // Log the error and continue.
+                        Log.e(TAG,
+                                String.format("Failed to dexopt [packageName = %s, dexPath = %s, "
+                                                + "isa = %s, classLoaderContext = %s]",
+                                        pkgState.getPackageName(), dexInfo.dexPath(), isa,
+                                        dexInfo.classLoaderContext()),
+                                e);
+                        status = OptimizeResult.OPTIMIZE_FAILED;
+                    } finally {
+                        results.add(new DexFileOptimizeResult(
+                                dexInfo.dexPath(), isa, compilerFilter, status));
+                    }
+                }
+            } finally {
+                // TODO(jiakaiz): Cleanup profile.
+            }
+        }
+
+        return results;
+    }
+
+    @NonNull
+    private String adjustCompilerFilter(@NonNull PackageState pkgState,
+            @NonNull AndroidPackageApi pkg, @NonNull String targetCompilerFilter,
+            @NonNull String reason) {
+        if (mInjector.isSystemUiPackage(pkgState.getPackageName())) {
+            String systemUiCompilerFilter = getSystemUiCompilerFilter();
+            if (!systemUiCompilerFilter.isEmpty()) {
+                return systemUiCompilerFilter;
+            }
+        }
+
+        // We force vmSafeMode on debuggable apps as well:
+        //  - the runtime ignores their compiled code
+        //  - they generally have lots of methods that could make the compiler used run out of
+        //    memory (b/130828957)
+        // Note that forcing the compiler filter here applies to all compilations (even if they
+        // are done via adb shell commands). This is okay because the runtime will ignore the
+        // compiled code anyway.
+        if (pkg.isVmSafeMode() || pkg.isDebuggable()) {
+            return DexFile.getSafeModeCompilerFilter(targetCompilerFilter);
+        }
+
+        return targetCompilerFilter;
+    }
+
+    @NonNull
+    private String getSystemUiCompilerFilter() {
+        String compilerFilter = SystemProperties.get("dalvik.vm.systemuicompilerfilter");
+        if (!compilerFilter.isEmpty() && !Utils.isValidArtServiceCompilerFilter(compilerFilter)) {
+            throw new IllegalStateException(
+                    "Got invalid compiler filter '" + compilerFilter + "' for System UI");
+        }
+        return compilerFilter;
+    }
+
+    @NonNull
+    PermissionSettings getPermissionSettings(
+            @NonNull PackageState pkgState, @NonNull AndroidPackageApi pkg, boolean canBePublic) {
+        int uid = pkg.getUid();
+        if (uid < 0) {
+            throw new IllegalStateException(
+                    "Package '" + pkgState.getPackageName() + "' has invalid app uid");
+        }
+        int sharedGid = UserHandle.getSharedAppGid(uid);
+        if (sharedGid < 0) {
+            throw new IllegalStateException(
+                    String.format("Unable to get shared gid for package '%s' (uid: %d)",
+                            pkgState.getPackageName(), uid));
+        }
+
+        // The files and directories should belong to the system so that Package Manager can manage
+        // them (e.g., move them around).
+        // We don't need the "read" bit for "others" on the directories because others only need to
+        // access the files in the directories, but they don't need to "ls" the directories.
+        FsPermission dirFsPermission = AidlUtils.buildFsPermission(Process.SYSTEM_UID,
+                Process.SYSTEM_UID, false /* isOtherReadable */, true /* isOtherExecutable */);
+        FsPermission fileFsPermission =
+                AidlUtils.buildFsPermission(Process.SYSTEM_UID, sharedGid, canBePublic);
+        // For primary dex, we can use the default SELinux context.
+        SeContext seContext = null;
+        return AidlUtils.buildPermissionSettings(dirFsPermission, fileFsPermission, seContext);
+    }
+
+    @NonNull
+    private DexoptOptions getDexoptOptions(@NonNull PackageState pkgState,
             @NonNull AndroidPackageApi pkg, @NonNull OptimizeOptions options) {
-        throw new UnsupportedOperationException();
+        DexoptOptions dexoptOptions = new DexoptOptions();
+        dexoptOptions.compilationReason = options.getReason();
+        dexoptOptions.targetSdkVersion = pkg.getTargetSdkVersion();
+        dexoptOptions.debuggable = pkg.isDebuggable() || isAlwaysDebuggable();
+        dexoptOptions.generateAppImage = false;
+        dexoptOptions.hiddenApiPolicyEnabled = isHiddenApiPolicyEnabled(pkgState, pkg);
+        return dexoptOptions;
+    }
+
+    private boolean isAlwaysDebuggable() {
+        return SystemProperties.getBoolean("dalvik.vm.always_debuggable", false /* def */);
+    }
+
+    private boolean isAppImageEnabled() {
+        return !SystemProperties.get("dalvik.vm.appimageformat").isEmpty();
+    }
+
+    private boolean isHiddenApiPolicyEnabled(
+            @NonNull PackageState pkgState, @NonNull AndroidPackageApi pkg) {
+        if (pkg.isSignedWithPlatformKey()) {
+            return false;
+        }
+        if (pkgState.isSystem() || pkgState.isUpdatedSystemApp()) {
+            // TODO(b/236389629): Check whether the app is in hidden api whitelist.
+            return !pkg.isUsesNonSdkApi();
+        }
+        return true;
+    }
+
+    @NonNull
+    GetDexoptNeededResult getDexoptNeeded(@NonNull DetailedPrimaryDexInfo dexInfo,
+            @NonNull String isa, @NonNull String compilerFilter, boolean shouldDowngrade,
+            boolean force) throws RemoteException {
+        int dexoptTrigger = getDexoptTrigger(shouldDowngrade, force);
+
+        // The result should come from artd even if all the bits of `dexoptTrigger` are set
+        // because the result also contains information about the usable VDEX file.
+        GetDexoptNeededResult result = mInjector.getArtd().getDexoptNeeded(dexInfo.dexPath(), isa,
+                dexInfo.classLoaderContext(), compilerFilter, dexoptTrigger);
+
+        return result;
+    }
+
+    int getDexoptTrigger(boolean shouldDowngrade, boolean force) {
+        if (force) {
+            return DexoptTrigger.COMPILER_FILTER_IS_BETTER | DexoptTrigger.COMPILER_FILTER_IS_SAME
+                    | DexoptTrigger.COMPILER_FILTER_IS_WORSE
+                    | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
+        }
+
+        if (shouldDowngrade) {
+            return DexoptTrigger.COMPILER_FILTER_IS_WORSE;
+        }
+
+        return DexoptTrigger.COMPILER_FILTER_IS_BETTER
+                | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
+    }
+
+    private @OptimizeResult.OptimizeStatus int dexoptFile(@NonNull DetailedPrimaryDexInfo dexInfo,
+            @NonNull String isa, boolean isInDalvikCache, @NonNull String compilerFilter,
+            @Nullable ProfilePath profile, @NonNull GetDexoptNeededResult getDexoptNeededResult,
+            @NonNull PermissionSettings permissionSettings, @PriorityClass byte priorityClass,
+            @NonNull DexoptOptions dexoptOptions) throws RemoteException {
+        OutputArtifacts outputArtifacts = AidlUtils.buildOutputArtifacts(
+                dexInfo.dexPath(), isa, isInDalvikCache, permissionSettings);
+
+        VdexPath inputVdex = getInputVdex(getDexoptNeededResult, dexInfo.dexPath(), isa);
+
+        if (!mInjector.getArtd().dexopt(outputArtifacts, dexInfo.dexPath(), isa,
+                    dexInfo.classLoaderContext(), compilerFilter, profile, inputVdex, priorityClass,
+                    dexoptOptions)) {
+            return OptimizeResult.OPTIMIZE_CANCELLED;
+        }
+
+        return OptimizeResult.OPTIMIZE_PERFORMED;
+    }
+
+    @Nullable
+    private VdexPath getInputVdex(@NonNull GetDexoptNeededResult getDexoptNeededResult,
+            @NonNull String dexPath, @NonNull String isa) {
+        if (!getDexoptNeededResult.isVdexUsable) {
+            return null;
+        }
+        switch (getDexoptNeededResult.artifactsLocation) {
+            case ArtifactsLocation.DALVIK_CACHE:
+                return VdexPath.artifactsPath(
+                        AidlUtils.buildArtifactsPath(dexPath, isa, true /* isInDalvikCache */));
+            case ArtifactsLocation.NEXT_TO_DEX:
+                return VdexPath.artifactsPath(
+                        AidlUtils.buildArtifactsPath(dexPath, isa, false /* isInDalvikCache */));
+            case ArtifactsLocation.DM:
+                return VdexPath.dexMetadataPath(AidlUtils.buildDexMetadataPath(dexPath));
+            default:
+                // This should never happen as the value is got from artd.
+                throw new IllegalStateException(
+                        "Unknown artifacts location " + getDexoptNeededResult.artifactsLocation);
+        }
     }
 
     /**
@@ -68,6 +312,13 @@
             mContext = context;
         }
 
+        boolean isSystemUiPackage(@NonNull String packageName) {
+            if (mContext == null) {
+                return false;
+            }
+            return packageName.equals(mContext.getString(R.string.config_systemUi));
+        }
+
         @NonNull
         public IArtd getArtd() {
             return Utils.getArtd();
diff --git a/libartservice/service/java/com/android/server/art/Utils.java b/libartservice/service/java/com/android/server/art/Utils.java
index b30d208..68cdf07 100644
--- a/libartservice/service/java/com/android/server/art/Utils.java
+++ b/libartservice/service/java/com/android/server/art/Utils.java
@@ -71,16 +71,6 @@
         return List.of();
     }
 
-    @NonNull
-    public static ArtifactsPath buildArtifactsPath(
-            @NonNull String dexPath, @NonNull String isa, boolean isInDalvikCache) {
-        ArtifactsPath artifactsPath = new ArtifactsPath();
-        artifactsPath.dexPath = dexPath;
-        artifactsPath.isa = isa;
-        artifactsPath.isInDalvikCache = isInDalvikCache;
-        return artifactsPath;
-    }
-
     public static boolean isInDalvikCache(@NonNull PackageState pkg) {
         return pkg.isSystem() && !pkg.isUpdatedSystemApp();
     }
diff --git a/libartservice/service/java/com/android/server/art/wrapper/AndroidPackageApi.java b/libartservice/service/java/com/android/server/art/wrapper/AndroidPackageApi.java
index 1c3ff6d..66f82a6 100644
--- a/libartservice/service/java/com/android/server/art/wrapper/AndroidPackageApi.java
+++ b/libartservice/service/java/com/android/server/art/wrapper/AndroidPackageApi.java
@@ -122,4 +122,44 @@
             throw new RuntimeException(e);
         }
     }
+
+    public boolean isVmSafeMode() {
+        try {
+            return (boolean) mPkg.getClass().getMethod("isVmSafeMode").invoke(mPkg);
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public boolean isDebuggable() {
+        try {
+            return (boolean) mPkg.getClass().getMethod("isDebuggable").invoke(mPkg);
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public boolean isSignedWithPlatformKey() {
+        try {
+            return (boolean) mPkg.getClass().getMethod("isSignedWithPlatformKey").invoke(mPkg);
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public boolean isUsesNonSdkApi() {
+        try {
+            return (boolean) mPkg.getClass().getMethod("isUsesNonSdkApi").invoke(mPkg);
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public int getTargetSdkVersion() {
+        try {
+            return (int) mPkg.getClass().getMethod("getTargetSdkVersion").invoke(mPkg);
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
 }
diff --git a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
index afaa3ee..ded89c9 100644
--- a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
+++ b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
@@ -135,7 +135,7 @@
         mArtManagerLocal.deleteOptimizedArtifacts(mock(PackageDataSnapshot.class), PKG_NAME);
     }
 
-    @Test(expected = IllegalStateException.class)
+    @Test(expected = IllegalArgumentException.class)
     public void testDeleteOptimizedArtifactsNoPackage() throws Exception {
         when(mPkgState.getAndroidPackage()).thenReturn(null);
 
@@ -192,7 +192,7 @@
         mArtManagerLocal.getOptimizationStatus(mock(PackageDataSnapshot.class), PKG_NAME);
     }
 
-    @Test(expected = IllegalStateException.class)
+    @Test(expected = IllegalArgumentException.class)
     public void testGetOptimizationStatusNoPackage() throws Exception {
         when(mPkgState.getAndroidPackage()).thenReturn(null);
 
@@ -238,7 +238,7 @@
                 new OptimizeOptions.Builder("install").build());
     }
 
-    @Test(expected = IllegalStateException.class)
+    @Test(expected = IllegalArgumentException.class)
     public void testOptimizePackageNoPackage() throws Exception {
         when(mPkgState.getAndroidPackage()).thenReturn(null);
 
diff --git a/libartservice/service/javatests/com/android/server/art/DexOptHelperTest.java b/libartservice/service/javatests/com/android/server/art/DexOptHelperTest.java
index dffbc1f..e68f21b 100644
--- a/libartservice/service/javatests/com/android/server/art/DexOptHelperTest.java
+++ b/libartservice/service/javatests/com/android/server/art/DexOptHelperTest.java
@@ -94,7 +94,7 @@
     }
 
     @Test
-    public void testDexopt() {
+    public void testDexopt() throws Exception {
         when(mPrimaryDexOptimizer.dexopt(same(mPkgState), same(mPkg), same(mOptions)))
                 .thenReturn(mPrimaryResults);
 
@@ -109,7 +109,7 @@
     }
 
     @Test
-    public void testDexoptNoCode() {
+    public void testDexoptNoCode() throws Exception {
         when(mPkg.isHasCode()).thenReturn(false);
 
         OptimizeResult result =
@@ -120,7 +120,7 @@
     }
 
     @Test
-    public void testDexoptWithAppHibernationManager() {
+    public void testDexoptWithAppHibernationManager() throws Exception {
         when(mInjector.getAppHibernationManager()).thenReturn(mAhm);
         lenient().when(mAhm.isHibernatingGlobally(PKG_NAME)).thenReturn(false);
         lenient().when(mAhm.isOatArtifactDeletionEnabled()).thenReturn(true);
@@ -135,7 +135,7 @@
     }
 
     @Test
-    public void testDexoptIsHibernating() {
+    public void testDexoptIsHibernating() throws Exception {
         when(mInjector.getAppHibernationManager()).thenReturn(mAhm);
         lenient().when(mAhm.isHibernatingGlobally(PKG_NAME)).thenReturn(true);
         lenient().when(mAhm.isOatArtifactDeletionEnabled()).thenReturn(true);
@@ -148,7 +148,7 @@
     }
 
     @Test
-    public void testDexoptIsHibernatingButOatArtifactDeletionDisabled() {
+    public void testDexoptIsHibernatingButOatArtifactDeletionDisabled() throws Exception {
         when(mInjector.getAppHibernationManager()).thenReturn(mAhm);
         lenient().when(mAhm.isHibernatingGlobally(PKG_NAME)).thenReturn(true);
         lenient().when(mAhm.isOatArtifactDeletionEnabled()).thenReturn(false);
@@ -163,7 +163,7 @@
     }
 
     @Test
-    public void testDexoptWithPowerManager() {
+    public void testDexoptWithPowerManager() throws Exception {
         var wakeLock = mock(PowerManager.WakeLock.class);
         when(mInjector.getPowerManager()).thenReturn(mPowerManager);
         when(mPowerManager.newWakeLock(eq(PowerManager.PARTIAL_WAKE_LOCK), any()))
@@ -182,7 +182,7 @@
     }
 
     @Test
-    public void testDexoptAlwaysReleasesWakeLock() {
+    public void testDexoptAlwaysReleasesWakeLock() throws Exception {
         var wakeLock = mock(PowerManager.WakeLock.class);
         when(mInjector.getPowerManager()).thenReturn(mPowerManager);
         when(mPowerManager.newWakeLock(eq(PowerManager.PARTIAL_WAKE_LOCK), any()))
diff --git a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerParameterizedTest.java b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerParameterizedTest.java
new file mode 100644
index 0000000..d1eba96
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerParameterizedTest.java
@@ -0,0 +1,299 @@
+/*
+ * 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.server.art;
+
+import static com.android.server.art.AidlUtils.buildFsPermission;
+import static com.android.server.art.AidlUtils.buildOutputArtifacts;
+import static com.android.server.art.AidlUtils.buildPermissionSettings;
+import static com.android.server.art.OutputArtifacts.PermissionSettings;
+import static com.android.server.art.model.OptimizeResult.DexFileOptimizeResult;
+import static com.android.server.art.testing.TestingUtils.deepEq;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.isNull;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.os.Process;
+import android.os.ServiceSpecificException;
+import android.os.SystemProperties;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.server.art.model.OptimizeOptions;
+import com.android.server.art.model.OptimizeResult;
+import com.android.server.art.testing.OnSuccessRule;
+import com.android.server.art.testing.TestingUtils;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SmallTest
+@RunWith(Parameterized.class)
+public class PrimaryDexOptimizerParameterizedTest extends PrimaryDexOptimizerTestBase {
+    @Rule
+    public OnSuccessRule onSuccessRule = new OnSuccessRule(() -> {
+        // Don't do this on failure because it will make the failure hard to understand.
+        verifyNoMoreInteractions(mArtd);
+    });
+
+    private OptimizeOptions mOptions;
+
+    @Parameter(0) public Params mParams;
+
+    @Parameters(name = "{0}")
+    public static Iterable<Params> data() {
+        List<Params> list = new ArrayList<>();
+        Params params;
+
+        // Baseline.
+        params = new Params();
+        list.add(params);
+
+        params = new Params();
+        params.mRequestedCompilerFilter = "speed";
+        params.mExpectedCompilerFilter = "speed";
+        list.add(params);
+
+        params = new Params();
+        params.mIsSystem = true;
+        params.mExpectedIsInDalvikCache = true;
+        list.add(params);
+
+        params = new Params();
+        params.mIsSystem = true;
+        params.mIsUpdatedSystemApp = true;
+        list.add(params);
+
+        params = new Params();
+        params.mIsSystem = true;
+        params.mIsUsesNonSdkApi = true;
+        params.mExpectedIsInDalvikCache = true;
+        params.mExpectedIsHiddenApiPolicyEnabled = false;
+        list.add(params);
+
+        params = new Params();
+        params.mIsUpdatedSystemApp = true;
+        params.mIsUsesNonSdkApi = true;
+        params.mExpectedIsHiddenApiPolicyEnabled = false;
+        list.add(params);
+
+        params = new Params();
+        params.mIsSignedWithPlatformKey = true;
+        params.mExpectedIsHiddenApiPolicyEnabled = false;
+        list.add(params);
+
+        params = new Params();
+        params.mIsDebuggable = true;
+        params.mRequestedCompilerFilter = "speed";
+        params.mExpectedCompilerFilter = "verify";
+        params.mExpectedIsDebuggable = true;
+        list.add(params);
+
+        params = new Params();
+        params.mIsVmSafeMode = true;
+        params.mRequestedCompilerFilter = "speed";
+        params.mExpectedCompilerFilter = "verify";
+        list.add(params);
+
+        params = new Params();
+        params.mAlwaysDebuggable = true;
+        params.mExpectedIsDebuggable = true;
+        list.add(params);
+
+        params = new Params();
+        params.mIsSystemUi = true;
+        params.mExpectedCompilerFilter = "speed";
+        list.add(params);
+
+        params = new Params();
+        params.mForce = true;
+        params.mShouldDowngrade = false;
+        params.mExpectedDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_BETTER
+                | DexoptTrigger.COMPILER_FILTER_IS_SAME | DexoptTrigger.COMPILER_FILTER_IS_WORSE
+                | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
+        list.add(params);
+
+        params = new Params();
+        params.mForce = true;
+        params.mShouldDowngrade = true;
+        params.mExpectedDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_BETTER
+                | DexoptTrigger.COMPILER_FILTER_IS_SAME | DexoptTrigger.COMPILER_FILTER_IS_WORSE
+                | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
+        list.add(params);
+
+        params = new Params();
+        params.mShouldDowngrade = true;
+        params.mExpectedDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_WORSE;
+        list.add(params);
+
+        return list;
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+
+        lenient().when(mInjector.isSystemUiPackage(any())).thenReturn(mParams.mIsSystemUi);
+
+        lenient()
+                .when(SystemProperties.getBoolean(eq("dalvik.vm.always_debuggable"), anyBoolean()))
+                .thenReturn(mParams.mAlwaysDebuggable);
+
+        lenient().when(mPkg.isVmSafeMode()).thenReturn(mParams.mIsVmSafeMode);
+        lenient().when(mPkg.isDebuggable()).thenReturn(mParams.mIsDebuggable);
+        lenient().when(mPkg.getTargetSdkVersion()).thenReturn(123);
+        lenient().when(mPkg.isSignedWithPlatformKey()).thenReturn(mParams.mIsSignedWithPlatformKey);
+        lenient().when(mPkg.isUsesNonSdkApi()).thenReturn(mParams.mIsUsesNonSdkApi);
+        lenient().when(mPkgState.isSystem()).thenReturn(mParams.mIsSystem);
+        lenient().when(mPkgState.isUpdatedSystemApp()).thenReturn(mParams.mIsUpdatedSystemApp);
+
+        mOptions = new OptimizeOptions.Builder("install")
+                           .setCompilerFilter(mParams.mRequestedCompilerFilter)
+                           .setPriorityClass(PriorityClass.INTERACTIVE)
+                           .setForce(mParams.mForce)
+                           .setShouldDowngrade(mParams.mShouldDowngrade)
+                           .build();
+    }
+
+    @Test
+    public void testDexopt() throws Exception {
+        PermissionSettings permissionSettings = buildPermissionSettings(
+                buildFsPermission(Process.SYSTEM_UID, Process.SYSTEM_UID,
+                        false /* isOtherReadable */, true /* isOtherExecutable */),
+                buildFsPermission(Process.SYSTEM_UID, 52345, true /* isOtherReadable */),
+                null /* seContext */);
+        DexoptOptions dexoptOptions = new DexoptOptions();
+        dexoptOptions.compilationReason = "install";
+        dexoptOptions.targetSdkVersion = 123;
+        dexoptOptions.debuggable = mParams.mExpectedIsDebuggable;
+        dexoptOptions.generateAppImage = false;
+        dexoptOptions.hiddenApiPolicyEnabled = mParams.mExpectedIsHiddenApiPolicyEnabled;
+
+        // The first one is normal.
+        doReturn(dexoptIsNeeded())
+                .when(mArtd)
+                .getDexoptNeeded("/data/app/foo/base.apk", "arm64", "PCL[]",
+                        mParams.mExpectedCompilerFilter, mParams.mExpectedDexoptTrigger);
+        doReturn(true).when(mArtd).dexopt(
+                deepEq(buildOutputArtifacts("/data/app/foo/base.apk", "arm64",
+                        mParams.mExpectedIsInDalvikCache, permissionSettings)),
+                eq("/data/app/foo/base.apk"), eq("arm64"), eq("PCL[]"),
+                eq(mParams.mExpectedCompilerFilter), isNull() /* profile */,
+                isNull() /* inputVdex */, eq(PriorityClass.INTERACTIVE), deepEq(dexoptOptions));
+
+        // The second one fails on `dexopt`.
+        doReturn(dexoptIsNeeded())
+                .when(mArtd)
+                .getDexoptNeeded("/data/app/foo/base.apk", "arm", "PCL[]",
+                        mParams.mExpectedCompilerFilter, mParams.mExpectedDexoptTrigger);
+        doThrow(ServiceSpecificException.class)
+                .when(mArtd)
+                .dexopt(deepEq(buildOutputArtifacts("/data/app/foo/base.apk", "arm",
+                                mParams.mExpectedIsInDalvikCache, permissionSettings)),
+                        eq("/data/app/foo/base.apk"), eq("arm"), eq("PCL[]"),
+                        eq(mParams.mExpectedCompilerFilter), isNull() /* profile */,
+                        isNull() /* inputVdex */, eq(PriorityClass.INTERACTIVE),
+                        deepEq(dexoptOptions));
+
+        // The third one doesn't need dexopt.
+        doReturn(dexoptIsNotNeeded())
+                .when(mArtd)
+                .getDexoptNeeded("/data/app/foo/split_0.apk", "arm64", "PCL[base.apk]",
+                        mParams.mExpectedCompilerFilter, mParams.mExpectedDexoptTrigger);
+
+        // The fourth one is normal.
+        doReturn(dexoptIsNeeded())
+                .when(mArtd)
+                .getDexoptNeeded("/data/app/foo/split_0.apk", "arm", "PCL[base.apk]",
+                        mParams.mExpectedCompilerFilter, mParams.mExpectedDexoptTrigger);
+        doReturn(true).when(mArtd).dexopt(
+                deepEq(buildOutputArtifacts("/data/app/foo/split_0.apk", "arm",
+                        mParams.mExpectedIsInDalvikCache, permissionSettings)),
+                eq("/data/app/foo/split_0.apk"), eq("arm"), eq("PCL[base.apk]"),
+                eq(mParams.mExpectedCompilerFilter), isNull() /* profile */,
+                isNull() /* inputVdex */, eq(PriorityClass.INTERACTIVE), deepEq(dexoptOptions));
+
+        assertThat(mPrimaryDexOptimizer.dexopt(mPkgState, mPkg, mOptions))
+                .comparingElementsUsing(TestingUtils.<DexFileOptimizeResult>deepEquality())
+                .containsExactly(
+                        new DexFileOptimizeResult("/data/app/foo/base.apk", "arm64",
+                                mParams.mExpectedCompilerFilter, OptimizeResult.OPTIMIZE_PERFORMED),
+                        new DexFileOptimizeResult("/data/app/foo/base.apk", "arm",
+                                mParams.mExpectedCompilerFilter, OptimizeResult.OPTIMIZE_FAILED),
+                        new DexFileOptimizeResult("/data/app/foo/split_0.apk", "arm64",
+                                mParams.mExpectedCompilerFilter, OptimizeResult.OPTIMIZE_SKIPPED),
+                        new DexFileOptimizeResult("/data/app/foo/split_0.apk", "arm",
+                                mParams.mExpectedCompilerFilter,
+                                OptimizeResult.OPTIMIZE_PERFORMED));
+    }
+
+    private static class Params {
+        // Package information.
+        public boolean mIsSystem = false;
+        public boolean mIsUpdatedSystemApp = false;
+        public boolean mIsSignedWithPlatformKey = false;
+        public boolean mIsUsesNonSdkApi = false;
+        public boolean mIsVmSafeMode = false;
+        public boolean mIsDebuggable = false;
+        public boolean mIsSystemUi = false;
+
+        // Options.
+        public String mRequestedCompilerFilter = "verify";
+        public boolean mForce = false;
+        public boolean mShouldDowngrade = false;
+
+        // System properties.
+        public boolean mAlwaysDebuggable = false;
+
+        // Expectations.
+        public String mExpectedCompilerFilter = "verify";
+        public int mExpectedDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_BETTER
+                | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
+        public boolean mExpectedIsInDalvikCache = false;
+        public boolean mExpectedIsDebuggable = false;
+        public boolean mExpectedIsHiddenApiPolicyEnabled = true;
+
+        public String toString() {
+            return String.format("isSystem=%b,isUpdatedSystemApp=%b,isSignedWithPlatformKey=%b,"
+                            + "isUsesNonSdkApi=%b,isVmSafeMode=%b,isDebuggable=%b,isSystemUi=%b,"
+                            + "requestedCompilerFilter=%s,force=%b,shouldDowngrade=%b,"
+                            + "alwaysDebuggable=%b => targetCompilerFilter=%s,"
+                            + "expectedDexoptTrigger=%d,expectedIsInDalvikCache=%b,"
+                            + "expectedIsDebuggable=%b,expectedIsHiddenApiPolicyEnabled=%b",
+                    mIsSystem, mIsUpdatedSystemApp, mIsSignedWithPlatformKey, mIsUsesNonSdkApi,
+                    mIsVmSafeMode, mIsDebuggable, mIsSystemUi, mRequestedCompilerFilter, mForce,
+                    mShouldDowngrade, mAlwaysDebuggable, mExpectedCompilerFilter,
+                    mExpectedDexoptTrigger, mExpectedIsInDalvikCache, mExpectedIsDebuggable,
+                    mExpectedIsHiddenApiPolicyEnabled);
+        }
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
new file mode 100644
index 0000000..594d054
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.server.art;
+
+import static com.android.server.art.GetDexoptNeededResult.ArtifactsLocation;
+import static com.android.server.art.testing.TestingUtils.deepEq;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyByte;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.isNull;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.art.model.OptimizeOptions;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class PrimaryDexOptimizerTest extends PrimaryDexOptimizerTestBase {
+    private OptimizeOptions mOptions;
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+
+        mOptions = new OptimizeOptions.Builder("install").setCompilerFilter("verify").build();
+    }
+
+    @Test
+    public void testDexoptInputVdex() throws Exception {
+        // null.
+        doReturn(dexoptIsNeeded(ArtifactsLocation.NONE_OR_ERROR))
+                .when(mArtd)
+                .getDexoptNeeded(eq("/data/app/foo/base.apk"), eq("arm64"), any(), any(), anyInt());
+        doReturn(true).when(mArtd).dexopt(any(), eq("/data/app/foo/base.apk"), eq("arm64"), any(),
+                any(), any(), isNull(), anyByte(), any());
+
+        // ArtifactsPath, isInDalvikCache=true.
+        doReturn(dexoptIsNeeded(ArtifactsLocation.DALVIK_CACHE))
+                .when(mArtd)
+                .getDexoptNeeded(eq("/data/app/foo/base.apk"), eq("arm"), any(), any(), anyInt());
+        doReturn(true).when(mArtd).dexopt(any(), eq("/data/app/foo/base.apk"), eq("arm"), any(),
+                any(), any(),
+                deepEq(VdexPath.artifactsPath(AidlUtils.buildArtifactsPath(
+                        "/data/app/foo/base.apk", "arm", true /* isInDalvikCache */))),
+                anyByte(), any());
+
+        // ArtifactsPath, isInDalvikCache=false.
+        doReturn(dexoptIsNeeded(ArtifactsLocation.NEXT_TO_DEX))
+                .when(mArtd)
+                .getDexoptNeeded(
+                        eq("/data/app/foo/split_0.apk"), eq("arm64"), any(), any(), anyInt());
+        doReturn(true).when(mArtd).dexopt(any(), eq("/data/app/foo/split_0.apk"), eq("arm64"),
+                any(), any(), any(),
+                deepEq(VdexPath.artifactsPath(AidlUtils.buildArtifactsPath(
+                        "/data/app/foo/split_0.apk", "arm64", false /* isInDalvikCache */))),
+                anyByte(), any());
+
+        // DexMetadataPath.
+        doReturn(dexoptIsNeeded(ArtifactsLocation.DM))
+                .when(mArtd)
+                .getDexoptNeeded(
+                        eq("/data/app/foo/split_0.apk"), eq("arm"), any(), any(), anyInt());
+        doReturn(true).when(mArtd).dexopt(any(), eq("/data/app/foo/split_0.apk"), eq("arm"), any(),
+                any(), any(),
+                deepEq(VdexPath.dexMetadataPath(
+                        AidlUtils.buildDexMetadataPath("/data/app/foo/split_0.apk"))),
+                anyByte(), any());
+
+        mPrimaryDexOptimizer.dexopt(mPkgState, mPkg, mOptions);
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java
new file mode 100644
index 0000000..92b4cb2
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java
@@ -0,0 +1,127 @@
+/*
+ * 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.server.art;
+
+import static com.android.server.art.GetDexoptNeededResult.ArtifactsLocation;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+
+import android.content.pm.ApplicationInfo;
+import android.os.SystemProperties;
+
+import com.android.server.art.testing.StaticMockitoRule;
+import com.android.server.art.wrapper.AndroidPackageApi;
+import com.android.server.art.wrapper.PackageState;
+
+import dalvik.system.PathClassLoader;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.mockito.Mock;
+
+import java.util.ArrayList;
+
+public class PrimaryDexOptimizerTestBase {
+    protected static final String PKG_NAME = "com.example.foo";
+
+    @Rule public StaticMockitoRule mockitoRule = new StaticMockitoRule(SystemProperties.class);
+
+    @Mock protected PrimaryDexOptimizer.Injector mInjector;
+    @Mock protected IArtd mArtd;
+    protected PackageState mPkgState;
+    protected AndroidPackageApi mPkg;
+
+    protected PrimaryDexOptimizer mPrimaryDexOptimizer;
+
+    @Before
+    public void setUp() throws Exception {
+        lenient().when(mInjector.getArtd()).thenReturn(mArtd);
+        lenient().when(mInjector.isSystemUiPackage(any())).thenReturn(false);
+
+        lenient()
+                .when(SystemProperties.get("dalvik.vm.systemuicompilerfilter"))
+                .thenReturn("speed");
+        lenient()
+                .when(SystemProperties.getBoolean(eq("dalvik.vm.always_debuggable"), anyBoolean()))
+                .thenReturn(false);
+
+        mPkgState = createPackageState();
+        mPkg = mPkgState.getAndroidPackage();
+
+        mPrimaryDexOptimizer = new PrimaryDexOptimizer(mInjector);
+    }
+
+    private AndroidPackageApi createPackage() {
+        // This package has the base APK and one split APK that has code.
+        AndroidPackageApi pkg = mock(AndroidPackageApi.class);
+        lenient().when(pkg.getBaseApkPath()).thenReturn("/data/app/foo/base.apk");
+        lenient().when(pkg.isHasCode()).thenReturn(true);
+        lenient().when(pkg.getClassLoaderName()).thenReturn(PathClassLoader.class.getName());
+        lenient().when(pkg.getSplitNames()).thenReturn(new String[] {"split_0", "split_1"});
+        lenient()
+                .when(pkg.getSplitCodePaths())
+                .thenReturn(
+                        new String[] {"/data/app/foo/split_0.apk", "/data/app/foo/split_1.apk"});
+        lenient()
+                .when(pkg.getSplitFlags())
+                .thenReturn(new int[] {ApplicationInfo.FLAG_HAS_CODE, 0});
+        lenient().when(pkg.getUid()).thenReturn(12345);
+        lenient().when(pkg.isVmSafeMode()).thenReturn(false);
+        lenient().when(pkg.isDebuggable()).thenReturn(false);
+        lenient().when(pkg.getTargetSdkVersion()).thenReturn(123);
+        lenient().when(pkg.isSignedWithPlatformKey()).thenReturn(false);
+        lenient().when(pkg.isUsesNonSdkApi()).thenReturn(false);
+        return pkg;
+    }
+
+    private PackageState createPackageState() {
+        PackageState pkgState = mock(PackageState.class);
+        lenient().when(pkgState.getPackageName()).thenReturn(PKG_NAME);
+        lenient().when(pkgState.getPrimaryCpuAbi()).thenReturn("arm64-v8a");
+        lenient().when(pkgState.getSecondaryCpuAbi()).thenReturn("armeabi-v7a");
+        lenient().when(pkgState.isSystem()).thenReturn(false);
+        lenient().when(pkgState.isUpdatedSystemApp()).thenReturn(false);
+        lenient().when(pkgState.getUsesLibraryInfos()).thenReturn(new ArrayList<>());
+        AndroidPackageApi pkg = createPackage();
+        lenient().when(pkgState.getAndroidPackage()).thenReturn(pkg);
+        return pkgState;
+    }
+
+    protected GetDexoptNeededResult dexoptIsNotNeeded() {
+        var result = new GetDexoptNeededResult();
+        result.isDexoptNeeded = false;
+        return result;
+    }
+
+    protected GetDexoptNeededResult dexoptIsNeeded() {
+        return dexoptIsNeeded(ArtifactsLocation.NONE_OR_ERROR);
+    }
+
+    protected GetDexoptNeededResult dexoptIsNeeded(@ArtifactsLocation byte location) {
+        var result = new GetDexoptNeededResult();
+        result.isDexoptNeeded = true;
+        result.artifactsLocation = location;
+        if (location != ArtifactsLocation.NONE_OR_ERROR) {
+            result.isVdexUsable = true;
+        }
+        return result;
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java b/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java
new file mode 100644
index 0000000..e7582b1
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java
@@ -0,0 +1,120 @@
+/*
+ * 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.server.art.testing;
+
+import static org.mockito.Mockito.argThat;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.Log;
+
+import com.google.common.truth.Correspondence;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+
+public final class TestingUtils {
+    private static final String TAG = "TestingUtils";
+
+    private TestingUtils() {}
+
+    /**
+     * Recursively compares two objects using reflection. Returns true if the two objects are equal.
+     * For simplicity, this method only supports types that every field is a primitive type, a
+     * string, or a supported type.
+     */
+    public static boolean deepEquals(
+            @Nullable Object a, @Nullable Object b, @NonNull StringBuilder errorMsg) {
+        try {
+            if (a == null && b == null) {
+                return true;
+            }
+            if (a == null || b == null) {
+                errorMsg.append(String.format("Nullability mismatch: %s != %s",
+                        a == null ? "null" : "nonnull", b == null ? "null" : "nonnull"));
+                return false;
+            }
+            if (a.getClass() != b.getClass()) {
+                errorMsg.append(
+                        String.format("Type mismatch: %s != %s", a.getClass(), b.getClass()));
+                return false;
+            }
+            if (a.getClass() == String.class) {
+                if (!a.equals(b)) {
+                    errorMsg.append(String.format("%s != %s", a, b));
+                }
+                return a.equals(b);
+            }
+            if (a.getClass().isArray()) {
+                throw new UnsupportedOperationException("Array type is not supported");
+            }
+            for (Field field : a.getClass().getDeclaredFields()) {
+                if (Modifier.isStatic(field.getModifiers())) {
+                    continue;
+                }
+                field.setAccessible(true);
+                if (field.getType().isPrimitive()) {
+                    if (!field.get(a).equals(field.get(b))) {
+                        errorMsg.append(String.format("Field %s mismatch: %s != %s",
+                                field.getName(), field.get(a), field.get(b)));
+                        return false;
+                    }
+                } else if (!deepEquals(field.get(a), field.get(b), errorMsg)) {
+                    errorMsg.insert(0, String.format("Field %s mismatch: ", field.getName()));
+                    return false;
+                }
+            }
+            return true;
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /** Same as above, but ignores any error message. */
+    public static boolean deepEquals(@Nullable Object a, @Nullable Object b) {
+        var errorMsgIgnored = new StringBuilder();
+        return deepEquals(a, b, errorMsgIgnored);
+    }
+
+    /**
+     * A Mockito argument matcher that uses {@link #deepEquals} to compare objects and logs any
+     * mismatch.
+     */
+    public static <T> T deepEq(@Nullable T expected) {
+        return argThat(arg -> {
+            var errorMsg = new StringBuilder();
+            boolean result = deepEquals(arg, expected, errorMsg);
+            if (!result) {
+                Log.e(TAG, errorMsg.toString());
+            }
+            return result;
+        });
+    }
+
+    /**
+     * A Truth correspondence that uses {@link #deepEquals} to compare objects and reports any
+     * mismatch.
+     */
+    public static <T> Correspondence<T, T> deepEquality() {
+        return Correspondence.<T, T>from(TestingUtils::deepEquals, "deeply equals")
+                .formattingDiffsUsing((actual, expected) -> {
+                    var errorMsg = new StringBuilder();
+                    deepEquals(actual, expected, errorMsg);
+                    return errorMsg.toString();
+                });
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java b/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java
new file mode 100644
index 0000000..c1fe78b
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.server.art.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class TestingUtilsTest {
+    @Test
+    public void testDeepEquals() {
+        var a = new Foo();
+        var b = new Foo();
+        assertThat(TestingUtils.deepEquals(a, b)).isTrue();
+    }
+
+    @Test
+    public void testDeepEqualsNull() {
+        assertThat(TestingUtils.deepEquals(null, null)).isTrue();
+    }
+
+    @Test
+    public void testDeepEqualsNullabilityMismatch() {
+        var a = new Foo();
+        assertThat(TestingUtils.deepEquals(a, null)).isFalse();
+    }
+
+    @Test
+    public void testDeepEqualsTypeMismatch() {
+        var a = new Foo();
+        var b = new Bar();
+        assertThat(TestingUtils.deepEquals(a, b)).isFalse();
+    }
+
+    @Test
+    public void testDeepEqualsPrimitiveFieldMismatch() {
+        var a = new Foo();
+        var b = new Foo();
+        b.mA = 11111111;
+        assertThat(TestingUtils.deepEquals(a, b)).isFalse();
+    }
+
+    @Test
+    public void testDeepEqualsStringFieldMismatch() {
+        var a = new Foo();
+        var b = new Foo();
+        b.mB = "def";
+        assertThat(TestingUtils.deepEquals(a, b)).isFalse();
+    }
+
+    @Test
+    public void deepEqualsNestedFieldMismatch() {
+        var a = new Foo();
+        var b = new Foo();
+        b.mC.setB(11111111);
+        assertThat(TestingUtils.deepEquals(a, b)).isFalse();
+    }
+
+    @Test(expected = UnsupportedOperationException.class)
+    public void testDeepEqualsArrayNotSupported() throws Exception {
+        int[] a = new int[] {1};
+        int[] b = new int[] {2};
+        TestingUtils.deepEquals(a, b);
+    }
+
+    @Test(expected = UnsupportedOperationException.class)
+    public void testDeepEqualsContainerNotSupported() throws Exception {
+        var a = new ArrayList<Integer>();
+        a.add(1);
+        var b = new ArrayList<Integer>();
+        b.add(2);
+        TestingUtils.deepEquals(a, b);
+    }
+}
+
+class Foo {
+    public int mA = 1234567;
+    public String mB = "abc";
+    public Bar mC = new Bar();
+}
+
+class Bar {
+    public static int sA = 10000000;
+    private int mB = 7654321;
+    public void setB(int b) {
+        mB = b;
+    }
+}