Support snapshotting profiles.

Bug: 258486155
Test: atest ArtServiceTests
Test: adb shell pm art snapshot-app-profile /data/system/1.prof com.google.android.youtube
Test: adb shell pm art snapshot-boot-image-profile /data/system/1.prof
Ignore-AOSP-First: ART Services.
Change-Id: I81220ca563ac24100056083bc4a4a8661c0dc1d0
diff --git a/artd/artd.cc b/artd/artd.cc
index b97ed6a..208e5cc 100644
--- a/artd/artd.cc
+++ b/artd/artd.cc
@@ -83,6 +83,7 @@
 using ::aidl::com::android::server::art::GetDexoptNeededResult;
 using ::aidl::com::android::server::art::GetOptimizationStatusResult;
 using ::aidl::com::android::server::art::IArtdCancellationSignal;
+using ::aidl::com::android::server::art::MergeProfileOptions;
 using ::aidl::com::android::server::art::OutputArtifacts;
 using ::aidl::com::android::server::art::OutputProfile;
 using ::aidl::com::android::server::art::PriorityClass;
@@ -511,6 +512,7 @@
   OR_RETURN_NON_FATAL(dst->Keep());
   *_aidl_return = true;
   in_dst->profilePath.id = dst->TempId();
+  in_dst->profilePath.tmpPath = dst->TempPath();
   return ScopedAStatus::ok();
 }
 
@@ -570,7 +572,8 @@
 ndk::ScopedAStatus Artd::mergeProfiles(const std::vector<ProfilePath>& in_profiles,
                                        const std::optional<ProfilePath>& in_referenceProfile,
                                        OutputProfile* in_outputProfile,
-                                       const std::string& in_dexFile,
+                                       const std::vector<std::string>& in_dexFiles,
+                                       const MergeProfileOptions& in_options,
                                        bool* _aidl_return) {
   std::vector<std::string> profile_paths;
   for (const ProfilePath& profile : in_profiles) {
@@ -582,7 +585,9 @@
   }
   std::string output_profile_path =
       OR_RETURN_FATAL(BuildFinalProfilePath(in_outputProfile->profilePath));
-  OR_RETURN_FATAL(ValidateDexPath(in_dexFile));
+  for (const std::string& dex_file : in_dexFiles) {
+    OR_RETURN_FATAL(ValidateDexPath(dex_file));
+  }
 
   CmdlineBuilder args;
   FdLogger fd_logger;
@@ -629,14 +634,20 @@
   args.Add("--reference-profile-file-fd=%d", output_profile_file->Fd());
   fd_logger.Add(*output_profile_file);
 
-  std::unique_ptr<File> dex_file = OR_RETURN_NON_FATAL(OpenFileForReading(in_dexFile));
-  args.Add("--apk-fd=%d", dex_file->Fd());
-  fd_logger.Add(*dex_file);
+  std::vector<std::unique_ptr<File>> dex_files;
+  for (const std::string& dex_path : in_dexFiles) {
+    std::unique_ptr<File> dex_file = OR_RETURN_NON_FATAL(OpenFileForReading(dex_path));
+    args.Add("--apk-fd=%d", dex_file->Fd());
+    fd_logger.Add(*dex_file);
+    dex_files.push_back(std::move(dex_file));
+  }
 
   args.AddIfNonEmpty("--min-new-classes-percent-change=%s",
                      props_->GetOrEmpty("dalvik.vm.bgdexopt.new-classes-percent"))
       .AddIfNonEmpty("--min-new-methods-percent-change=%s",
-                     props_->GetOrEmpty("dalvik.vm.bgdexopt.new-methods-percent"));
+                     props_->GetOrEmpty("dalvik.vm.bgdexopt.new-methods-percent"))
+      .AddIf(in_options.forceMerge, "--force-merge")
+      .AddIf(in_options.forBootImage, "--boot-image-merge");
 
   LOG(INFO) << "Running profman: " << Join(args.Get(), /*separator=*/" ")
             << "\nOpened FDs: " << fd_logger;
@@ -654,13 +665,16 @@
     return ScopedAStatus::ok();
   }
 
-  if (result.value() != ProfmanResult::kCompile) {
+  ProfmanResult::ProcessingResult expected_result =
+      in_options.forceMerge ? ProfmanResult::kSuccess : ProfmanResult::kCompile;
+  if (result.value() != expected_result) {
     return NonFatal("profman returned an unexpected code: {}"_format(result.value()));
   }
 
   OR_RETURN_NON_FATAL(output_profile_file->Keep());
   *_aidl_return = true;
   in_outputProfile->profilePath.id = output_profile_file->TempId();
+  in_outputProfile->profilePath.tmpPath = output_profile_file->TempPath();
   return ScopedAStatus::ok();
 }
 
diff --git a/artd/artd.h b/artd/artd.h
index c024087..e90d541 100644
--- a/artd/artd.h
+++ b/artd/artd.h
@@ -109,7 +109,8 @@
       const std::vector<aidl::com::android::server::art::ProfilePath>& in_profiles,
       const std::optional<aidl::com::android::server::art::ProfilePath>& in_referenceProfile,
       aidl::com::android::server::art::OutputProfile* in_outputProfile,
