ART services: optimize package - add APIs.

This change adds a method `ArtManagerLocal.optimizePackage` and related
data structures including `OptimizeOptions` and `OptimizeResult`.

Bug: 229268202
Test: atest ArtServiceTests
Ignore-AOSP-First: ART Services.
Change-Id: I09a6cece11b969dcbc922de4e1043fe608fefb59
diff --git a/artd/binder/com/android/server/art/PriorityClass.aidl b/artd/binder/com/android/server/art/PriorityClass.aidl
new file mode 100644
index 0000000..38adfc7
--- /dev/null
+++ b/artd/binder/com/android/server/art/PriorityClass.aidl
@@ -0,0 +1,38 @@
+/*
+ * 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;
+
+/**
+ * Indicates the priority of an operation. The value affects the resource usage and the process
+ * priority. A higher value may result in faster execution but may consume more resources and
+ * compete for resources with other processes.
+ *
+ * @hide
+ */
+enum PriorityClass {
+    /** Indicates that the operation blocks boot. */
+    BOOT = 100,
+    /**
+     * Indicates that a human is waiting on the result and the operation is more latency sensitive
+     * than usual.
+     */
+    INTERACTIVE_FAST = 80,
+    /** Indicates that a human is waiting on the result. */
+    INTERACTIVE = 60,
+    /** Indicates that the operation runs in background. */
+    BACKGROUND = 40,
+}
diff --git a/libartservice/service/Android.bp b/libartservice/service/Android.bp
index 7bd4c34..49da309 100644
--- a/libartservice/service/Android.bp
+++ b/libartservice/service/Android.bp
@@ -130,7 +130,8 @@
         "androidx.test.ext.truth",
         "androidx.test.runner",
         "artd-aidl-java",
-        "mockito-target-minus-junit4",
+        // We need ExtendedMockito to mock static methods.
+        "mockito-target-extended-minus-junit4",
         "service-art.impl",
         // Statically link against system server to allow us to mock system
         // server APIs. This won't work on master-art, but it's fine because we
@@ -138,6 +139,12 @@
         "services.core",
     ],
 
+    jni_libs: [
+        // The two libraries below are required by ExtendedMockito.
+        "libdexmakerjvmtiagent",
+        "libstaticjvmtiagent",
+    ],
+
     min_sdk_version: "31",
 
     test_suites: ["general-tests"],
diff --git a/libartservice/service/AndroidManifest.xml b/libartservice/service/AndroidManifest.xml
index 921bde9..1c13fc6 100644
--- a/libartservice/service/AndroidManifest.xml
+++ b/libartservice/service/AndroidManifest.xml
@@ -20,7 +20,8 @@
     xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.android.server.art.tests">
 
-    <application android:label="ArtServiceTests">
+    <!-- android:debuggable is required by ExtendedMockito. -->
+    <application android:label="ArtServiceTests" android:debuggable="true">
         <uses-library android:name="android.test.runner" />
     </application>
 
diff --git a/libartservice/service/AndroidTest.xml b/libartservice/service/AndroidTest.xml
new file mode 100644
index 0000000..7a47ca3
--- /dev/null
+++ b/libartservice/service/AndroidTest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<configuration description="Config for ART Services test cases">
+    <option name="test-suite-tag" value="apct" />
+
+    <!-- This test needs access to system APIs for mainline modules. -->
+    <option name="hidden-api-checks" value="false"/>
+
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="cleanup-apks" value="true" />
+        <option name="test-file-name" value="ArtServiceTests.apk" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest">
+        <option name="package" value="com.android.server.art.tests"/>
+    </test>
+
+    <!-- Only run tests if the device under test is SDK version 31 (Android 12) or above. -->
+    <!-- TODO(jiakaiz): Change this to U once `ro.build.version.sdk` is bumped. -->
+    <object type="module_controller" class="com.android.tradefed.testtype.suite.module.Sdk31ModuleController" />
+</configuration>
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index e7e011d..9b2e9f8 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -23,11 +23,12 @@
 import static com.android.server.art.model.OptimizationStatus.DexFileOptimizationStatus;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.annotation.SystemApi;
+import android.content.Context;
 import android.os.Binder;
 import android.os.ParcelFileDescriptor;
 import android.os.RemoteException;
-import android.os.ServiceManager;
 import android.os.ServiceSpecificException;
 import android.util.Log;
 
@@ -36,6 +37,8 @@
 import com.android.server.art.model.ArtFlags;
 import com.android.server.art.model.DeleteResult;
 import com.android.server.art.model.OptimizationStatus;
+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.PackageManagerLocal;
 import com.android.server.art.wrapper.PackageState;
@@ -61,13 +64,20 @@
 
     @NonNull private final Injector mInjector;
 
