Implement background dexopt job.

Bug: 255563304
Test: atest ArtServiceTests
Ignore-AOSP-First: ART Services
Change-Id: I6ecbee4492304f1899f4188244a91c23e89afb38
diff --git a/libartservice/service/Android.bp b/libartservice/service/Android.bp
index cd43853..55653b5 100644
--- a/libartservice/service/Android.bp
+++ b/libartservice/service/Android.bp
@@ -86,8 +86,16 @@
     ],
     libs: [
         "auto_value_annotations",
+        // TODO(b/256866172): Transitive dependency, for r8 only.
+        "framework-statsd.stubs.module_lib",
+        // TODO(b/256866172): Transitive dependency, for r8 only. This module
+        // always refers to the jar in prebuilts/sdk. We can't use
+        // "framework-connectivity.stubs.module_lib" here because it's not
+        // available on master-art.
+        "sdk_module-lib_current_framework-connectivity",
     ],
     static_libs: [
+        "art-statslog-art-java",
         "artd-aidl-java",
         "modules-utils-shell-command-handler",
         "service-art-proto-java",
@@ -118,6 +126,29 @@
     ],
 }
 
+java_library {
+    name: "art-statslog-art-java",
+    srcs: [
+        ":art-statslog-art-java-gen",
+    ],
+    libs: [
+        "framework-statsd.stubs.module_lib",
+    ],
+    sdk_version: "system_server_current",
+    min_sdk_version: "31",
+    apex_available: [
+        "com.android.art",
+        "com.android.art.debug",
+    ],
+}
+
+genrule {
+    name: "art-statslog-art-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module art --javaPackage com.android.server.art --javaClass ArtStatsLog",
+    out: ["java/com/android/server/art/ArtStatsLog.java"],
+}
+
 art_cc_defaults {
     name: "art_libartservice_tests_defaults",
     defaults: ["libartservice_defaults"],
diff --git a/libartservice/service/api/system-server-current.txt b/libartservice/service/api/system-server-current.txt
index 1003030..6639926 100644
--- a/libartservice/service/api/system-server-current.txt
+++ b/libartservice/service/api/system-server-current.txt
@@ -4,7 +4,9 @@
   public final class ArtManagerLocal {
     ctor @Deprecated public ArtManagerLocal();
     ctor public ArtManagerLocal(@NonNull android.content.Context);
+    method public void cancelBackgroundDexoptJob();
     method public void clearOptimizePackagesCallback();
+    method public void clearScheduleBackgroundDexoptJobCallback();
     method @NonNull public com.android.server.art.model.DeleteResult deleteOptimizedArtifacts(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String);
     method @NonNull public com.android.server.art.model.DeleteResult deleteOptimizedArtifacts(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String, int);
     method @NonNull public com.android.server.art.model.OptimizationStatus getOptimizationStatus(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String);
@@ -13,13 +15,21 @@
     method public void notifyDexContainersLoaded(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String, @NonNull java.util.Map<java.lang.String,java.lang.String>);
     method @NonNull public com.android.server.art.model.OptimizeResult optimizePackage(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String, @NonNull com.android.server.art.model.OptimizeParams);
     method @NonNull public com.android.server.art.model.OptimizeResult optimizePackage(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String, @NonNull com.android.server.art.model.OptimizeParams, @NonNull android.os.CancellationSignal);
+    method public int scheduleBackgroundDexoptJob();
     method public void setOptimizePackagesCallback(@NonNull java.util.concurrent.Executor, @NonNull com.android.server.art.ArtManagerLocal.OptimizePackagesCallback);
+    method public void setScheduleBackgroundDexoptJobCallback(@NonNull java.util.concurrent.Executor, @NonNull com.android.server.art.ArtManagerLocal.ScheduleBackgroundDexoptJobCallback);
+    method public void startBackgroundDexoptJob();
+    method public void unscheduleBackgroundDexoptJob();
   }
 
   public static interface ArtManagerLocal.OptimizePackagesCallback {
     method public void onOverrideBatchOptimizeParams(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String, @NonNull java.util.List<java.lang.String>, @NonNull com.android.server.art.model.BatchOptimizeParams.Builder);
   }
 
+  public static interface ArtManagerLocal.ScheduleBackgroundDexoptJobCallback {
+    method public void onOverrideJobInfo(@NonNull android.app.job.JobInfo.Builder);
+  }
+
   public class ReasonMapping {
     field public static final String REASON_BG_DEXOPT = "bg-dexopt";
     field public static final String REASON_BOOT_AFTER_OTA = "boot-after-ota";
@@ -51,6 +61,9 @@
     field public static final int PRIORITY_BOOT = 100; // 0x64
     field public static final int PRIORITY_INTERACTIVE = 60; // 0x3c
     field public static final int PRIORITY_INTERACTIVE_FAST = 80; // 0x50
+    field public static final int SCHEDULE_DISABLED_BY_SYSPROP = 2; // 0x2
+    field public static final int SCHEDULE_JOB_SCHEDULER_FAILURE = 1; // 0x1
+    field public static final int SCHEDULE_SUCCESS = 0; // 0x0
   }
 
   public abstract class BatchOptimizeParams {
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index cc64f71..ed14e19 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -22,6 +22,7 @@
 import static com.android.server.art.Utils.Abi;
 import static com.android.server.art.model.ArtFlags.DeleteFlags;
 import static com.android.server.art.model.ArtFlags.GetStatusFlags;
+import static com.android.server.art.model.ArtFlags.ScheduleStatus;
 import static com.android.server.art.model.Config.Callback;
 import static com.android.server.art.model.OptimizationStatus.DexContainerFileOptimizationStatus;
 
@@ -30,6 +31,7 @@
 import android.annotation.Nullable;
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
+import android.app.job.JobInfo;
 import android.apphibernation.AppHibernationManager;
 import android.content.Context;
 import android.os.Binder;
@@ -78,11 +80,11 @@
 
     @Deprecated
     public ArtManagerLocal() {
-        this(new Injector(null /* context */));
+        mInjector = new Injector(this, null /* context */);
     }
 
     public ArtManagerLocal(@NonNull Context context) {
-        this(new Injector(context));
+        mInjector = new Injector(this, context);
     }
 
     /** @hide */
@@ -248,6 +250,9 @@
      * Optimizes a package. The time this operation takes ranges from a few milliseconds to several
      * minutes, depending on the params and the code size of the package.
      *
+     * When this operation ends (either completed or cancelled), callbacks added by {@link
+     * #addOptimizePackageDoneCallback(Executor, OptimizePackageDoneCallback)} are called.
+     *
      * @throws IllegalArgumentException if the package is not found or the params are illegal
      * @throws IllegalStateException if an internal error occurs
      */
@@ -300,6 +305,9 @@
      * usage is always bound by {@code dalvik.vm.*dex2oat-cpu-set} regardless of the number of
      * threads.
      *
+     * When this operation ends (either completed or cancelled), callbacks added by {@link
+     * #addOptimizePackageDoneCallback(Executor, OptimizePackageDoneCallback)} are called.
+     *
      * @param snapshot the snapshot from {@link PackageManagerLocal} to operate on
      * @param reason determines the default list of packages and options
      * @param cancellationSignal provides the ability to cancel this operation
@@ -358,6 +366,105 @@
     }
 
     /**
+     * Schedules a background dexopt job. Does nothing if the job is already scheduled.
+     *
+     * Use this method if you want the system to automatically determine the best time to run
+     * dexopt.
+     *
+     * The job will be run by the job scheduler. The job scheduling configuration can be overridden
+     * by {@link #setScheduleBackgroundDexoptJobCallback(Executor,
+     * ScheduleBackgroundDexoptJobCallback)}. By default, it runs periodically (at most once a day)
+     * when all the following constraints are meet.
+     *
+     * <ul>
+     *   <li>The device is idling. (see {@link JobInfo.Builder#setRequiresDeviceIdle(boolean)})
+     *   <li>The device is charging. (see {@link JobInfo.Builder#setRequiresCharging(boolean)})
+     *   <li>The battery level is not low.
+     *     (see {@link JobInfo.Builder#setRequiresBatteryNotLow(boolean)})
+     *   <li>The free storage space is not low.
+     *     (see {@link JobInfo.Builder#setRequiresStorageNotLow(boolean)})
+     * </ul>
+     *
+     * When the job is running, the job scheduler cancels the job immediately whenever one of the
+     * constraints above is no longer met, and retries it in the next <i>maintenance window</i>.
+     * For information about <i>maintenance window</i>, see
+     * https://developer.android.com/training/monitoring-device-state/doze-standby.
+     *
+     * See {@link #optimizePackages(PackageManagerLocal.FilteredSnapshot, String,
+     * CancellationSignal)} for how to customize the behavior of the job.
+     *
+     * When the job ends (either completed or cancelled), the result is sent to the callbacks added
+     * by {@link #addOptimizePackageDoneCallback(Executor, OptimizePackageDoneCallback)} with the
+     * reason {@link ReasonMapping#REASON_BG_DEXOPT}.
+     */
+    public @ScheduleStatus int scheduleBackgroundDexoptJob() {
+        return mInjector.getBackgroundDexOptJob().schedule();
+    }
+
+    /**
+     * Unschedules the background dexopt job scheduled by {@link #scheduleBackgroundDexoptJob()}.
+     * Does nothing if the job is not scheduled.
+     *
+     * Use this method if you no longer want the system to automatically run dexopt.
+     *
+     * If the job is already started by the job scheduler and is running, it will be cancelled
+     * immediately, and the result sent to the callbacks added by {@link
+     * #addOptimizePackageDoneCallback(Executor, OptimizePackageDoneCallback)} will contain {@link
+     * OptimizeResult#OPTIMIZE_CANCELLED}. Note that a job started by {@link
+     * #startBackgroundDexoptJob()} will not be cancelled by this method.
+     */
+    public void unscheduleBackgroundDexoptJob() {
+        mInjector.getBackgroundDexOptJob().unschedule();
+    }
+
+    /**
+     * Overrides the configuration of the background dexopt job. This method is thread-safe.
+     */
+    public void setScheduleBackgroundDexoptJobCallback(@NonNull @CallbackExecutor Executor executor,
+            @NonNull ScheduleBackgroundDexoptJobCallback callback) {
+        mInjector.getConfig().setScheduleBackgroundDexoptJobCallback(executor, callback);
+    }
+
+    /**
+     * Clears the callback set by {@link #setScheduleBackgroundDexoptJobCallback(Executor,
+     * ScheduleBackgroundDexoptJobCallback)}. This method is thread-safe.
+     */
+    public void clearScheduleBackgroundDexoptJobCallback() {
+        mInjector.getConfig().clearScheduleBackgroundDexoptJobCallback();
+    }
+
+    /**
+     * Manually starts a background dexopt job. Does nothing if a job is already started by this
+     * method or by the job scheduler. This method is not blocking.
+     *
+     * Unlike the job started by job scheduler, the job started by this method does not respect
+     * constraints described in {@link #scheduleBackgroundDexoptJob()}, and hence will not be
+     * cancelled when they aren't met.
+     *
+     * See {@link #optimizePackages(PackageManagerLocal.FilteredSnapshot, String,
+     * CancellationSignal)} for how to customize the behavior of the job.
+     *
+     * When the job ends (either completed or cancelled), the result is sent to the callbacks added
+     * by {@link #addOptimizePackageDoneCallback(Executor, OptimizePackageDoneCallback)} with the
+     * reason {@link ReasonMapping#REASON_BG_DEXOPT}.
+     */
+    public void startBackgroundDexoptJob() {
+        mInjector.getBackgroundDexOptJob().start();
+    }
+
+    /**
+     * Cancels the running background dexopt job started by the job scheduler or by {@link
+     * #startBackgroundDexoptJob()}. Does nothing if the job is not running. This method is not
+     * blocking.
+     *
+     * The result sent to the callbacks added by {@link #addOptimizePackageDoneCallback(Executor,
+     * OptimizePackageDoneCallback)} will contain {@link OptimizeResult#OPTIMIZE_CANCELLED}.
+     */
+    public void cancelBackgroundDexoptJob() {
+        mInjector.getBackgroundDexOptJob().cancel();
+    }
+
+    /**
      * Notifies ART Service that a list of dex container files have been loaded.
      *
      * ART Service uses this information to:
@@ -381,6 +488,16 @@
                 snapshot, loadingPackageName, classLoaderContextByDexContainerFile);
     }
 
+    /**
+     * Should be used by {@link BackgroundDexOptJobService} ONLY.
+     *
+     * @hide
+     */
+    @NonNull
+    BackgroundDexOptJob getBackgroundDexOptJob() {
+        return mInjector.getBackgroundDexOptJob();
+    }
+
     @NonNull
     private List<String> getDefaultPackages(@NonNull PackageManagerLocal.FilteredSnapshot snapshot,
             @NonNull @BatchOptimizeReason String reason) {
@@ -415,6 +532,17 @@
                 @NonNull BatchOptimizeParams.Builder builder);
     }
 
+    public interface ScheduleBackgroundDexoptJobCallback {
+        /**
+         * Mutates {@code builder} to override the configuration of the background dexopt job.
+         *
+         * The default configuration described in {@link
+         * ArtManagerLocal#scheduleBackgroundDexoptJob()} is passed to the callback as the {@code
+         * builder} argument.
+         */
+        void onOverrideJobInfo(@NonNull JobInfo.Builder builder);
+    }
+
     /**
      * Injector pattern for testing purpose.
      *
@@ -425,16 +553,19 @@
         @Nullable private final Context mContext;
         @Nullable private final PackageManagerLocal mPackageManagerLocal;
         @Nullable private final Config mConfig;
+        @Nullable private final BackgroundDexOptJob mBgDexOptJob;
 
-        Injector(@Nullable Context context) {
+        Injector(@NonNull ArtManagerLocal artManagerLocal, @Nullable Context context) {
             mContext = context;
             if (context != null) {
                 // We only need them on Android U and above, where a context is passed.
                 mPackageManagerLocal = LocalManagerRegistry.getManager(PackageManagerLocal.class);
                 mConfig = new Config();
+                mBgDexOptJob = new BackgroundDexOptJob(context, artManagerLocal, mConfig);
             } else {
                 mPackageManagerLocal = null;
                 mConfig = null;
+                mBgDexOptJob = null;
             }
         }
 
@@ -467,5 +598,10 @@
         public AppHibernationManager getAppHibernationManager() {
             return Objects.requireNonNull(mContext.getSystemService(AppHibernationManager.class));
         }
+
+        @NonNull
+        public BackgroundDexOptJob getBackgroundDexOptJob() {
+            return Objects.requireNonNull(mBgDexOptJob);
+        }
     }
 }
diff --git a/libartservice/service/java/com/android/server/art/ArtShellCommand.java b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
index b50ad3e..d0986c1 100644
--- a/libartservice/service/java/com/android/server/art/ArtShellCommand.java
+++ b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
@@ -183,6 +183,42 @@
                         throw new RuntimeException(e);
                     }
                 }
+                case "bg-dexopt-job": {
+                    String opt = getNextOption();
+                    if (opt == null) {
+                        mArtManagerLocal.startBackgroundDexoptJob();
+                        return 0;
+                    }
+                    switch (opt) {
+                        case "--cancel": {
+                            mArtManagerLocal.cancelBackgroundDexoptJob();
+                            return 0;
+                        }
+                        case "--enable": {
+                            // This operation requires the uid to be "system" (1000).
+                            long identityToken = Binder.clearCallingIdentity();
+                            try {
+                                mArtManagerLocal.scheduleBackgroundDexoptJob();
+                            } finally {
+                                Binder.restoreCallingIdentity(identityToken);
+                            }
+                            return 0;
+                        }
+                        case "--disable": {
+                            // This operation requires the uid to be "system" (1000).
+                            long identityToken = Binder.clearCallingIdentity();
+                            try {
+                                mArtManagerLocal.unscheduleBackgroundDexoptJob();
+                            } finally {
+                                Binder.restoreCallingIdentity(identityToken);
+                            }
+                            return 0;
+                        }
+                        default:
+                            pw.println("Error: Unknown option: " + opt);
+                            return 1;
+                    }
+                }
                 default:
                     // Handles empty, help, and invalid commands.
                     return handleDefaultCommands(cmd);
@@ -242,6 +278,22 @@
         pw.println("    Save dex use information to a file in binary proto format.");
         pw.println("  dex-use-load PATH");
         pw.println("    Load dex use information from a file in binary proto format.");
+        pw.println("  bg-dexopt-job [--cancel | --disable | --enable]");
+        pw.println("    Control the background dexopt job.");
+        pw.println("    Without flags, it starts a background dexopt job immediately. It does");
+        pw.println("      nothing if a job is already started either automatically by the system");
+        pw.println("      or through this command. This command is not blocking.");
+        pw.println("    Options:");
+        pw.println("      --cancel Cancel any currently running background dexopt job");
+        pw.println("        immediately. This cancels jobs started either automatically by the");
+        pw.println("        system or through this command. This command is not blocking.");
+        pw.println("      --disable: Disable the background dexopt job from being started by the");
+        pw.println("        job scheduler. If a job is already started by the job scheduler and");
+        pw.println("        is running, it will be cancelled immediately. Does not affect");
+        pw.println("        jobs started through this command or by the system in other ways.");
+        pw.println("        This state will be lost when the system_server process exits.");
+        pw.println("      --enable: Enable the background dexopt job to be started by the job");
+        pw.println("        scheduler again, if previously disabled by --disable.");
     }
 
     private void enforceRoot() {
diff --git a/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java b/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java
new file mode 100644
index 0000000..b93bdda
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java
@@ -0,0 +1,262 @@
+/*
+ * 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.ArtManagerLocal.ScheduleBackgroundDexoptJobCallback;
+import static com.android.server.art.model.ArtFlags.ScheduleStatus;
+import static com.android.server.art.model.Config.Callback;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobScheduler;
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.CancellationSignal;
+import android.os.SystemClock;
+import android.os.SystemProperties;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.LocalManagerRegistry;
+import com.android.server.art.model.ArtFlags;
+import com.android.server.art.model.Config;
+import com.android.server.art.model.OptimizeResult;
+import com.android.server.pm.PackageManagerLocal;
+
+import com.google.auto.value.AutoValue;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/** @hide */
+public class BackgroundDexOptJob {
+    private static final String TAG = "BackgroundDexOptJob";
+
+    /**
+     * "android" is the package name for a <service> declared in
+     * frameworks/base/core/res/AndroidManifest.xml
+     */
+    private static final String JOB_PKG_NAME = Utils.PLATFORM_PACKAGE_NAME;
+    /** An arbitrary number. Must be unique among all jobs owned by the system uid. */
+    private static final int JOB_ID = 27873780;
+
+    @VisibleForTesting public static final long JOB_INTERVAL_MS = TimeUnit.DAYS.toMillis(1);
+
+    @NonNull private final Injector mInjector;
+
+    @GuardedBy("this") @Nullable private CompletableFuture<Result> mRunningJob = null;
+    @GuardedBy("this") @Nullable private CancellationSignal mCancellationSignal = null;
+
+    public BackgroundDexOptJob(@NonNull Context context, @NonNull ArtManagerLocal artManagerLocal,
+            @NonNull Config config) {
+        this(new Injector(context, artManagerLocal, config));
+    }
+
+    @VisibleForTesting
+    public BackgroundDexOptJob(@NonNull Injector injector) {
+        mInjector = injector;
+    }
+
+    /** Handles {@link BackgroundDexOptJobService#onStartJob(JobParameters)}. */
+    public boolean onStartJob(
+            @NonNull BackgroundDexOptJobService jobService, @NonNull JobParameters params) {
+        start().thenAcceptAsync(result -> {
+            writeStats(params, result);
+            // This is a periodic job, where the interval is specified in the `JobInfo`. "false"
+            // means not to execute again during a future idle maintenance window in the same
+            // interval. This job will be executed again in the next interval.
+            // This call will be ignored if `onStopJob` is called.
+            jobService.jobFinished(params, false /* wantReschedule */);
+        });
+        // "true" means the job will continue running until `jobFinished` is called.
+        return true;
+    }
+
+    /** Handles {@link BackgroundDexOptJobService#onStopJob(JobParameters)}. */
+    public boolean onStopJob(@NonNull JobParameters params) {
+        cancel();
+        // "true" means to execute again during a future idle maintenance window in the same
+        // interval.
+        return true;
+    }
+
+    /** Handles {@link ArtManagerLocal#scheduleBackgroundDexoptJob()}. */
+    public @ScheduleStatus int schedule() {
+        if (this != BackgroundDexOptJobService.getJob()) {
+            throw new IllegalStateException("This job cannot be scheduled");
+        }
+
+        if (SystemProperties.getBoolean("pm.dexopt.disable_bg_dexopt", false /* def */)) {
+            Log.i(TAG, "Job is disabled by system property 'pm.dexopt.disable_bg_dexopt'");
+            return ArtFlags.SCHEDULE_DISABLED_BY_SYSPROP;
+        }
+
+        JobInfo.Builder builder =
+                new JobInfo
+                        .Builder(JOB_ID,
+                                new ComponentName(
+                                        JOB_PKG_NAME, BackgroundDexOptJobService.class.getName()))
+                        .setPeriodic(JOB_INTERVAL_MS)
+                        .setRequiresDeviceIdle(true)
+                        .setRequiresCharging(true)
+                        .setRequiresBatteryNotLow(true)
+                        .setRequiresStorageNotLow(true);
+
+        Callback<ScheduleBackgroundDexoptJobCallback> callback =
+                mInjector.getConfig().getScheduleBackgroundDexoptJobCallback();
+        if (callback != null) {
+            Utils.executeAndWait(
+                    callback.executor(), () -> { callback.get().onOverrideJobInfo(builder); });
+        }
+
+        return mInjector.getJobScheduler().schedule(builder.build()) == JobScheduler.RESULT_SUCCESS
+                ? ArtFlags.SCHEDULE_SUCCESS
+                : ArtFlags.SCHEDULE_JOB_SCHEDULER_FAILURE;
+    }
+
+    /** Handles {@link ArtManagerLocal#unscheduleBackgroundDexoptJob()}. */
+    public void unschedule() {
+        if (this != BackgroundDexOptJobService.getJob()) {
+            throw new IllegalStateException("This job cannot be unscheduled");
+        }
+
+        mInjector.getJobScheduler().cancel(JOB_ID);
+    }
+
+    @NonNull
+    public synchronized CompletableFuture<Result> start() {
+        if (mRunningJob != null) {
+            Log.i(TAG, "Job is already running");
+            return mRunningJob;
+        }
+
+        mCancellationSignal = new CancellationSignal();
+        mRunningJob = new CompletableFuture().supplyAsync(() -> {
+            Log.i(TAG, "Job started");
+            try {
+                return run(mCancellationSignal);
+            } catch (RuntimeException e) {
+                Log.e(TAG, "Fatal error", e);
+                return new FatalErrorResult();
+            } finally {
+                Log.i(TAG, "Job finished");
+                synchronized (this) {
+                    mRunningJob = null;
+                    mCancellationSignal = null;
+                }
+            }
+        });
+        return mRunningJob;
+    }
+
+    public synchronized void cancel() {
+        if (mRunningJob == null) {
+            Log.i(TAG, "Job is not running");
+            return;
+        }
+
+        mCancellationSignal.cancel();
+        Log.i(TAG, "Job cancelled");
+    }
+
+    @NonNull
+    private CompletedResult run(@NonNull CancellationSignal cancellationSignal) {
+        // TODO(b/254013427): Cleanup dex use info.
+        // TODO(b/254013425): Cleanup unused secondary dex file artifacts.
+        // TODO(b/255565888): Downgrade inactive apps.
+        long startTimeMs = SystemClock.uptimeMillis();
+        OptimizeResult dexoptResult;
+        try (var snapshot = mInjector.getPackageManagerLocal().withFilteredSnapshot()) {
+            dexoptResult = mInjector.getArtManagerLocal().optimizePackages(
+                    snapshot, ReasonMapping.REASON_BG_DEXOPT, cancellationSignal);
+        }
+        return CompletedResult.create(dexoptResult, SystemClock.uptimeMillis() - startTimeMs);
+    }
+
+    private void writeStats(@NonNull JobParameters params, @NonNull Result result) {
+        if (result instanceof CompletedResult) {
+            var completedResult = (CompletedResult) result;
+            int status = completedResult.dexoptResult().getFinalStatus()
+                            == OptimizeResult.OPTIMIZE_CANCELLED
+                    ? ArtStatsLog.BACKGROUND_DEXOPT_JOB_ENDED__STATUS__STATUS_ABORT_BY_CANCELLATION
+                    : ArtStatsLog.BACKGROUND_DEXOPT_JOB_ENDED__STATUS__STATUS_JOB_FINISHED;
+            ArtStatsLog.write(ArtStatsLog.BACKGROUND_DEXOPT_JOB_ENDED, status,
+                    params.getStopReason(), completedResult.durationMs(), 0 /* deprecated */);
+        } else if (result instanceof FatalErrorResult) {
+            ArtStatsLog.write(ArtStatsLog.BACKGROUND_DEXOPT_JOB_ENDED,
+                    ArtStatsLog.BACKGROUND_DEXOPT_JOB_ENDED__STATUS__STATUS_UNKNOWN,
+                    JobParameters.STOP_REASON_UNDEFINED, 0 /* durationMs */, 0 /* deprecated */);
+        }
+    }
+
+    static abstract class Result {}
+    static class FatalErrorResult extends Result {}
+
+    @AutoValue
+    static abstract class CompletedResult extends Result {
+        abstract @NonNull OptimizeResult dexoptResult();
+        abstract long durationMs();
+
+        @NonNull
+        static CompletedResult create(@NonNull OptimizeResult dexoptResult, long durationMs) {
+            return new AutoValue_BackgroundDexOptJob_CompletedResult(dexoptResult, durationMs);
+        }
+    }
+
+    /**
+     * Injector pattern for testing purpose.
+     *
+     * @hide
+     */
+    @VisibleForTesting
+    public static class Injector {
+        @NonNull private final Context mContext;
+        @NonNull private final ArtManagerLocal mArtManagerLocal;
+        @NonNull private final Config mConfig;
+
+        Injector(@NonNull Context context, @NonNull ArtManagerLocal artManagerLocal,
+                @NonNull Config config) {
+            mContext = context;
+            mArtManagerLocal = artManagerLocal;
+            mConfig = config;
+        }
+
+        @NonNull
+        public ArtManagerLocal getArtManagerLocal() {
+            return mArtManagerLocal;
+        }
+
+        @NonNull
+        public PackageManagerLocal getPackageManagerLocal() {
+            return LocalManagerRegistry.getManager(PackageManagerLocal.class);
+        }
+
+        @NonNull
+        public Config getConfig() {
+            return mConfig;
+        }
+
+        @NonNull
+        public JobScheduler getJobScheduler() {
+            return mContext.getSystemService(JobScheduler.class);
+        }
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/BackgroundDexOptJobService.java b/libartservice/service/java/com/android/server/art/BackgroundDexOptJobService.java
new file mode 100644
index 0000000..5ab35d5
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/BackgroundDexOptJobService.java
@@ -0,0 +1,46 @@
+/*
+ * 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.app.job.JobParameters;
+import android.app.job.JobService;
+
+import com.android.server.LocalManagerRegistry;
+
+/**
+ * Entry point for the callback from the job scheduler. This class is instantiated by the system
+ * automatically.
+ *
+ * @hide
+ */
+public class BackgroundDexOptJobService extends JobService {
+    @Override
+    public boolean onStartJob(@NonNull JobParameters params) {
+        return getJob().onStartJob(this, params);
+    }
+
+    @Override
+    public boolean onStopJob(@NonNull JobParameters params) {
+        return getJob().onStopJob(params);
+    }
+
+    @NonNull
+    static BackgroundDexOptJob getJob() {
+        return LocalManagerRegistry.getManager(ArtManagerLocal.class).getBackgroundDexOptJob();
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/model/ArtFlags.java b/libartservice/service/java/com/android/server/art/model/ArtFlags.java
index 9ce066b..bf08a2f 100644
--- a/libartservice/service/java/com/android/server/art/model/ArtFlags.java
+++ b/libartservice/service/java/com/android/server/art/model/ArtFlags.java
@@ -18,6 +18,7 @@
 
 import android.annotation.IntDef;
 import android.annotation.SystemApi;
+import android.app.job.JobScheduler;
 
 import com.android.server.art.ArtManagerLocal;
 import com.android.server.art.PriorityClass;
@@ -170,5 +171,29 @@
     @Retention(RetentionPolicy.SOURCE)
     public @interface PriorityClassApi {}
 
+    /** The job has been successfully scheduled. */
+    public static final int SCHEDULE_SUCCESS = 0;
+
+    /** @see JobScheduler#RESULT_FAILURE */
+    public static final int SCHEDULE_JOB_SCHEDULER_FAILURE = 1;
+
+    /** The job is disabled by the system property {@code pm.dexopt.disable_bg_dexopt}. */
+    public static final int SCHEDULE_DISABLED_BY_SYSPROP = 2;
+
+    /**
+     * Indicates the result of scheduling a background dexopt job.
+     *
+     * @hide
+     */
+    // clang-format off
+    @IntDef(prefix = "SCHEDULE_", value = {
+        SCHEDULE_SUCCESS,
+        SCHEDULE_JOB_SCHEDULER_FAILURE,
+        SCHEDULE_DISABLED_BY_SYSPROP,
+    })
+    // clang-format on
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface ScheduleStatus {}
+
     private ArtFlags() {}
 }
diff --git a/libartservice/service/java/com/android/server/art/model/Config.java b/libartservice/service/java/com/android/server/art/model/Config.java
index 3082685..a8ed18b 100644
--- a/libartservice/service/java/com/android/server/art/model/Config.java
+++ b/libartservice/service/java/com/android/server/art/model/Config.java
@@ -17,6 +17,7 @@
 package com.android.server.art.model;
 
 import static com.android.server.art.ArtManagerLocal.OptimizePackagesCallback;
+import static com.android.server.art.ArtManagerLocal.ScheduleBackgroundDexoptJobCallback;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -40,6 +41,15 @@
     @Nullable
     private Callback<OptimizePackagesCallback> mOptimizePackagesCallback = null;
 
+    /**
+     * @see ArtManagerLocal#setScheduleBackgroundDexoptJobCallback(Executor,
+     *         ScheduleBackgroundDexoptJobCallback)
+     */
+    @GuardedBy("this")
+    @Nullable
+    private Callback<ScheduleBackgroundDexoptJobCallback> mScheduleBackgroundDexoptJobCallback =
+            null;
+
     public synchronized void setOptimizePackagesCallback(
             @NonNull Executor executor, @NonNull OptimizePackagesCallback callback) {
         mOptimizePackagesCallback = Callback.<OptimizePackagesCallback>create(callback, executor);
@@ -54,6 +64,22 @@
         return mOptimizePackagesCallback;
     }
 
+    public synchronized void setScheduleBackgroundDexoptJobCallback(
+            @NonNull Executor executor, @NonNull ScheduleBackgroundDexoptJobCallback callback) {
+        mScheduleBackgroundDexoptJobCallback =
+                Callback.<ScheduleBackgroundDexoptJobCallback>create(callback, executor);
+    }
+
+    public synchronized void clearScheduleBackgroundDexoptJobCallback() {
+        mScheduleBackgroundDexoptJobCallback = null;
+    }
+
+    @Nullable
+    public synchronized Callback<ScheduleBackgroundDexoptJobCallback>
+    getScheduleBackgroundDexoptJobCallback() {
+        return mScheduleBackgroundDexoptJobCallback;
+    }
+
     @AutoValue
     public static abstract class Callback<T> {
         public abstract @NonNull T get();
diff --git a/libartservice/service/javatests/com/android/server/art/BackgroundDexOptJobTest.java b/libartservice/service/javatests/com/android/server/art/BackgroundDexOptJobTest.java
new file mode 100644
index 0000000..c235f65
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/BackgroundDexOptJobTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.Config.Callback;
+
+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.anyInt;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.same;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.os.CancellationSignal;
+import android.os.SystemProperties;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.art.BackgroundDexOptJob.CompletedResult;
+import com.android.server.art.BackgroundDexOptJob.FatalErrorResult;
+import com.android.server.art.BackgroundDexOptJob.Result;
+import com.android.server.art.model.ArtFlags;
+import com.android.server.art.model.Config;
+import com.android.server.art.model.OptimizeResult;
+import com.android.server.art.testing.StaticMockitoRule;
+import com.android.server.pm.PackageManagerLocal;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class BackgroundDexOptJobTest {
+    private static final long TIMEOUT_SEC = 1;
+
+    @Rule
+    public StaticMockitoRule mockitoRule =
+            new StaticMockitoRule(SystemProperties.class, BackgroundDexOptJobService.class);
+
+    @Mock private BackgroundDexOptJob.Injector mInjector;
+    @Mock private ArtManagerLocal mArtManagerLocal;
+    @Mock private PackageManagerLocal mPackageManagerLocal;
+    @Mock private PackageManagerLocal.FilteredSnapshot mSnapshot;
+    @Mock private JobScheduler mJobScheduler;
+    @Mock private OptimizeResult mOptimizeResult;
+    private Config mConfig;
+    private BackgroundDexOptJob mBackgroundDexOptJob;
+
+    @Before
+    public void setUp() throws Exception {
+        lenient()
+                .when(SystemProperties.getBoolean(eq("pm.dexopt.disable_bg_dexopt"), anyBoolean()))
+                .thenReturn(false);
+
+        lenient().when(mPackageManagerLocal.withFilteredSnapshot()).thenReturn(mSnapshot);
+
+        mConfig = new Config();
+
+        lenient().when(mInjector.getArtManagerLocal()).thenReturn(mArtManagerLocal);
+        lenient().when(mInjector.getPackageManagerLocal()).thenReturn(mPackageManagerLocal);
+        lenient().when(mInjector.getConfig()).thenReturn(mConfig);
+        lenient().when(mInjector.getJobScheduler()).thenReturn(mJobScheduler);
+
+        mBackgroundDexOptJob = new BackgroundDexOptJob(mInjector);
+        lenient().when(BackgroundDexOptJobService.getJob()).thenReturn(mBackgroundDexOptJob);
+    }
+
+    @Test
+    public void testStart() {
+        when(mArtManagerLocal.optimizePackages(
+                     same(mSnapshot), eq(ReasonMapping.REASON_BG_DEXOPT), any()))
+                .thenReturn(mOptimizeResult);
+
+        Result result = Utils.getFuture(mBackgroundDexOptJob.start());
+        assertThat(result).isInstanceOf(CompletedResult.class);
+        assertThat(((CompletedResult) result).dexoptResult()).isSameInstanceAs(mOptimizeResult);
+    }
+
+    @Test
+    public void testStartAlreadyRunning() {
+        Semaphore optimizeDone = new Semaphore(0);
+        when(mArtManagerLocal.optimizePackages(any(), any(), any())).thenAnswer(invocation -> {
+            assertThat(optimizeDone.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue();
+            return mOptimizeResult;
+        });
+
+        Future<Result> future1 = mBackgroundDexOptJob.start();
+        Future<Result> future2 = mBackgroundDexOptJob.start();
+        assertThat(future1).isSameInstanceAs(future2);
+
+        optimizeDone.release();
+        Utils.getFuture(future1);
+
+        verify(mArtManagerLocal, times(1)).optimizePackages(any(), any(), any());
+    }
+
+    @Test
+    public void testStartAnother() {
+        when(mArtManagerLocal.optimizePackages(any(), any(), any())).thenReturn(mOptimizeResult);
+
+        Future<Result> future1 = mBackgroundDexOptJob.start();
+        Utils.getFuture(future1);
+        Future<Result> future2 = mBackgroundDexOptJob.start();
+        Utils.getFuture(future2);
+        assertThat(future1).isNotSameInstanceAs(future2);
+    }
+
+    @Test
+    public void testStartFatalError() {
+        when(mArtManagerLocal.optimizePackages(any(), any(), any()))
+                .thenThrow(IllegalStateException.class);
+
+        Result result = Utils.getFuture(mBackgroundDexOptJob.start());
+        assertThat(result).isInstanceOf(FatalErrorResult.class);
+    }
+
+    @Test
+    public void testStartIgnoreDisabled() {
+        lenient()
+                .when(SystemProperties.getBoolean(eq("pm.dexopt.disable_bg_dexopt"), anyBoolean()))
+                .thenReturn(true);
+
+        when(mArtManagerLocal.optimizePackages(any(), any(), any())).thenReturn(mOptimizeResult);
+
+        // The `start` method should ignore the system property. The system property is for
+        // `schedule`.
+        Utils.getFuture(mBackgroundDexOptJob.start());
+    }
+
+    @Test
+    public void testCancel() {
+        Semaphore optimizeCancelled = new Semaphore(0);
+        when(mArtManagerLocal.optimizePackages(any(), any(), any())).thenAnswer(invocation -> {
+            assertThat(optimizeCancelled.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue();
+            var cancellationSignal = invocation.<CancellationSignal>getArgument(2);
+            assertThat(cancellationSignal.isCanceled()).isTrue();
+            return mOptimizeResult;
+        });
+
+        Future<Result> future = mBackgroundDexOptJob.start();
+        mBackgroundDexOptJob.cancel();
+        optimizeCancelled.release();
+        Utils.getFuture(future);
+    }
+
+    @Test
+    public void testSchedule() {
+        var captor = ArgumentCaptor.forClass(JobInfo.class);
+        when(mJobScheduler.schedule(captor.capture())).thenReturn(JobScheduler.RESULT_SUCCESS);
+
+        assertThat(mBackgroundDexOptJob.schedule()).isEqualTo(ArtFlags.SCHEDULE_SUCCESS);
+
+        JobInfo jobInfo = captor.getValue();
+        assertThat(jobInfo.getIntervalMillis()).isEqualTo(BackgroundDexOptJob.JOB_INTERVAL_MS);
+        assertThat(jobInfo.isRequireDeviceIdle()).isTrue();
+        assertThat(jobInfo.isRequireCharging()).isTrue();
+        assertThat(jobInfo.isRequireBatteryNotLow()).isTrue();
+        assertThat(jobInfo.isRequireStorageNotLow()).isTrue();
+    }
+
+    @Test
+    public void testScheduleDisabled() {
+        when(SystemProperties.getBoolean(eq("pm.dexopt.disable_bg_dexopt"), anyBoolean()))
+                .thenReturn(true);
+
+        assertThat(mBackgroundDexOptJob.schedule())
+                .isEqualTo(ArtFlags.SCHEDULE_DISABLED_BY_SYSPROP);
+
+        verify(mJobScheduler, never()).schedule(any());
+    }
+
+    @Test
+    public void testScheduleOverride() {
+        mConfig.setScheduleBackgroundDexoptJobCallback(Runnable::run, builder -> {
+            builder.setRequiresBatteryNotLow(false);
+            builder.setPriority(JobInfo.PRIORITY_LOW);
+        });
+
+        var captor = ArgumentCaptor.forClass(JobInfo.class);
+        when(mJobScheduler.schedule(captor.capture())).thenReturn(JobScheduler.RESULT_SUCCESS);
+
+        assertThat(mBackgroundDexOptJob.schedule()).isEqualTo(ArtFlags.SCHEDULE_SUCCESS);
+
+        JobInfo jobInfo = captor.getValue();
+        assertThat(jobInfo.getIntervalMillis()).isEqualTo(BackgroundDexOptJob.JOB_INTERVAL_MS);
+        assertThat(jobInfo.isRequireDeviceIdle()).isTrue();
+        assertThat(jobInfo.isRequireCharging()).isTrue();
+        assertThat(jobInfo.isRequireBatteryNotLow()).isFalse();
+        assertThat(jobInfo.isRequireStorageNotLow()).isTrue();
+        assertThat(jobInfo.getPriority()).isEqualTo(JobInfo.PRIORITY_LOW);
+    }
+
+    @Test
+    public void testScheduleOverrideCleared() {
+        mConfig.setScheduleBackgroundDexoptJobCallback(
+                Runnable::run, builder -> { builder.setRequiresBatteryNotLow(false); });
+        mConfig.clearScheduleBackgroundDexoptJobCallback();
+
+        var captor = ArgumentCaptor.forClass(JobInfo.class);
+        when(mJobScheduler.schedule(captor.capture())).thenReturn(JobScheduler.RESULT_SUCCESS);
+
+        assertThat(mBackgroundDexOptJob.schedule()).isEqualTo(ArtFlags.SCHEDULE_SUCCESS);
+
+        JobInfo jobInfo = captor.getValue();
+        assertThat(jobInfo.isRequireBatteryNotLow()).isTrue();
+    }
+
+    @Test
+    public void testUnschedule() {
+        mBackgroundDexOptJob.unschedule();
+        verify(mJobScheduler).cancel(anyInt());
+    }
+}
diff --git a/libartservice/service/proguard.flags b/libartservice/service/proguard.flags
index 8315cf2..8ef413f 100644
--- a/libartservice/service/proguard.flags
+++ b/libartservice/service/proguard.flags
@@ -5,3 +5,6 @@
   *** set*(***);
   *** has*();
 }
+
+# A job service is referenced by the framework through reflection.
+-keep class * extends android.app.job.JobService { *; }