-      const std::string& in_dexFile,
+      const std::vector<std::string>& in_dexFiles,
+      const aidl::com::android::server::art::MergeProfileOptions& in_options,
       bool* _aidl_return) override;
 
   ndk::ScopedAStatus getArtifactsVisibility(
diff --git a/artd/artd_test.cc b/artd/artd_test.cc
index e1480cd..f14a608 100644
--- a/artd/artd_test.cc
+++ b/artd/artd_test.cc
@@ -1063,6 +1063,7 @@
   CreateFile(src_file, "abc");
   OutputProfile dst{.profilePath = src, .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   dst.profilePath.id = "";
+  dst.profilePath.tmpPath = "";
 
   CreateFile(dex_file_);
 
@@ -1084,7 +1085,9 @@
   EXPECT_TRUE(artd_->copyAndRewriteProfile(src, &dst, dex_file_, &result).isOk());
   EXPECT_TRUE(result);
   EXPECT_THAT(dst.profilePath.id, Not(IsEmpty()));
-  CheckContent(OR_FATAL(BuildTmpProfilePath(dst.profilePath)), "def");
+  std::string real_path = OR_FATAL(BuildTmpProfilePath(dst.profilePath));
+  EXPECT_EQ(dst.profilePath.tmpPath, real_path);
+  CheckContent(real_path, "def");
 }
 
 TEST_F(ArtdTest, copyAndRewriteProfileFalse) {
@@ -1093,6 +1096,7 @@
   CreateFile(src_file, "abc");
   OutputProfile dst{.profilePath = src, .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   dst.profilePath.id = "";
+  dst.profilePath.tmpPath = "";
 
   CreateFile(dex_file_);
 
@@ -1102,6 +1106,8 @@
   bool result;
   EXPECT_TRUE(artd_->copyAndRewriteProfile(src, &dst, dex_file_, &result).isOk());
   EXPECT_FALSE(result);
+  EXPECT_THAT(dst.profilePath.id, IsEmpty());
+  EXPECT_THAT(dst.profilePath.tmpPath, IsEmpty());
 }
 
 TEST_F(ArtdTest, copyAndRewriteProfileNotFound) {
@@ -1110,10 +1116,13 @@
   const TmpProfilePath& src = profile_path_->get<ProfilePath::tmpProfilePath>();
   OutputProfile dst{.profilePath = src, .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   dst.profilePath.id = "";
+  dst.profilePath.tmpPath = "";
 
   bool result;
   EXPECT_TRUE(artd_->copyAndRewriteProfile(src, &dst, dex_file_, &result).isOk());
   EXPECT_FALSE(result);
+  EXPECT_THAT(dst.profilePath.id, IsEmpty());
+  EXPECT_THAT(dst.profilePath.tmpPath, IsEmpty());
 }
 
 TEST_F(ArtdTest, copyAndRewriteProfileFailed) {
@@ -1122,6 +1131,7 @@
   CreateFile(src_file, "abc");
   OutputProfile dst{.profilePath = src, .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   dst.profilePath.id = "";
+  dst.profilePath.tmpPath = "";
 
   CreateFile(dex_file_);
 
@@ -1133,6 +1143,8 @@
   EXPECT_FALSE(status.isOk());
   EXPECT_EQ(status.getExceptionCode(), EX_SERVICE_SPECIFIC);
   EXPECT_THAT(status.getMessage(), HasSubstr("profman returned an unexpected code: 100"));
+  EXPECT_THAT(dst.profilePath.id, IsEmpty());
+  EXPECT_THAT(dst.profilePath.tmpPath, IsEmpty());
 }
 
 TEST_F(ArtdTest, commitTmpProfile) {
@@ -1334,8 +1346,12 @@
   OutputProfile output_profile{.profilePath = reference_profile_path,
                                .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   output_profile.profilePath.id = "";
+  output_profile.profilePath.tmpPath = "";
 
-  CreateFile(dex_file_);
+  std::string dex_file_1 = scratch_path_ + "/a/b.apk";
+  std::string dex_file_2 = scratch_path_ + "/a/c.apk";
+  CreateFile(dex_file_1);
+  CreateFile(dex_file_2);
 
   EXPECT_CALL(
       *mock_exec_utils_,
@@ -1346,7 +1362,10 @@
                             Not(Contains(Flag("--profile-file-fd=", FdOf(profile_0_file)))),
                             Contains(Flag("--profile-file-fd=", FdOf(profile_1_file))),
                             Contains(Flag("--reference-profile-file-fd=", FdHasContent("abc"))),
-                            Contains(Flag("--apk-fd=", FdOf(dex_file_))))),
+                            Contains(Flag("--apk-fd=", FdOf(dex_file_1))),
+                            Contains(Flag("--apk-fd=", FdOf(dex_file_2))),
+                            Not(Contains("--force-merge")),
+                            Not(Contains("--boot-image-merge")))),
           _,
           _))
       .WillOnce(DoAll(WithArg<0>(ClearAndWriteToFdFlag("--reference-profile-file-fd=", "merged")),
@@ -1357,12 +1376,15 @@
                   ->mergeProfiles({profile_0_path, profile_1_path},
                                   reference_profile_path,
                                   &output_profile,
-                                  dex_file_,
+                                  {dex_file_1, dex_file_2},
+                                  /*in_options=*/{},
                                   &result)
                   .isOk());
   EXPECT_TRUE(result);
   EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
-  CheckContent(OR_FATAL(BuildTmpProfilePath(output_profile.profilePath)), "merged");
+  std::string real_path = OR_FATAL(BuildTmpProfilePath(output_profile.profilePath));
+  EXPECT_EQ(output_profile.profilePath.tmpPath, real_path);
+  CheckContent(real_path, "merged");
 }
 
 TEST_F(ArtdTest, mergeProfilesEmptyReferenceProfile) {
@@ -1374,6 +1396,7 @@
   OutputProfile output_profile{.profilePath = profile_path_->get<ProfilePath::tmpProfilePath>(),
                                .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   output_profile.profilePath.id = "";
+  output_profile.profilePath.tmpPath = "";
 
   CreateFile(dex_file_);
 
@@ -1392,12 +1415,17 @@
                       Return(ProfmanResult::kCompile)));
 
   bool result;
-  EXPECT_TRUE(
-      artd_->mergeProfiles({profile_0_path}, std::nullopt, &output_profile, dex_file_, &result)
-          .isOk());
+  EXPECT_TRUE(artd_
+                  ->mergeProfiles({profile_0_path},
+                                  std::nullopt,
+                                  &output_profile,
+                                  {dex_file_},
+                                  /*in_options=*/{},
+                                  &result)
+                  .isOk());
   EXPECT_TRUE(result);
   EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
-  CheckContent(OR_FATAL(BuildTmpProfilePath(output_profile.profilePath)), "merged");
+  EXPECT_THAT(output_profile.profilePath.tmpPath, Not(IsEmpty()));
 }
 
 TEST_F(ArtdTest, mergeProfilesProfilesDontExist) {
@@ -1418,17 +1446,59 @@
   OutputProfile output_profile{.profilePath = reference_profile_path,
                                .fsPermission = FsPermission{.uid = -1, .gid = -1}};
   output_profile.profilePath.id = "";
+  output_profile.profilePath.tmpPath = "";
 
   CreateFile(dex_file_);
 
   EXPECT_CALL(*mock_exec_utils_, DoExecAndReturnCode).Times(0);
 
   bool result;
-  EXPECT_TRUE(
-      artd_->mergeProfiles({profile_0_path}, std::nullopt, &output_profile, dex_file_, &result)
-          .isOk());
+  EXPECT_TRUE(artd_
+                  ->mergeProfiles({profile_0_path},
+                                  std::nullopt,
+                                  &output_profile,
+                                  {dex_file_},
+                                  /*in_options=*/{},
+                                  &result)
+                  .isOk());
   EXPECT_FALSE(result);
   EXPECT_THAT(output_profile.profilePath.id, IsEmpty());
+  EXPECT_THAT(output_profile.profilePath.tmpPath, IsEmpty());
+}
+
+TEST_F(ArtdTest, mergeProfilesWithOptions) {
+  PrimaryCurProfilePath profile_0_path{
+      .userId = 0, .packageName = "com.android.foo", .profileName = "primary"};
+  std::string profile_0_file = OR_FATAL(BuildPrimaryCurProfilePath(profile_0_path));
+  CreateFile(profile_0_file, "def");
+
+  OutputProfile output_profile{.profilePath = profile_path_->get<ProfilePath::tmpProfilePath>(),
+                               .fsPermission = FsPermission{.uid = -1, .gid = -1}};
+  output_profile.profilePath.id = "";
+  output_profile.profilePath.tmpPath = "";
+
+  CreateFile(dex_file_);
+
+  EXPECT_CALL(
+      *mock_exec_utils_,
+      DoExecAndReturnCode(
+          WhenSplitBy("--", _, AllOf(Contains("--force-merge"), Contains("--boot-image-merge"))),
+          _,
+          _))
+      .WillOnce(Return(ProfmanResult::kSuccess));
+
+  bool result;
+  EXPECT_TRUE(artd_
+                  ->mergeProfiles({profile_0_path},
+                                  std::nullopt,
+                                  &output_profile,
+                                  {dex_file_},
+                                  {.forceMerge = true, .forBootImage = true},
+                                  &result)
+                  .isOk());
+  EXPECT_TRUE(result);
+  EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
+  EXPECT_THAT(output_profile.profilePath.tmpPath, Not(IsEmpty()));
 }
 
 }  // namespace