+    // TODO(b/236954191): Deprecate this.
     public ArtManagerLocal() {
         this(new Injector());
     }
 
+    // TODO(b/236954191): Expose this.
+    /** @hide */
+    public ArtManagerLocal(@NonNull Context context) {
+        this(new Injector(context));
+    }
+
     /** @hide */
     @VisibleForTesting
-    public ArtManagerLocal(Injector injector) {
+    public ArtManagerLocal(@NonNull Injector injector) {
         mInjector = injector;
     }
 
@@ -215,6 +225,20 @@
         }
     }
 
+    /** @hide */
+    @NonNull
+    public OptimizeResult optimizePackage(@NonNull PackageDataSnapshot snapshot,
+            @NonNull String packageName, @NonNull OptimizeOptions options) {
+        if (!options.isForPrimaryDex() && !options.isForSecondaryDex()) {
+            throw new IllegalArgumentException("Nothing to optimize");
+        }
+
+        PackageState pkgState = getPackageStateOrThrow(snapshot, packageName);
+        AndroidPackageApi pkg = getPackageOrThrow(pkgState);
+
+        return mInjector.getDexOptHelper().dexopt(snapshot, pkgState, pkg, options);
+    }
+
     private PackageState getPackageStateOrThrow(
             @NonNull PackageDataSnapshot snapshot, @NonNull String packageName) {
         PackageState pkgState = mInjector.getPackageManagerLocal().getPackageState(
@@ -240,9 +264,16 @@
      */
     @VisibleForTesting
     public static class Injector {
-        private final PackageManagerLocal mPackageManagerLocal;
+        @Nullable private final Context mContext;
+        @Nullable private final PackageManagerLocal mPackageManagerLocal;
 
         Injector() {
+            this(null /* context */);
+        }
+
+        Injector(@Nullable Context context) {
+            mContext = context;
+
             PackageManagerLocal packageManagerLocal = null;
             try {
                 packageManagerLocal = PackageManagerLocal.getInstance();
@@ -257,16 +288,28 @@
             mPackageManagerLocal = packageManagerLocal;
         }
 
+        // TODO(b/236954191): Make this @NonNull.
+        @Nullable
+        public Context getContext() {
+            return mContext;
+        }
+
+        @NonNull
         public PackageManagerLocal getPackageManagerLocal() {
+            if (mPackageManagerLocal == null) {
+                throw new IllegalStateException("PackageManagerLocal is null");
+            }
             return mPackageManagerLocal;
         }
 
+        @NonNull
         public IArtd getArtd() {
-            IArtd artd = IArtd.Stub.asInterface(ServiceManager.waitForService("artd"));
-            if (artd == null) {
-                throw new IllegalStateException("Unable to connect to artd");
-            }
-            return artd;
+            return Utils.getArtd();
+        }
+
+        @NonNull
+        public DexOptHelper getDexOptHelper() {
+            return new DexOptHelper(getContext());
         }
     }
 }
diff --git a/libartservice/service/java/com/android/server/art/ArtShellCommand.java b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
index 9a49aae..1146aa0 100644
--- a/libartservice/service/java/com/android/server/art/ArtShellCommand.java
+++ b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
@@ -25,6 +25,8 @@
 import com.android.server.art.model.ArtFlags;
 import com.android.server.art.model.DeleteResult;
 import com.android.server.art.model.OptimizationStatus;
+import com.android.server.art.model.OptimizeOptions;
+import com.android.server.art.model.OptimizeResult;
 import com.android.server.art.wrapper.PackageManagerLocal;
 import com.android.server.pm.snapshot.PackageDataSnapshot;
 
@@ -53,12 +55,13 @@
         PrintWriter pw = getOutPrintWriter();
         PackageDataSnapshot snapshot = mPackageManagerLocal.snapshot();
         switch (cmd) {
-            case "delete-optimized-artifacts":
+            case "delete-optimized-artifacts": {
                 DeleteResult result = mArtManagerLocal.deleteOptimizedArtifacts(
                         snapshot, getNextArgRequired(), ArtFlags.defaultDeleteFlags());
                 pw.printf("Freed %d bytes\n", result.getFreedBytes());
                 return 0;
-            case "get-optimization-status":
+            }
+            case "get-optimization-status": {
                 OptimizationStatus optimizationStatus = mArtManagerLocal.getOptimizationStatus(
                         snapshot, getNextArgRequired(), ArtFlags.defaultGetStatusFlags());
                 for (DexFileOptimizationStatus status :
@@ -70,6 +73,41 @@
                             status.getLocationDebugString());
                 }
                 return 0;
+            }
+            case "optimize-package": {
+                var optionsBuilder = new OptimizeOptions.Builder("cmdline");
+                String opt;
+                while ((opt = getNextOption()) != null) {
+                    switch (opt) {
+                        case "-m":
+                            optionsBuilder.setCompilerFilter(getNextArgRequired());
+                            break;
+                        case "-f":
+                            optionsBuilder.setForce(true);
+                            break;
+                        default:
+                            pw.println("Error: Unknown option: " + opt);
+                            return 1;
+                    }
+                }
+                OptimizeResult result = mArtManagerLocal.optimizePackage(
+                        snapshot, getNextArgRequired(), optionsBuilder.build());
+                switch (result.getFinalStatus()) {
+                    case OptimizeResult.OPTIMIZE_SKIPPED:
+                        pw.println("SKIPPED");
+                        break;
+                    case OptimizeResult.OPTIMIZE_PERFORMED:
+                        pw.println("PERFORMED");
+                        break;
+                    case OptimizeResult.OPTIMIZE_FAILED:
+                        pw.println("FAILED");
+                        break;
+                    case OptimizeResult.OPTIMIZE_CANCELLED:
+                        pw.println("CANCELLED");
+                        break;
+                }
+                return 0;
+            }
             default:
                 // Handles empty, help, and invalid commands.
                 return handleDefaultCommands(cmd);
@@ -89,14 +127,20 @@
         pw.println("  help or -h");
         pw.println("    Print this help text.");
         // TODO(jiakaiz): Also do operations for secondary dex'es by default.
-        pw.println("  delete-optimized-artifacts <package-name>");
+        pw.println("  delete-optimized-artifacts PACKAGE_NAME");
         pw.println("    Delete the optimized artifacts of a package.");
         pw.println("    By default, the command only deletes the optimized artifacts of primary "
                 + "dex'es.");
-        pw.println("  get-optimization-status <package-name>");
+        pw.println("  get-optimization-status PACKAGE_NAME");
         pw.println("    Print the optimization status of a package.");
         pw.println("    By default, the command only prints the optimization status of primary "
                 + "dex'es.");
+        pw.println("  optimize-package [-m COMPILER_FILTER] [-f] PACKAGE_NAME");
+        pw.println("    Optimize a package.");
+        pw.println("    By default, the command only optimizes primary dex'es.");
+        pw.println("    Options:");
+        pw.println("      -m Set the compiler filter.");
+        pw.println("      -f Force compilation.");
     }
 
     private void enforceRoot() {
diff --git a/libartservice/service/java/com/android/server/art/DexOptHelper.java b/libartservice/service/java/com/android/server/art/DexOptHelper.java
new file mode 100644
index 0000000..38dec8c
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/DexOptHelper.java
@@ -0,0 +1,82 @@
+/*
+ * 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.model.OptimizeResult.DexFileOptimizeResult;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+
+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 com.android.server.pm.snapshot.PackageDataSnapshot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * A helper class to handle dexopt.
+ *
+ * It talks to other components (e.g., PowerManager) and dispatches tasks to dex optimizers.
+ *
+ * @hide
+ */
+public class DexOptHelper {
+    private static final String TAG = "DexoptHelper";
+
+    @NonNull private final Injector mInjector;
+
+    public DexOptHelper(@Nullable Context context) {
+        this(new Injector(context));
+    }
+
+    @VisibleForTesting
+    public DexOptHelper(@NonNull Injector injector) {
+        mInjector = injector;
+    }
+
+    /**
+     * DO NOT use this method directly. Use {@link
+     * ArtManagerLocal#optimizePackage(PackageDataSnapshot, String, OptimizeOptions)}.
+     */
+    @NonNull
+    public OptimizeResult dexopt(@NonNull PackageDataSnapshot snapshot,
+            @NonNull PackageState pkgState, @NonNull AndroidPackageApi pkg,
+            @NonNull OptimizeOptions options) {
+        throw new UnsupportedOperationException();
+    }
+
+    /**
+     * Injector pattern for testing purpose.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    public static class Injector {
+        // TODO(b/236954191): Make this @NonNull.
+        @Nullable private final Context mContext;
+
+        Injector(@Nullable Context context) {
+            mContext = context;
+        }
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/ReasonMapping.java b/libartservice/service/java/com/android/server/art/ReasonMapping.java
new file mode 100644
index 0000000..6e9f56f
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/ReasonMapping.java
@@ -0,0 +1,126 @@
+/*
+ * 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 android.annotation.NonNull;
+import android.os.SystemProperties;
+import android.text.TextUtils;
+
+import dalvik.system.DexFile;
+
+/**
+ * Maps a compilation reason to a compiler filter and a priority class.
+ *
+ * @hide
+ */
+public class ReasonMapping {
+    private ReasonMapping() {}
+
+    /** Optimizing apps on the first boot. */
+    public static final String REASON_FIRST_BOOT = "first-boot";
+    /** Optimizing apps on the next boot after an OTA. */
+    public static final String REASON_BOOT_AFTER_OTA = "boot-after-ota";
+    /** Installing an app after user presses the "install"/"update" button. */
+    public static final String REASON_INSTALL = "install";
+    /** Optimizing apps in the background. */
+    public static final String REASON_BG_DEXOPT = "bg-dexopt";
+    /** Invoked by cmdline. */
+    public static final String REASON_CMDLINE = "cmdline";
+    /** Downgrading the compiler filter when an app is not used for a long time. */
+    public static final String REASON_INACTIVE = "inactive";
+
+    // Reasons for Play Install Hints (go/install-hints).
+    public static final String REASON_INSTALL_FAST = "install-fast";
+    public static final String REASON_INSTALL_BULK = "install-bulk";
+    public static final String REASON_INSTALL_BULK_SECONDARY = "install-bulk-secondary";
+    public static final String REASON_INSTALL_BULK_DOWNGRADED = "install-bulk-downgraded";
+    public static final String REASON_INSTALL_BULK_SECONDARY_DOWNGRADED =
+            "install-bulk-secondary-downgraded";
+
+    /**
+     * Loads the compiler filter from the system property for the given reason and checks for
+     * validity.
+     *
+     * @throws IllegalArgumentException if the reason is invalid
+     * @throws IllegalStateException if the system property value is invalid
+     *
+     * @hide
+     */
+    @NonNull
+    public static String getCompilerFilterForReason(@NonNull String reason) {
+        String value = SystemProperties.get("pm.dexopt." + reason);
+        if (TextUtils.isEmpty(value)) {
+            throw new IllegalArgumentException("No compiler filter for reason '" + reason + "'");
+        }
+        if (!Utils.isValidArtServiceCompilerFilter(value)) {
+            throw new IllegalStateException(
+                    "Got invalid compiler filter '" + value + "' for reason '" + reason + "'");
+        }
+        return value;
+    }
+
+    /**
+     * Loads the compiler filter from the system property for:
+     * - shared libraries
+     * - apps used by other apps without a dex metadata file
+     *
+     * @throws IllegalStateException if the system property value is invalid
+     *
+     * @hide
+     */
+    @NonNull
+    public static String getCompilerFilterForShared() {
+        // "shared" is technically not a compilation reason, but the compiler filter is defined as a
+        // system property as if "shared" is a reason.
+        String value = getCompilerFilterForReason("shared");
+        if (DexFile.isProfileGuidedCompilerFilter(value)) {
+            throw new IllegalStateException(
+                    "Compiler filter for 'shared' must not be profile guided, got '" + value + "'");
+        }
+        return value;
+    }
+
+    /**
+     * Returns the priority for the given reason.
+     *
+     * @throws IllegalArgumentException if the reason is invalid
+     * @see PriorityClass
+     *
+     * @hide
+     */
+    public static @PriorityClass byte getPriorityClassForReason(@NonNull String reason) {
+        switch (reason) {
+            case REASON_FIRST_BOOT:
+            case REASON_BOOT_AFTER_OTA:
+                return PriorityClass.BOOT;
+            case REASON_INSTALL_FAST:
+                return PriorityClass.INTERACTIVE_FAST;
+            case REASON_INSTALL:
+            case REASON_CMDLINE:
+                return PriorityClass.INTERACTIVE;
+            case REASON_BG_DEXOPT:
+            case REASON_INACTIVE:
+            case REASON_INSTALL_BULK:
+            case REASON_INSTALL_BULK_SECONDARY:
+            case REASON_INSTALL_BULK_DOWNGRADED:
+            case REASON_INSTALL_BULK_SECONDARY_DOWNGRADED:
+                return PriorityClass.BACKGROUND;
+            default:
+                throw new IllegalArgumentException("No priority class for reason '" + reason + "'");
+        }
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/Utils.java b/libartservice/service/java/com/android/server/art/Utils.java
index 9c67a0f..b30d208 100644
--- a/libartservice/service/java/com/android/server/art/Utils.java
+++ b/libartservice/service/java/com/android/server/art/Utils.java
@@ -18,12 +18,15 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.os.ServiceManager;
 import android.util.SparseArray;
 
 import com.android.server.art.ArtifactsPath;
+import com.android.server.art.model.OptimizeOptions;
 import com.android.server.art.wrapper.PackageManagerLocal;
 import com.android.server.art.wrapper.PackageState;
 
+import dalvik.system.DexFile;
 import dalvik.system.VMRuntime;
 
 import java.util.Collection;
@@ -81,4 +84,21 @@
     public static boolean isInDalvikCache(@NonNull PackageState pkg) {
         return pkg.isSystem() && !pkg.isUpdatedSystemApp();
     }
+
+    /** Returns true if the given string is a valid compiler filter. */
+    public static boolean isValidArtServiceCompilerFilter(@NonNull String compilerFilter) {
+        if (compilerFilter.equals(OptimizeOptions.COMPILER_FILTER_NOOP)) {
+            return true;
+        }
+        return DexFile.isValidCompilerFilter(compilerFilter);
+    }
+
+    @NonNull
+    public static IArtd getArtd() {
+        IArtd artd = IArtd.Stub.asInterface(ServiceManager.waitForService("artd"));
+        if (artd == null) {
+            throw new IllegalStateException("Unable to connect to artd");
+        }
+        return artd;
+    }
 }
diff --git a/libartservice/service/java/com/android/server/art/model/OptimizeOptions.java b/libartservice/service/java/com/android/server/art/model/OptimizeOptions.java
new file mode 100644
index 0000000..6e897ec
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/model/OptimizeOptions.java
@@ -0,0 +1,204 @@
+/*
+ * 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.model;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.text.TextUtils;
+
+import com.android.server.art.PriorityClass;
+import com.android.server.art.ReasonMapping;
+import com.android.server.art.Utils;
+
+/** @hide */
+public class OptimizeOptions {
+    public static final class Builder {
+        private OptimizeOptions mOptions = new OptimizeOptions();
+
+        /**
+         * Creates a builder.
+         *
+         * @param reason See {@link #setReason(String)}.
+         */
+        public Builder(@NonNull String reason) {
+            setReason(reason);
+        }
+
+        /** Whether to generate optimized artifacts for primary dex'es. Default: true. */
+        public Builder setForPrimaryDex(boolean value) {
+            mOptions.mIsForPrimaryDex = value;
+            return this;
+        }
+
+        /** Whether to generate optimized artifacts for secondary dex'es. Default: false. */
+        public Builder setForSecondaryDex(boolean value) {
+            mOptions.mIsForSecondaryDex = value;
+            return this;
+        }
+
+        /** Whether to optimize dependency packages as well. Default: false. */
+        public Builder setIncludesDependencies(boolean value) {
+            mOptions.mIncludesDependencies = value;
+            return this;
+        }
+
+        /**
+         * The target compiler filter. Note that the compiler filter might be adjusted before the
+         * execution based on factors like whether the profile is available or whether the app is
+         * used by other apps. If not set, the default compiler filter for the given reason will be
+         * used.
+         */
+        public Builder setCompilerFilter(@NonNull String value) {
+            mOptions.mCompilerFilter = value;
+            return this;
+        }
+
+        /**
+         * The priority of the operation. If not set, the default priority class for the given
+         * reason will be used.
+         *
+         * @see PriorityClass
+         */
+        public Builder setPriorityClass(@PriorityClass byte value) {
+            mOptions.mPriorityClass = value;
+            return this;
+        }
+
+        /**
+         * Compilation reason. Can be a string defined in {@link ReasonMapping} or a custom string.
+         *
+         * If the value is a string defined in {@link ReasonMapping}, it determines the compiler
+         * filter and/or the priority class, if those values are not explicitly set.
+         *
+         * If the value is a custom string, the priority class and the compiler filter must be
+         * explicitly set.
+         */
+        public Builder setReason(@NonNull String value) {
+            mOptions.mReason = value;
+            return this;
+        }
+
+        /**
+         * Whether the intention is to downgrade the compiler filter. If true, the compilation will
+         * be skipped if the target compiler filter is better than or equal to the compiler filter
+         * of the existing optimized artifacts, or optimized artifacts do not exist.
+         */
+        public Builder setShouldDowngrade(boolean value) {
+            mOptions.mShouldDowngrade = value;
+            return this;
+        }
+
+        /**
+         * Whether to force compilation. If true, the compilation will be performed regardless of
+         * any existing optimized artifacts.
+         */
+        public Builder setForce(boolean value) {
+            mOptions.mForce = value;
+            return this;
+        }
+
+        /**
+         * Returns the built object.
+         *
+         * @throws IllegalArgumentException if the built options would be invalid
+         */
+        public OptimizeOptions build() {
+            if (mOptions.mReason.isEmpty()) {
+                throw new IllegalArgumentException("Reason must not be empty");
+            }
+
+            if (mOptions.mCompilerFilter.isEmpty()) {
+                mOptions.mCompilerFilter =
+                        ReasonMapping.getCompilerFilterForReason(mOptions.mReason);
+            } else if (!Utils.isValidArtServiceCompilerFilter(mOptions.mCompilerFilter)) {
+                throw new IllegalArgumentException(
+                        "Invalid compiler filter '" + mOptions.mCompilerFilter + "'");
+            }
+
+            if (mOptions.mPriorityClass == -1) {
+                mOptions.mPriorityClass = ReasonMapping.getPriorityClassForReason(mOptions.mReason);
+            } else if (mOptions.mPriorityClass < 0 || mOptions.mPriorityClass > 100) {
+                throw new IllegalArgumentException("Invalid priority class "
+                        + mOptions.mPriorityClass + ". Must be between 0 and 100");
+            }
+
+            return mOptions;
+        }
+    }
+
+    /**
+     * A value indicating that dexopt shouldn't be run. This value is consumed by ART Services and
+     * is not propagated to dex2oat.
+     */
+    public static final String COMPILER_FILTER_NOOP = "skip";
+
+    private boolean mIsForPrimaryDex = true;
+    private boolean mIsForSecondaryDex = false;
+    private boolean mIncludesDependencies = false;
+    private @NonNull String mCompilerFilter = "";
+    private @PriorityClass byte mPriorityClass = -1;
+    private @NonNull String mReason = "";
+    private boolean mShouldDowngrade = false;
+    private boolean mForce = false;
+
+    private OptimizeOptions() {}
+
+    /** Whether to generate optimized artifacts for primary dex'es. */
+    public boolean isForPrimaryDex() {
+        return mIsForPrimaryDex;
+    }
+
+    /** Whether to generate optimized artifacts for secondary dex'es. */
+    public boolean isForSecondaryDex() {
+        return mIsForSecondaryDex;
+    }
+
+    /** Whether to optimize dependency packages as well. */
+    public boolean getIncludesDependencies() {
+        return mIncludesDependencies;
+    }
+
+    /** The target compiler filter. */
+    public @NonNull String getCompilerFilter() {
+        return mCompilerFilter;
+    }
+
+    /** The priority class. */
+    public @PriorityClass byte getPriorityClass() {
+        return mPriorityClass;
+    }
+
+    /**
+     * The compilation reason.
+     *
+     * DO NOT directly use the string value to determine the resource usage and the process
+     * priority. Use {@link #getPriorityClass}.
+     */
+    public @NonNull String getReason() {
+        return mReason;
+    }
+
+    /** Whether the intention is to downgrade the compiler filter. */
+    public boolean getShouldDowngrade() {
+        return mShouldDowngrade;
+    }
+
+    /** Whether to force compilation. */
+    public boolean getForce() {
+        return mForce;
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/model/OptimizeResult.java b/libartservice/service/java/com/android/server/art/model/OptimizeResult.java
new file mode 100644
index 0000000..efebbf2
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/model/OptimizeResult.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.model;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+
+import com.android.internal.annotations.Immutable;
+
+import java.util.List;
+
+/** @hide */
+@Immutable
+public class OptimizeResult {
+    // Possible values of {@link #OptimizeStatus}.
+    // A larger number means a higher priority. If multiple dex files are processed, the final
+    // status will be the one with the highest priority.
+    public static final int OPTIMIZE_SKIPPED = 10;
+    public static final int OPTIMIZE_PERFORMED = 20;
+    public static final int OPTIMIZE_FAILED = 30;
+    public static final int OPTIMIZE_CANCELLED = 40;
+
+    /** @hide */
+    @IntDef(prefix = {"OPTIMIZE_"},
+            value = {OPTIMIZE_SKIPPED, OPTIMIZE_FAILED, OPTIMIZE_PERFORMED, OPTIMIZE_CANCELLED})
+    public @interface OptimizeStatus {}
+
+    private final @NonNull String mPackageName;
+    private final @NonNull String mRequestedCompilerFilter;
+    private final @NonNull String mReason;
+    private final @NonNull List<DexFileOptimizeResult> mDexFileOptimizeResults;
+
+    public OptimizeResult(@NonNull String packageName, @NonNull String requestedCompilerFilter,
+            @NonNull String reason, @NonNull List<DexFileOptimizeResult> dexFileOptimizeResults) {
+        mPackageName = packageName;
+        mRequestedCompilerFilter = requestedCompilerFilter;
+        mReason = reason;
+        mDexFileOptimizeResults = dexFileOptimizeResults;
+    }
+
+    /** The package name. */
+    public @NonNull String getPackageName() {
+        return mPackageName;
+    }
+
+    /**
+     * The requested compiler filter. Note that the compiler filter might be adjusted before the
+     * execution based on factors like whether the profile is available or whether the app is
+     * used by other apps.
+     *
+     * @see DexFileOptimizeResult#getActualCompilerFilter.
+     */
+    public @NonNull String getRequestedCompilerFilter() {
+        return mRequestedCompilerFilter;
+    }
+
+    /** The compilation reason. */
+    public @NonNull String getReason() {
+        return mReason;
+    }
+
+    /** The final status. */
+    public @OptimizeStatus int getFinalStatus() {
+        return mDexFileOptimizeResults.stream()
+                .mapToInt(result -> result.getStatus())
+                .max()
+                .orElse(OPTIMIZE_SKIPPED);
+    }
+
+    /** The result of each individual dex file. */
+    @NonNull
+    public List<DexFileOptimizeResult> getDexFileOptimizeResults() {
+        return mDexFileOptimizeResults;
+    }
+
+    /** Describes the result of a dex file. */
+    @Immutable
+    public static class DexFileOptimizeResult {
+        private final @NonNull String mDexFile;
+        private final @NonNull String mInstructionSet;
+        private final @NonNull String mActualCompilerFilter;
+        private final @OptimizeStatus int mStatus;
+
+        /** @hide */
+        public DexFileOptimizeResult(@NonNull String dexFile, @NonNull String instructionSet,
+                @NonNull String compilerFilter, @OptimizeStatus int status) {
+            mDexFile = dexFile;
+            mInstructionSet = instructionSet;
+            mActualCompilerFilter = compilerFilter;
+            mStatus = status;
+        }
+
+        /** The absolute path to the dex file. */
+        public @NonNull String getDexFile() {
+            return mDexFile;
+        }
+
+        /** The instruction set. */
+        public @NonNull String getInstructionSet() {
+            return mInstructionSet;
+        }
+
+        /** The actual compiler filter. */
+        public @NonNull String getActualCompilerFilter() {
+            return mActualCompilerFilter;
+        }
+
+        /** The status of optimizing this dex file. */
+        public @OptimizeStatus int getStatus() {
+            return mStatus;
+        }
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
index 78843e3..afaa3ee 100644
--- a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
+++ b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
@@ -26,6 +26,7 @@
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.same;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
@@ -37,6 +38,8 @@
 
 import com.android.server.art.model.DeleteResult;
 import com.android.server.art.model.OptimizationStatus;
+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.PackageManagerLocal;
 import com.android.server.art.wrapper.PackageState;
@@ -66,7 +69,9 @@
     @Mock private ArtManagerLocal.Injector mInjector;
     @Mock private PackageManagerLocal mPackageManagerLocal;
     @Mock private IArtd mArtd;
+    @Mock private DexOptHelper mDexOptHelper;
     private PackageState mPkgState;
+    private AndroidPackageApi mPkg;
 
     // True if the primary dex'es are in a readonly partition.
     @Parameter(0) public boolean mIsInReadonlyPartition;
@@ -80,10 +85,15 @@
 
     @Before
     public void setUp() throws Exception {
+        // Use `lenient()` to suppress `UnnecessaryStubbingException` thrown by the strict stubs.
+        // These are the default test setups. They may or may not be used depending on the code path
+        // that each test case examines.
         lenient().when(mInjector.getPackageManagerLocal()).thenReturn(mPackageManagerLocal);
         lenient().when(mInjector.getArtd()).thenReturn(mArtd);
+        lenient().when(mInjector.getDexOptHelper()).thenReturn(mDexOptHelper);
 
         mPkgState = createPackageState();
+        mPkg = mPkgState.getAndroidPackage();
         lenient()
                 .when(mPackageManagerLocal.getPackageState(any(), anyInt(), eq(PKG_NAME)))
                 .thenReturn(mPkgState);
@@ -207,6 +217,35 @@
         }
     }
 
+    @Test
+    public void testOptimizePackage() throws Exception {
+        var options = new OptimizeOptions.Builder("install").build();
+        var result = mock(OptimizeResult.class);
+
+        when(mDexOptHelper.dexopt(any(), same(mPkgState), same(mPkg), same(options)))
+                .thenReturn(result);
+
+        assertThat(mArtManagerLocal.optimizePackage(
+                           mock(PackageDataSnapshot.class), PKG_NAME, options))
+                .isSameInstanceAs(result);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testOptimizePackagePackageNotFound() throws Exception {
+        when(mPackageManagerLocal.getPackageState(any(), anyInt(), eq(PKG_NAME))).thenReturn(null);
+
+        mArtManagerLocal.optimizePackage(mock(PackageDataSnapshot.class), PKG_NAME,
+                new OptimizeOptions.Builder("install").build());
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testOptimizePackageNoPackage() throws Exception {
+        when(mPkgState.getAndroidPackage()).thenReturn(null);
+
+        mArtManagerLocal.optimizePackage(mock(PackageDataSnapshot.class), PKG_NAME,
+                new OptimizeOptions.Builder("install").build());
+    }
+
     private AndroidPackageApi createPackage() {
         AndroidPackageApi pkg = mock(AndroidPackageApi.class);
 
diff --git a/libartservice/service/javatests/com/android/server/art/ReasonMappingTest.java b/libartservice/service/javatests/com/android/server/art/ReasonMappingTest.java
new file mode 100644
index 0000000..776cb0f
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/ReasonMappingTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.os.SystemProperties;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.art.testing.StaticMockitoRule;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+
+import java.util.List;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ReasonMappingTest {
+    @Rule public StaticMockitoRule mockitoRule = new StaticMockitoRule(SystemProperties.class);
+
+    @Test
+    public void testGetCompilerFilterForReason() {
+        when(SystemProperties.get("pm.dexopt.foo")).thenReturn("speed");
+        assertThat(ReasonMapping.getCompilerFilterForReason("foo")).isEqualTo("speed");
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testGetCompilerFilterForReasonInvalidFilter() throws Exception {
+        when(SystemProperties.get("pm.dexopt.foo")).thenReturn("invalid-filter");
+        ReasonMapping.getCompilerFilterForReason("foo");
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testGetCompilerFilterForReasonInvalidReason() throws Exception {
+        ReasonMapping.getCompilerFilterForReason("foo");
+    }
+
+    @Test
+    public void testGetCompilerFilterForShared() {
+        when(SystemProperties.get("pm.dexopt.shared")).thenReturn("speed");
+        assertThat(ReasonMapping.getCompilerFilterForShared()).isEqualTo("speed");
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testGetCompilerFilterForSharedProfileGuidedFilter() throws Exception {
+        when(SystemProperties.get("pm.dexopt.shared")).thenReturn("speed-profile");
+        ReasonMapping.getCompilerFilterForShared();
+    }
+
+    @Test
+    public void testGetPriorityClassForReason() throws Exception {
+        assertThat(ReasonMapping.getPriorityClassForReason("install"))
+                .isEqualTo(PriorityClass.INTERACTIVE);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testGetPriorityClassForReasonInvalidReason() throws Exception {
+        ReasonMapping.getPriorityClassForReason("foo");
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/model/OptimizeOptionsTest.java b/libartservice/service/javatests/com/android/server/art/model/OptimizeOptionsTest.java
new file mode 100644
index 0000000..47515c7
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/model/OptimizeOptionsTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.model;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.art.PriorityClass;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class OptimizeOptionsTest {
+    @Test
+    public void testBuild() {
+        new OptimizeOptions.Builder("install").build();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testBuildEmptyReason() {
+        new OptimizeOptions.Builder("").build();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testBuildInvalidCompilerFilter() {
+        new OptimizeOptions.Builder("install").setCompilerFilter("invalid").build();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testBuildInvalidPriorityClass() {
+        new OptimizeOptions.Builder("install").setPriorityClass((byte) 101).build();
+    }
+
+    @Test
+    public void testBuildCustomReason() {
+        new OptimizeOptions.Builder("custom")
+                .setCompilerFilter("speed")
+                .setPriorityClass((byte) 90)
+                .build();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testBuildCustomReasonEmptyCompilerFilter() {
+        new OptimizeOptions.Builder("custom").setPriorityClass(PriorityClass.INTERACTIVE).build();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testBuildCustomReasonEmptyPriorityClass() {
+        new OptimizeOptions.Builder("custom").setCompilerFilter("speed").build();
+    }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/testing/StaticMockitoRule.java b/libartservice/service/javatests/com/android/server/art/testing/StaticMockitoRule.java
new file mode 100644
index 0000000..f6e1107
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/testing/StaticMockitoRule.java
@@ -0,0 +1,64 @@
+/*
+ * 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.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
+
+import com.android.dx.mockito.inline.extended.StaticMockitoSession;
+import com.android.dx.mockito.inline.extended.StaticMockitoSessionBuilder;
+
+import org.junit.rules.MethodRule;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.Statement;
+import org.mockito.quality.Strictness;
+
+/**
+ * Similar to {@link MockitoRule}, but uses {@StaticMockitoSession}, which allows mocking static
+ * methods.
+ */
+public class StaticMockitoRule implements MethodRule {
+    private Class<?>[] mClasses;
+
+    public StaticMockitoRule(Class<?>... classes) {
+        mClasses = classes;
+    }
+
+    @Override
+    public Statement apply(Statement base, FrameworkMethod method, Object target) {
+        return new Statement() {
+            public void evaluate() throws Throwable {
+                StaticMockitoSessionBuilder builder =
+                        mockitoSession()
+                                .name(target.getClass().getSimpleName() + "." + method.getName())
+                                .initMocks(target)
+                                .strictness(Strictness.STRICT_STUBS);
+
+                for (Class<?> clazz : mClasses) {
+                    builder.mockStatic(clazz);
+                }
+
+                StaticMockitoSession session = builder.startMocking();
+
+                try {
+                    base.evaluate();
+                } finally {
+                    session.finishMocking();
+                }
+            }
+        };
+    }
+}