diff --git a/artd/binder/com/android/server/art/IArtd.aidl b/artd/binder/com/android/server/art/IArtd.aidl
index 19c1d66..237f8a9 100644
--- a/artd/binder/com/android/server/art/IArtd.aidl
+++ b/artd/binder/com/android/server/art/IArtd.aidl
@@ -85,14 +85,16 @@
      * Merges profiles. Both `profiles` and `referenceProfile` are inputs, while the difference is
      * that `referenceProfile` is also used as the reference to calculate the diff. `profiles` that
      * don't exist are skipped, while `referenceProfile`, if provided, must exist. Returns true,
-     * writes the merge result to `outputProfile` and fills `outputProfile.profilePath.id` if a
-     * merge has been performed.
+     * writes the merge result to `outputProfile` and fills `outputProfile.profilePath.id` and
+     * `outputProfile.profilePath.tmpPath` if a merge has been performed.
      *
      * Throws fatal and non-fatal errors.
      */
     boolean mergeProfiles(in List<com.android.server.art.ProfilePath> profiles,
             in @nullable com.android.server.art.ProfilePath referenceProfile,
-            inout com.android.server.art.OutputProfile outputProfile, @utf8InCpp String dexFile);
+            inout com.android.server.art.OutputProfile outputProfile,
+            in @utf8InCpp List<String> dexFiles,
+            in com.android.server.art.MergeProfileOptions options);
 
     /**
      * Returns the visibility of the artifacts.
diff --git a/artd/binder/com/android/server/art/MergeProfileOptions.aidl b/artd/binder/com/android/server/art/MergeProfileOptions.aidl
new file mode 100644
index 0000000..fb7db80
--- /dev/null
+++ b/artd/binder/com/android/server/art/MergeProfileOptions.aidl
@@ -0,0 +1,35 @@
+/*
+ * 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;
+
+/**
+ * Miscellaneous options for merging profiles. Every field corresponds to a profman command line
+ * flag.
+ *
+ * DO NOT add fields for flags that artd can determine directly with trivial logic. That includes
+ * static flags, and flags that only depend on system properties or other passed parameters.
+ *
+ * All fields are required.
+ *
+ * @hide
+ */
+parcelable MergeProfileOptions {
+    /** --force-merge */
+    boolean forceMerge;
+    /** --boot-image-merge */
+    boolean forBootImage;
+}
diff --git a/artd/binder/com/android/server/art/ProfilePath.aidl b/artd/binder/com/android/server/art/ProfilePath.aidl
index fd413a9..43df531 100644
--- a/artd/binder/com/android/server/art/ProfilePath.aidl
+++ b/artd/binder/com/android/server/art/ProfilePath.aidl
@@ -90,5 +90,7 @@
         WritableProfilePath finalPath;
         /** A unique identifier to distinguish this temporary file from others. Filled by artd. */
         @utf8InCpp String id;
+        /** The path to the temporary file. Filled by artd. */
+        @utf8InCpp String tmpPath;
     }
 }
diff --git a/libartservice/service/api/system-server-current.txt b/libartservice/service/api/system-server-current.txt
index 0eb1738..eea0da2 100644
--- a/libartservice/service/api/system-server-current.txt
+++ b/libartservice/service/api/system-server-current.txt
@@ -20,6 +20,8 @@
     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 @NonNull public android.os.ParcelFileDescriptor snapshotAppProfile(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot, @NonNull String, @Nullable String) throws com.android.server.art.ArtManagerLocal.SnapshotProfileException;
+    method @NonNull public android.os.ParcelFileDescriptor snapshotBootImageProfile(@NonNull com.android.server.pm.PackageManagerLocal.FilteredSnapshot) throws com.android.server.art.ArtManagerLocal.SnapshotProfileException;
     method public void startBackgroundDexoptJob();
     method public void unscheduleBackgroundDexoptJob();
   }
@@ -36,6 +38,10 @@
     method public void onOverrideJobInfo(@NonNull android.app.job.JobInfo.Builder);
   }
 
+  public static class ArtManagerLocal.SnapshotProfileException extends java.lang.Exception {
+    ctor public ArtManagerLocal.SnapshotProfileException(@NonNull Throwable);
+  }
+
   public class ArtModuleServiceInitializer {
     method public static void setArtModuleServiceManager(@NonNull android.os.ArtModuleServiceManager);
   }
diff --git a/libartservice/service/java/com/android/server/art/AidlUtils.java b/libartservice/service/java/com/android/server/art/AidlUtils.java
index 21563aea..f38000d 100644
--- a/libartservice/service/java/com/android/server/art/AidlUtils.java
+++ b/libartservice/service/java/com/android/server/art/AidlUtils.java
@@ -149,6 +149,7 @@
         outputProfile.profilePath = new TmpProfilePath();
         outputProfile.profilePath.finalPath = finalPath;
         outputProfile.profilePath.id = ""; // Will be filled by artd.
+        outputProfile.profilePath.tmpPath = ""; // Will be filled by artd.
         outputProfile.fsPermission = buildFsPermission(uid, gid, isPublic);
         return outputProfile;
     }
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index 90f027a..7a5aa61 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -37,8 +37,11 @@
 import android.os.Binder;
 import android.os.CancellationSignal;
 import android.os.ParcelFileDescriptor;
+import android.os.Process;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
+import android.os.UserManager;
+import android.text.TextUtils;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.LocalManagerRegistry;
@@ -53,13 +56,17 @@
 import com.android.server.pm.pkg.AndroidPackage;
 import com.android.server.pm.pkg.PackageState;
 
+import java.io.File;
+import java.io.FileNotFoundException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
 
 /**
  * This class provides a system API for functionality provided by the ART module.
@@ -75,6 +82,8 @@
 @SystemApi(client = SystemApi.Client.SYSTEM_SERVER)
 public final class ArtManagerLocal {
     private static final String TAG = "ArtService";
+    private static final String[] CLASSPATHS_FOR_BOOT_IMAGE_PROFILE = {
+            "BOOTCLASSPATH", "SYSTEMSERVERCLASSPATH", "STANDALONE_SYSTEMSERVER_JARS"};
 
     @NonNull private final Injector mInjector;
 
@@ -122,7 +131,8 @@
      * Uses the default flags ({@link ArtFlags#defaultDeleteFlags()}).
      *
      * @throws IllegalArgumentException if the package is not found or the flags are illegal
-     * @throws IllegalStateException if an internal error occurs
+     * @throws IllegalStateException if the operation encounters an error that should never happen
+     *         (e.g., an internal logic error).
      */
     @NonNull
     public DeleteResult deleteOptimizedArtifacts(
@@ -182,7 +192,8 @@
      * Uses the default flags ({@link ArtFlags#defaultGetStatusFlags()}).
      *
      * @throws IllegalArgumentException if the package is not found or the flags are illegal
-     * @throws IllegalStateException if an internal error occurs
+     * @throws IllegalStateException if the operation encounters an error that should never happen
+     *         (e.g., an internal logic error).
      */
     @NonNull
     public OptimizationStatus getOptimizationStatus(
@@ -254,7 +265,8 @@
      * #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
+     * @throws IllegalStateException if the operation encounters an error that should never happen
+     *         (e.g., an internal logic error).
      */
     @NonNull
     public OptimizeResult optimizePackage(@NonNull PackageManagerLocal.FilteredSnapshot snapshot,
@@ -311,7 +323,8 @@
      * @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
-     * @throws IllegalStateException if an internal error occurs, or the callback set by {@link
+     * @throws IllegalStateException if the operation encounters an error that should never happen
+     *         (e.g., an internal logic error), or the callback set by {@link
      *         #setOptimizePackagesCallback(Executor, OptimizePackagesCallback)} provides invalid
      *         params.
      *
@@ -511,6 +524,111 @@
     }
 
     /**
+     * Snapshots the profile of the given app split. The profile snapshot is the aggregation of all
+     * existing profiles of the app split (all current user profiles and the reference profile).
+     *
+     * @param snapshot the snapshot from {@link PackageManagerLocal} to operate on
+     * @param packageName the name of the app that owns the profile
+     * @param splitName see {@link AndroidPackageSplit#getName()}
+     * @return the file descriptor of the snapshot. It doesn't have any path associated with it. The
+     *         caller is responsible for closing it. Note that the content may be empty.
+     * @throws IllegalArgumentException if the package or the split is not found
+     * @throws IllegalStateException if the operation encounters an error that should never happen
+     *         (e.g., an internal logic error).
+     * @throws SnapshotProfileException if the operation encounters an error that the caller should
+     *         handle (e.g., an I/O error, a sub-process crash).
+     */
+    @NonNull
+    public ParcelFileDescriptor snapshotAppProfile(
+            @NonNull PackageManagerLocal.FilteredSnapshot snapshot, @NonNull String packageName,
+            @Nullable String splitName) throws SnapshotProfileException {
+        PackageState pkgState = Utils.getPackageStateOrThrow(snapshot, packageName);
+        AndroidPackage pkg = Utils.getPackageOrThrow(pkgState);
+
+        PrimaryDexInfo dexInfo;
+        if (splitName == null) {
+            dexInfo = PrimaryDexUtils.getDexInfo(pkg).get(0);
+        } else {
+            dexInfo = PrimaryDexUtils.getDexInfo(pkg)
+                              .stream()
+                              .filter(info -> splitName.equals(info.splitName()))
+                              .findFirst()
+                              .orElseThrow(() -> {
+                                  return new IllegalArgumentException(
+                                          String.format("Split '%s' not found", splitName));
+                              });
+        }
+
+        List<ProfilePath> profiles = new ArrayList<>();
+        profiles.add(PrimaryDexUtils.buildRefProfilePath(pkgState, dexInfo));
+        profiles.addAll(
+                PrimaryDexUtils.getCurProfiles(mInjector.getUserManager(), pkgState, dexInfo));
+
+        OutputProfile output = PrimaryDexUtils.buildOutputProfile(
+                pkgState, dexInfo, Process.SYSTEM_UID, Process.SYSTEM_UID, false /* isPublic */);
+
+        return mergeProfilesAndGetFd(
+                profiles, output, List.of(dexInfo.dexPath()), false /* forBootImage */);
+    }
+
+    /**
+     * Snapshots the boot image profile
+     * (https://source.android.com/docs/core/bootloader/boot-image-profiles). The profile snapshot
+     * is the aggregation of all existing profiles on the device (all current user profiles and
+     * reference profiles) of all apps and the system server filtered by applicable classpaths.
+     *
+     * @param snapshot the snapshot from {@link PackageManagerLocal} to operate on
+     * @return the file descriptor of the snapshot. It doesn't have any path associated with it. The
+     *         caller is responsible for closing it. Note that the content may be empty.
+     * @throws IllegalStateException if the operation encounters an error that should never happen
+     *         (e.g., an internal logic error).
+     * @throws SnapshotProfileException if the operation encounters an error that the caller should
+     *         handle (e.g., an I/O error, a sub-process crash).
+     */
+    @NonNull
+    public ParcelFileDescriptor snapshotBootImageProfile(
+            @NonNull PackageManagerLocal.FilteredSnapshot snapshot)
+            throws SnapshotProfileException {
+        List<ProfilePath> profiles = new ArrayList<>();
+
+        // System server profiles.
+        PackageState pkgState = Utils.getPackageStateOrThrow(snapshot, Utils.PLATFORM_PACKAGE_NAME);
+        AndroidPackage pkg = Utils.getPackageOrThrow(pkgState);
+        PrimaryDexInfo dexInfo = PrimaryDexUtils.getDexInfo(pkg).get(0);
+        profiles.add(PrimaryDexUtils.buildRefProfilePath(pkgState, dexInfo));
+        profiles.addAll(
+                PrimaryDexUtils.getCurProfiles(mInjector.getUserManager(), pkgState, dexInfo));
+
+        // App profiles.
+        snapshot.forAllPackageStates((appPkgState) -> {
+            // Hibernating apps can still provide useful profile contents, so skip the hibernation
+            // check.
+            if (Utils.canOptimizePackage(appPkgState, null /* appHibernationManager */)) {
+                AndroidPackage appPkg = Utils.getPackageOrThrow(appPkgState);
+                for (PrimaryDexInfo appDexInfo : PrimaryDexUtils.getDexInfo(appPkg)) {
+                    if (!appDexInfo.hasCode()) {
+                        continue;
+                    }
+                    profiles.add(PrimaryDexUtils.buildRefProfilePath(appPkgState, appDexInfo));
+                    profiles.addAll(PrimaryDexUtils.getCurProfiles(
+                            mInjector.getUserManager(), appPkgState, appDexInfo));
+                }
+            }
+        });
+
+        OutputProfile output = AidlUtils.buildOutputProfileForPrimary(Utils.PLATFORM_PACKAGE_NAME,
+                "primary", Process.SYSTEM_UID, Process.SYSTEM_UID, false /* isPublic */);
+
+        List<String> dexPaths = Arrays.stream(CLASSPATHS_FOR_BOOT_IMAGE_PROFILE)
+                                        .map(envVar -> Constants.getenv(envVar))
+                                        .filter(classpath -> !TextUtils.isEmpty(classpath))
+                                        .flatMap(classpath -> Arrays.stream(classpath.split(":")))
+                                        .collect(Collectors.toList());
+
+        return mergeProfilesAndGetFd(profiles, output, dexPaths, true /* forBootImage */);
+    }
+
+    /**
      * Should be used by {@link BackgroundDexOptJobService} ONLY.
      *
      * @hide
@@ -532,6 +650,44 @@
         return packages;
     }
 
+    @NonNull
+    private ParcelFileDescriptor mergeProfilesAndGetFd(@NonNull List<ProfilePath> profiles,
+            @NonNull OutputProfile output, @NonNull List<String> dexPaths, boolean forBootImage)
+            throws SnapshotProfileException {
+        try {
+            var options = new MergeProfileOptions();
+            options.forceMerge = true;
+            options.forBootImage = forBootImage;
+
+            boolean hasContent = false;
+            try {
+                hasContent = mInjector.getArtd().mergeProfiles(
+                        profiles, null /* referenceProfile */, output, dexPaths, options);
+            } catch (ServiceSpecificException e) {
+                throw new SnapshotProfileException(e);
+            }
+
+            String path = hasContent ? output.profilePath.tmpPath : "/dev/null";
+            ParcelFileDescriptor fd;
+            try {
+                fd = ParcelFileDescriptor.open(new File(path), ParcelFileDescriptor.MODE_READ_ONLY);
+            } catch (FileNotFoundException e) {
+                throw new IllegalStateException(
+                        String.format("Failed to open profile snapshot '%s'", path), e);
+            }
+
+            if (hasContent) {
+                // This is done on the open file so that only the FD keeps a reference to its
+                // contents.
+                mInjector.getArtd().deleteProfile(ProfilePath.tmpProfilePath(output.profilePath));
+            }
+
+            return fd;
+        } catch (RemoteException e) {
+            throw new IllegalStateException("An error occurred when calling artd", e);
+        }
+    }
+
     public interface OptimizePackagesCallback {
         /**
          * Mutates {@code builder} to override the default params for {@link
@@ -569,6 +725,13 @@
         void onOptimizePackageDone(@NonNull OptimizeResult result);
     }
 
+    /** Represents an error that happens when snapshotting profiles.  */
+    public static class SnapshotProfileException extends Exception {
+        public SnapshotProfileException(@NonNull Throwable cause) {
+            super(cause);
+        }
+    }
+
     /**
      * Injector pattern for testing purpose.
      *
@@ -629,5 +792,10 @@
         public BackgroundDexOptJob getBackgroundDexOptJob() {
             return Objects.requireNonNull(mBgDexOptJob);
         }
+
+        @NonNull
+        public UserManager getUserManager() {
+            return Objects.requireNonNull(mContext.getSystemService(UserManager.class));
+        }
     }
 }
diff --git a/libartservice/service/java/com/android/server/art/ArtShellCommand.java b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
index 5540ac3..cc9d7ae 100644
--- a/libartservice/service/java/com/android/server/art/ArtShellCommand.java
+++ b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
@@ -16,6 +16,9 @@
 
 package com.android.server.art;
 
+import static android.os.ParcelFileDescriptor.AutoCloseInputStream;
+
+import static com.android.server.art.ArtManagerLocal.SnapshotProfileException;
 import static com.android.server.art.model.ArtFlags.OptimizeFlags;
 import static com.android.server.art.model.OptimizationStatus.DexContainerFileOptimizationStatus;
 import static com.android.server.art.model.OptimizeResult.DexContainerFileOptimizeResult;
@@ -25,6 +28,7 @@
 import android.annotation.NonNull;
 import android.os.Binder;
 import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
 import android.os.Process;
 
 import com.android.internal.annotations.GuardedBy;
@@ -36,6 +40,12 @@
 import com.android.server.art.model.OptimizeResult;
 import com.android.server.pm.PackageManagerLocal;
 
+import libcore.io.Streams;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.io.PrintWriter;
 import java.util.HashMap;
 import java.util.Map;
@@ -219,6 +229,29 @@
                             return 1;
                     }
                 }
+                case "snapshot-app-profile": {
+                    String outputPath = getNextArgRequired();
+                    ParcelFileDescriptor fd;
+                    try {
+                        fd = mArtManagerLocal.snapshotAppProfile(
+                                snapshot, getNextArgRequired(), getNextOption());
+                    } catch (SnapshotProfileException e) {
+                        throw new RuntimeException(e);
+                    }
+                    writeFdContentsToFile(fd, outputPath);
+                    return 0;
+                }
+                case "snapshot-boot-image-profile": {
+                    String outputPath = getNextArgRequired();
+                    ParcelFileDescriptor fd;
+                    try {
+                        fd = mArtManagerLocal.snapshotBootImageProfile(snapshot);
+                    } catch (SnapshotProfileException e) {
+                        throw new RuntimeException(e);
+                    }
+                    writeFdContentsToFile(fd, outputPath);
+                    return 0;
+                }
                 default:
                     // Handles empty, help, and invalid commands.
                     return handleDefaultCommands(cmd);
@@ -294,6 +327,11 @@
         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.");
+        pw.println("  snapshot-app-profile OUTPUT_PATH PACKAGE_NAME [SPLIT_NAME]");
+        pw.println("    Snapshot the profile of the given app and save it to the output path.");
+        pw.println("    If SPLIT_NAME is empty, the command snapshots the base APK.");
+        pw.println("  snapshot-boot-image-profile OUTPUT_PATH");
+        pw.println("    Snapshot the boot image profile and save it to the output path.");
     }
 
     private void enforceRoot() {
@@ -337,6 +375,16 @@
         }
     }
 
+    private void writeFdContentsToFile(
+            @NonNull ParcelFileDescriptor fd, @NonNull String outputPath) {
+        try (InputStream inputStream = new AutoCloseInputStream(fd);
+                OutputStream outputStream = new FileOutputStream(outputPath)) {
+            Streams.copy(inputStream, outputStream);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
     private static class WithCancellationSignal implements AutoCloseable {
         @NonNull private final CancellationSignal mSignal = new CancellationSignal();
         @NonNull private final String mJobId;
diff --git a/libartservice/service/java/com/android/server/art/Constants.java b/libartservice/service/java/com/android/server/art/Constants.java
index 10953c7..2d2d757 100644
--- a/libartservice/service/java/com/android/server/art/Constants.java
+++ b/libartservice/service/java/com/android/server/art/Constants.java
@@ -19,6 +19,7 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.os.Build;
+import android.system.Os;
 
 /**
  * A mockable wrapper class for device-specific constants.
@@ -49,4 +50,9 @@
         // the native one.
         return Build.SUPPORTED_32_BIT_ABIS.length > 0 ? Build.SUPPORTED_32_BIT_ABIS[0] : null;
     }
+
+    @Nullable
+    public static String getenv(@NonNull String name) {
+        return Os.getenv(name);
+    }
 }
diff --git a/libartservice/service/java/com/android/server/art/DexOptimizer.java b/libartservice/service/java/com/android/server/art/DexOptimizer.java
index 51443c6..13afa0b 100644
--- a/libartservice/service/java/com/android/server/art/DexOptimizer.java
+++ b/libartservice/service/java/com/android/server/art/DexOptimizer.java
@@ -490,8 +490,8 @@
         OutputProfile output = buildOutputProfile(dexInfo, false /* isPublic */);
 
         try {
-            if (mInjector.getArtd().mergeProfiles(
-                        getCurProfiles(dexInfo), referenceProfile, output, dexInfo.dexPath())) {
+            if (mInjector.getArtd().mergeProfiles(getCurProfiles(dexInfo), referenceProfile, output,
+                        List.of(dexInfo.dexPath()), new MergeProfileOptions())) {
                 return ProfilePath.tmpProfilePath(output.profilePath);
             }
         } catch (ServiceSpecificException e) {
diff --git a/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java b/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
index 67fdced..5ef3d6a 100644
--- a/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
+++ b/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
@@ -38,7 +38,6 @@
 import com.android.server.pm.PackageManagerLocal;
 import com.android.server.pm.pkg.AndroidPackage;
 import com.android.server.pm.pkg.PackageState;
-import com.android.server.pm.pkg.PackageUserState;
 
 import dalvik.system.DexFile;
 
@@ -170,8 +169,7 @@
     @Override
     @NonNull
     protected ProfilePath buildRefProfilePath(@NonNull DetailedPrimaryDexInfo dexInfo) {
-        String profileName = getProfileName(dexInfo.splitName());
-        return AidlUtils.buildProfilePathForPrimaryRef(mPkgState.getPackageName(), profileName);
+        return PrimaryDexUtils.buildRefProfilePath(mPkgState, dexInfo);
     }
 
     @Override
@@ -188,25 +186,14 @@
     @NonNull
     protected OutputProfile buildOutputProfile(
             @NonNull DetailedPrimaryDexInfo dexInfo, boolean isPublic) {
-        String profileName = getProfileName(dexInfo.splitName());
-        return AidlUtils.buildOutputProfileForPrimary(
-                mPkgState.getPackageName(), profileName, Process.SYSTEM_UID, mSharedGid, isPublic);
+        return PrimaryDexUtils.buildOutputProfile(
+                mPkgState, dexInfo, Process.SYSTEM_UID, mSharedGid, isPublic);
     }
 
     @Override
     @NonNull
     protected List<ProfilePath> getCurProfiles(@NonNull DetailedPrimaryDexInfo dexInfo) {
-        List<ProfilePath> profiles = new ArrayList<>();
-        for (UserHandle handle :
-                mInjector.getUserManager().getUserHandles(true /* excludeDying */)) {
-            int userId = handle.getIdentifier();
-            PackageUserState userState = mPkgState.getStateForUser(handle);
-            if (userState.isInstalled()) {
-                profiles.add(AidlUtils.buildProfilePathForPrimaryCur(
-                        userId, mPkgState.getPackageName(), getProfileName(dexInfo.splitName())));
-            }
-        }
-        return profiles;
+        return PrimaryDexUtils.getCurProfiles(mInjector.getUserManager(), mPkgState, dexInfo);
     }
 
     @Override
@@ -221,9 +208,4 @@
                 || !TextUtils.isEmpty(mPkg.getStaticSharedLibraryName())
                 || !mPkg.getLibraryNames().isEmpty();
     }
-
-    @NonNull
-    private String getProfileName(@Nullable String splitName) {
-        return splitName == null ? "primary" : splitName + ".split";
-    }
 }
diff --git a/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java b/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java
index 3ffaf54..7053b08 100644
--- a/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java
+++ b/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java
@@ -18,6 +18,8 @@
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.os.UserHandle;
+import android.os.UserManager;
 import android.text.TextUtils;
 
 import com.android.internal.annotations.Immutable;
@@ -25,6 +27,7 @@
 import com.android.server.pm.pkg.AndroidPackage;
 import com.android.server.pm.pkg.AndroidPackageSplit;
 import com.android.server.pm.pkg.PackageState;
+import com.android.server.pm.pkg.PackageUserState;
 import com.android.server.pm.pkg.SharedLibrary;
 
 import dalvik.system.DelegateLastClassLoader;
@@ -272,6 +275,41 @@
         return pkg.isIsolatedSplitLoading() && pkg.getSplits().size() > 1;
     }
 
+    @NonNull
+    public static ProfilePath buildRefProfilePath(
+            @NonNull PackageState pkgState, @NonNull PrimaryDexInfo dexInfo) {
+        String profileName = getProfileName(dexInfo.splitName());
+        return AidlUtils.buildProfilePathForPrimaryRef(pkgState.getPackageName(), profileName);
+    }
+
+    @NonNull
+    public static OutputProfile buildOutputProfile(@NonNull PackageState pkgState,
+            @NonNull PrimaryDexInfo dexInfo, int uid, int gid, boolean isPublic) {
+        String profileName = getProfileName(dexInfo.splitName());
+        return AidlUtils.buildOutputProfileForPrimary(
+                pkgState.getPackageName(), profileName, uid, gid, isPublic);
+    }
+
+    @NonNull
+    public static List<ProfilePath> getCurProfiles(@NonNull UserManager userManager,
+            @NonNull PackageState pkgState, @NonNull PrimaryDexInfo dexInfo) {
+        List<ProfilePath> profiles = new ArrayList<>();
+        for (UserHandle handle : userManager.getUserHandles(true /* excludeDying */)) {
+            int userId = handle.getIdentifier();
+            PackageUserState userState = pkgState.getStateForUser(handle);
+            if (userState.isInstalled()) {
+                profiles.add(AidlUtils.buildProfilePathForPrimaryCur(
+                        userId, pkgState.getPackageName(), getProfileName(dexInfo.splitName())));
+            }
+        }
+        return profiles;
+    }
+
+    @NonNull
+    private static String getProfileName(@Nullable String splitName) {
+        return splitName == null ? "primary" : splitName + ".split";
+    }
+
     /** Basic information about a primary dex file (either the base APK or a split APK). */
     @Immutable
     public static class PrimaryDexInfo {
diff --git a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
index f0aa7e5..85a7b87 100644
--- a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
+++ b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
@@ -16,6 +16,8 @@
 
 package com.android.server.art;
 
+import static android.os.ParcelFileDescriptor.AutoCloseInputStream;
+
 import static com.android.server.art.model.OptimizationStatus.DexContainerFileOptimizationStatus;
 import static com.android.server.art.testing.TestingUtils.deepEq;
 
@@ -25,10 +27,13 @@
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.argThat;
+import static org.mockito.Mockito.isNull;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.same;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -36,8 +41,12 @@
 
 import android.apphibernation.AppHibernationManager;
 import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.os.Process;
 import android.os.ServiceSpecificException;
 import android.os.SystemProperties;
+import android.os.UserHandle;
+import android.os.UserManager;
 
 import androidx.test.filters.SmallTest;
 
@@ -51,6 +60,7 @@
 import com.android.server.pm.pkg.AndroidPackage;
 import com.android.server.pm.pkg.AndroidPackageSplit;
 import com.android.server.pm.pkg.PackageState;
+import com.android.server.pm.pkg.PackageUserState;
 
 import org.junit.Before;
 import org.junit.Rule;
@@ -61,6 +71,11 @@
 import org.junit.runners.Parameterized.Parameters;
 import org.mockito.Mock;
 
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.concurrent.Executors;
 import java.util.function.Consumer;
@@ -82,6 +97,7 @@
     @Mock private IArtd mArtd;
     @Mock private DexOptHelper mDexOptHelper;
     @Mock private AppHibernationManager mAppHibernationManager;
+    @Mock private UserManager mUserManager;
     private PackageState mPkgState;
     private AndroidPackage mPkg;
     private Config mConfig;
@@ -108,6 +124,7 @@
         lenient().when(mInjector.getDexOptHelper()).thenReturn(mDexOptHelper);
         lenient().when(mInjector.getConfig()).thenReturn(mConfig);
         lenient().when(mInjector.getAppHibernationManager()).thenReturn(mAppHibernationManager);
+        lenient().when(mInjector.getUserManager()).thenReturn(mUserManager);
 
         lenient().when(SystemProperties.get(eq("pm.dexopt.install"))).thenReturn("speed-profile");
         lenient().when(SystemProperties.get(eq("pm.dexopt.bg-dexopt"))).thenReturn("speed-profile");
@@ -128,6 +145,10 @@
         lenient().when(mAppHibernationManager.isHibernatingGlobally(any())).thenReturn(false);
         lenient().when(mAppHibernationManager.isOatArtifactDeletionEnabled()).thenReturn(true);
 
+        lenient()
+                .when(mUserManager.getUserHandles(anyBoolean()))
+                .thenReturn(List.of(UserHandle.of(0), UserHandle.of(1)));
+
         lenient().when(mPackageManagerLocal.withFilteredSnapshot()).thenReturn(mSnapshot);
         List<PackageState> pkgStates = createPackageStates();
         for (PackageState pkgState : pkgStates) {
@@ -387,6 +408,146 @@
         mArtManagerLocal.optimizePackages(mSnapshot, "bg-dexopt", cancellationSignal);
     }
 
+    @Test
+    public void testSnapshotAppProfile() throws Exception {
+        var options = new MergeProfileOptions();
+        options.forceMerge = true;
+        options.forBootImage = false;
+
+        File tempFile = File.createTempFile("primary", ".prof");
+        tempFile.deleteOnExit();
+
+        when(mArtd.mergeProfiles(
+                     deepEq(List.of(AidlUtils.buildProfilePathForPrimaryRef(PKG_NAME, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     0 /* userId */, PKG_NAME, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     1 /* userId */, PKG_NAME, "primary"))),
+                     isNull(),
+                     deepEq(AidlUtils.buildOutputProfileForPrimary(PKG_NAME, "primary",
+                             Process.SYSTEM_UID, Process.SYSTEM_UID, false /* isPublic */)),
+                     deepEq(List.of("/data/app/foo/base.apk")), deepEq(options)))
+                .thenAnswer(invocation -> {
+                    try (var writer = new FileWriter(tempFile)) {
+                        writer.write("snapshot");
+                    } catch (IOException e) {
+                        throw new RuntimeException(e);
+                    }
+                    var output = invocation.<OutputProfile>getArgument(2);
+                    output.profilePath.tmpPath = tempFile.getPath();
+                    return true;
+                });
+
+        ParcelFileDescriptor fd =
+                mArtManagerLocal.snapshotAppProfile(mSnapshot, PKG_NAME, null /* splitName */);
+
+        verify(mArtd).deleteProfile(
+                argThat(profile -> profile.getTmpProfilePath().tmpPath.equals(tempFile.getPath())));
+
+        try (InputStream inputStream = new AutoCloseInputStream(fd)) {
+            String contents = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+            assertThat(contents).isEqualTo("snapshot");
+        }
+    }
+
+    @Test
+    public void testSnapshotAppProfileSplit() throws Exception {
+        when(mArtd.mergeProfiles(deepEq(List.of(AidlUtils.buildProfilePathForPrimaryRef(
+                                                        PKG_NAME, "split_0.split"),
+                                         AidlUtils.buildProfilePathForPrimaryCur(
+                                                 0 /* userId */, PKG_NAME, "split_0.split"),
+                                         AidlUtils.buildProfilePathForPrimaryCur(
+                                                 1 /* userId */, PKG_NAME, "split_0.split"))),
+                     isNull(),
+                     deepEq(AidlUtils.buildOutputProfileForPrimary(PKG_NAME, "split_0.split",
+                             Process.SYSTEM_UID, Process.SYSTEM_UID, false /* isPublic */)),
+                     deepEq(List.of("/data/app/foo/split_0.apk")), any()))
+                .thenReturn(false);
+
+        mArtManagerLocal.snapshotAppProfile(mSnapshot, PKG_NAME, "split_0");
+    }
+
+    @Test
+    public void testSnapshotAppProfileEmpty() throws Exception {
+        when(mArtd.mergeProfiles(any(), any(), any(), any(), any())).thenReturn(false);
+
+        ParcelFileDescriptor fd =
+                mArtManagerLocal.snapshotAppProfile(mSnapshot, PKG_NAME, null /* splitName */);
+
+        verify(mArtd, never()).deleteProfile(any());
+
+        try (InputStream inputStream = new AutoCloseInputStream(fd)) {
+            assertThat(inputStream.readAllBytes()).isEmpty();
+        }
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testSnapshotAppProfilePackageNotFound() throws Exception {
+        when(mSnapshot.getPackageState(anyString())).thenReturn(null);
+
+        mArtManagerLocal.snapshotAppProfile(mSnapshot, PKG_NAME, null /* splitName */);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testSnapshotAppProfileNoPackage() throws Exception {
+        when(mPkgState.getAndroidPackage()).thenReturn(null);
+
+        mArtManagerLocal.snapshotAppProfile(mSnapshot, PKG_NAME, null /* splitName */);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testSnapshotAppProfileSplitNotFound() throws Exception {
+        mArtManagerLocal.snapshotAppProfile(mSnapshot, PKG_NAME, "non-existent-split");
+    }
+
+    @Test
+    public void testSnapshotBootImageProfile() throws Exception {
+        // `lenient()` is required to allow mocking the same method multiple times.
+        lenient().when(Constants.getenv("BOOTCLASSPATH")).thenReturn("bcp0:bcp1");
+        lenient().when(Constants.getenv("SYSTEMSERVERCLASSPATH")).thenReturn("sscp0:sscp1");
+        lenient().when(Constants.getenv("STANDALONE_SYSTEMSERVER_JARS")).thenReturn("sssj0:sssj1");
+
+        var options = new MergeProfileOptions();
+        options.forceMerge = true;
+        options.forBootImage = true;
+
+        when(mArtd.mergeProfiles(
+                     deepEq(List.of(AidlUtils.buildProfilePathForPrimaryRef("android", "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     0 /* userId */, "android", "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     1 /* userId */, "android", "primary"),
+                             AidlUtils.buildProfilePathForPrimaryRef(PKG_NAME, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     0 /* userId */, PKG_NAME, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     1 /* userId */, PKG_NAME, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryRef(PKG_NAME, "split_0.split"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     0 /* userId */, PKG_NAME, "split_0.split"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     1 /* userId */, PKG_NAME, "split_0.split"),
+                             AidlUtils.buildProfilePathForPrimaryRef(PKG_NAME_SYS_UI, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     0 /* userId */, PKG_NAME_SYS_UI, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     1 /* userId */, PKG_NAME_SYS_UI, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryRef(
+                                     PKG_NAME_HIBERNATING, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     0 /* userId */, PKG_NAME_HIBERNATING, "primary"),
+                             AidlUtils.buildProfilePathForPrimaryCur(
+                                     1 /* userId */, PKG_NAME_HIBERNATING, "primary"))),
+                     isNull(),
+                     deepEq(AidlUtils.buildOutputProfileForPrimary("android", "primary",
+                             Process.SYSTEM_UID, Process.SYSTEM_UID, false /* isPublic */)),
+                     deepEq(List.of("bcp0", "bcp1", "sscp0", "sscp1", "sssj0", "sssj1")),
+                     deepEq(options)))
+                .thenReturn(false); // A non-empty merge is tested in `testSnapshotAppProfile`.
+
+        mArtManagerLocal.snapshotBootImageProfile(mSnapshot);
+    }
+
     private AndroidPackage createPackage(boolean multiSplit) {
         AndroidPackage pkg = mock(AndroidPackage.class);
 
@@ -413,6 +574,12 @@
         return pkg;
     }
 
+    private PackageUserState createPackageUserState() {
+        PackageUserState pkgUserState = mock(PackageUserState.class);
+        lenient().when(pkgUserState.isInstalled()).thenReturn(true);
+        return pkgUserState;
+    }
+
     private PackageState createPackageState(
             String packageName, int appId, boolean hasPackage, boolean multiSplit) {
         PackageState pkgState = mock(PackageState.class);
@@ -431,6 +598,9 @@
             lenient().when(pkgState.getAndroidPackage()).thenReturn(null);
         }
 
+        PackageUserState pkgUserState = createPackageUserState();
+        lenient().when(pkgState.getStateForUser(any())).thenReturn(pkgUserState);
+
         return pkgState;
     }
 
@@ -441,7 +611,8 @@
         PackageState sysUiPkgState = createPackageState(
                 PKG_NAME_SYS_UI, 1234 /* appId */, true /* hasPackage */, false /* multiSplit */);
 
-        // This should not be optimized because it's hibernating.
+        // This should not be optimized because it's hibernating. However, it should be included
+        // when snapshotting boot image profile.
         PackageState pkgHibernatingState = createPackageState(PKG_NAME_HIBERNATING,
                 10002 /* appId */, true /* hasPackage */, false /* multiSplit */);
         lenient()
diff --git a/libartservice/service/javatests/com/android/server/art/DexUseManagerTest.java b/libartservice/service/javatests/com/android/server/art/DexUseManagerTest.java
index 7fa2dfc..2dc4b5b 100644
--- a/libartservice/service/javatests/com/android/server/art/DexUseManagerTest.java
+++ b/libartservice/service/javatests/com/android/server/art/DexUseManagerTest.java
@@ -230,6 +230,7 @@
 
         if (saveAndLoad) {
             File tempFile = File.createTempFile("dex-use", ".pb");
+            tempFile.deleteOnExit();
             mDexUseManager.save(tempFile.getPath());
             mDexUseManager.clear();
             mDexUseManager.load(tempFile.getPath());
@@ -363,6 +364,7 @@
 
         if (saveAndLoad) {
             File tempFile = File.createTempFile("dex-use", ".pb");
+            tempFile.deleteOnExit();
             mDexUseManager.save(tempFile.getPath());
             mDexUseManager.clear();
             mDexUseManager.load(tempFile.getPath());
diff --git a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
index 5347776..5b98cad 100644
--- a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
+++ b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
@@ -92,6 +92,8 @@
             | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE
             | DexoptTrigger.COMPILER_FILTER_IS_SAME | DexoptTrigger.COMPILER_FILTER_IS_WORSE;
 
+    private final MergeProfileOptions mMergeProfileOptions = new MergeProfileOptions();
+
     private final DexoptResult mDexoptResult = createDexoptResult(false /* cancelled */);
 
     private PrimaryDexOptimizer mPrimaryDexOptimizer;
@@ -282,7 +284,7 @@
         when(mPkgState.getStateForUser(eq(UserHandle.of(0)))).thenReturn(mPkgUserStateInstalled);
         when(mPkgState.getStateForUser(eq(UserHandle.of(2)))).thenReturn(mPkgUserStateInstalled);
 
-        when(mArtd.mergeProfiles(any(), any(), any(), any())).thenReturn(true);
+        when(mArtd.mergeProfiles(any(), any(), any(), any(), any())).thenReturn(true);
 
         makeProfileUsable(mRefProfile);
         when(mArtd.getProfileVisibility(deepEq(mRefProfile)))
@@ -297,7 +299,8 @@
                                        0 /* userId */, PKG_NAME, "primary"),
                         AidlUtils.buildProfilePathForPrimaryCur(
                                 2 /* userId */, PKG_NAME, "primary"))),
-                deepEq(mRefProfile), deepEq(mPrivateOutputProfile), eq(mDexPath));
+                deepEq(mRefProfile), deepEq(mPrivateOutputProfile), deepEq(List.of(mDexPath)),
+                deepEq(mMergeProfileOptions));
 
         // It should use `mBetterOrSameDexoptTrigger` and the merged profile for both ISAs.
         inOrder.verify(mArtd).getDexoptNeeded(eq(mDexPath), eq("arm64"), any(), eq("speed-profile"),
@@ -325,7 +328,7 @@
         when(mPkgState.getStateForUser(eq(UserHandle.of(0)))).thenReturn(mPkgUserStateInstalled);
         when(mPkgState.getStateForUser(eq(UserHandle.of(2)))).thenReturn(mPkgUserStateInstalled);
 
-        when(mArtd.mergeProfiles(any(), any(), any(), any())).thenReturn(false);
+        when(mArtd.mergeProfiles(any(), any(), any(), any(), any())).thenReturn(false);
 
         makeProfileUsable(mRefProfile);
         when(mArtd.getProfileVisibility(deepEq(mRefProfile)))
diff --git a/libartservice/service/javatests/com/android/server/art/SecondaryDexOptimizerTest.java b/libartservice/service/javatests/com/android/server/art/SecondaryDexOptimizerTest.java
index 250ea8e..e6e62b9 100644
--- a/libartservice/service/javatests/com/android/server/art/SecondaryDexOptimizerTest.java
+++ b/libartservice/service/javatests/com/android/server/art/SecondaryDexOptimizerTest.java
@@ -95,6 +95,8 @@
             | DexoptTrigger.COMPILER_FILTER_IS_SAME
             | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
 
+    private final MergeProfileOptions mMergeProfileOptions = new MergeProfileOptions();
+
     @Rule
     public StaticMockitoRule mockitoRule =
             new StaticMockitoRule(SystemProperties.class, Constants.class);
@@ -182,7 +184,8 @@
         // It should use profile for dex 1.
 
         verify(mArtd).mergeProfiles(deepEq(List.of(mDex1CurProfile)), deepEq(mDex1RefProfile),
-                deepEq(mDex1PrivateOutputProfile), eq(DEX_1));
+                deepEq(mDex1PrivateOutputProfile), deepEq(List.of(DEX_1)),
+                deepEq(mMergeProfileOptions));
 
         verify(mArtd).getDexoptNeeded(
                 eq(DEX_1), eq("arm64"), any(), eq("speed-profile"), eq(mBetterOrSameDexoptTrigger));
@@ -196,7 +199,7 @@
         // It should use "speed" for dex 2 for both ISAs and make the artifacts public.
 
         verify(mArtd, never()).isProfileUsable(deepEq(mDex2RefProfile), any());
-        verify(mArtd, never()).mergeProfiles(any(), deepEq(mDex2RefProfile), any(), any());
+        verify(mArtd, never()).mergeProfiles(any(), deepEq(mDex2RefProfile), any(), any(), any());
 
         verify(mArtd).getDexoptNeeded(
                 eq(DEX_2), eq("arm64"), any(), eq("speed"), eq(mDefaultDexoptTrigger));
@@ -211,7 +214,7 @@
         // It should use "verify" for dex 3 and make the artifacts private.
 
         verify(mArtd, never()).isProfileUsable(deepEq(mDex3RefProfile), any());
-        verify(mArtd, never()).mergeProfiles(any(), deepEq(mDex3RefProfile), any(), any());
+        verify(mArtd, never()).mergeProfiles(any(), deepEq(mDex3RefProfile), any(), any(), any());
 
         verify(mArtd).getDexoptNeeded(
                 eq(DEX_3), eq("arm64"), isNull(), eq("verify"), eq(mDefaultDexoptTrigger));
@@ -295,7 +298,7 @@
                 .when(mArtd.getProfileVisibility(deepEq(mDex3RefProfile)))
                 .thenReturn(FileVisibility.NOT_OTHER_READABLE);
 
-        lenient().when(mArtd.mergeProfiles(any(), any(), any(), any())).thenReturn(true);
+        lenient().when(mArtd.mergeProfiles(any(), any(), any(), any(), any())).thenReturn(true);
     }
 
     private GetDexoptNeededResult dexoptIsNeeded() {