Implement shell command "dump-profiles".

This change does not add any new API because the functionality is only
used by the shell command.

This change also changes the behavior of "snapshot-app-profile" and
"snapshot-boot-image-profile" to pave the way to eventually take over
the legacy PM shell commands.

Bug: 261564086
Test: `adb shell pm art dump-profiles` with a package that has multiple
  splits.
Test: `adb shell pm art snapshot-app-profile` with a package that has
  multiple splits.
Ignore-AOSP-First: ART Services.
Change-Id: I67435d0ba63a655e58fe1186bddea3b16e2fa23a
diff --git a/artd/artd.cc b/artd/artd.cc
index 73208a6..2e55537 100644
--- a/artd/artd.cc
+++ b/artd/artd.cc
@@ -588,6 +588,9 @@
   for (const std::string& dex_file : in_dexFiles) {
     OR_RETURN_FATAL(ValidateDexPath(dex_file));
   }
+  if (in_options.forceMerge + in_options.dumpOnly + in_options.dumpClassesAndMethods > 1) {
+    return Fatal("Only one of 'forceMerge', 'dumpOnly', and 'dumpClassesAndMethods' can be set");
+  }
 
   CmdlineBuilder args;
   FdLogger fd_logger;
@@ -622,6 +625,11 @@
       OR_RETURN_NON_FATAL(NewFile::Create(output_profile_path, in_outputProfile->fsPermission));
 
   if (in_referenceProfile.has_value()) {
+    if (in_options.forceMerge || in_options.dumpOnly || in_options.dumpClassesAndMethods) {
+      return Fatal(
+          "Reference profile must not be set when 'forceMerge', 'dumpOnly', or "
+          "'dumpClassesAndMethods' is set");
+    }
     std::string reference_profile_path =
         OR_RETURN_FATAL(BuildProfileOrDmPath(*in_referenceProfile));
     if (in_referenceProfile->getTag() == ProfilePath::dexMetadataPath) {
@@ -630,8 +638,12 @@
     OR_RETURN_NON_FATAL(CopyFile(reference_profile_path, *output_profile_file));
   }
 
-  // profman is ok with this being an empty file when in_referenceProfile isn't set.
-  args.Add("--reference-profile-file-fd=%d", output_profile_file->Fd());
+  if (in_options.dumpOnly || in_options.dumpClassesAndMethods) {
+    args.Add("--dump-output-to-fd=%d", output_profile_file->Fd());
+  } else {
+    // profman is ok with this being an empty file when in_referenceProfile isn't set.
+    args.Add("--reference-profile-file-fd=%d", output_profile_file->Fd());
+  }
   fd_logger.Add(*output_profile_file);
 
   std::vector<std::unique_ptr<File>> dex_files;
@@ -642,12 +654,16 @@
     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"))
-      .AddIf(in_options.forceMerge, "--force-merge")
-      .AddIf(in_options.forBootImage, "--boot-image-merge");
+  if (in_options.dumpOnly || in_options.dumpClassesAndMethods) {
+    args.Add(in_options.dumpOnly ? "--dump-only" : "--dump-classes-and-methods");
+  } else {
+    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"))
+        .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;
@@ -666,7 +682,9 @@
   }
 
   ProfmanResult::ProcessingResult expected_result =
-      in_options.forceMerge ? ProfmanResult::kSuccess : ProfmanResult::kCompile;
+      (in_options.forceMerge || in_options.dumpOnly || in_options.dumpClassesAndMethods) ?
+          ProfmanResult::kSuccess :
+          ProfmanResult::kCompile;
   if (result.value() != expected_result) {
     return NonFatal("profman returned an unexpected code: {}"_format(result.value()));
   }
diff --git a/artd/artd_test.cc b/artd/artd_test.cc
index e28342b..8eab8ad 100644
--- a/artd/artd_test.cc
+++ b/artd/artd_test.cc
@@ -1614,7 +1614,7 @@
   EXPECT_THAT(output_profile.profilePath.tmpPath, IsEmpty());
 }
 
-TEST_F(ArtdTest, mergeProfilesWithOptions) {
+TEST_F(ArtdTest, mergeProfilesWithOptionsForceMerge) {
   PrimaryCurProfilePath profile_0_path{
       .userId = 0, .packageName = "com.android.foo", .profileName = "primary"};
   std::string profile_0_file = OR_FATAL(BuildPrimaryCurProfilePath(profile_0_path));
@@ -1649,6 +1649,82 @@
   EXPECT_THAT(output_profile.profilePath.tmpPath, Not(IsEmpty()));
 }
 
+TEST_F(ArtdTest, mergeProfilesWithOptionsDumpOnly) {
+  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("--dump-only"),
+                                    Not(Contains(Flag("--reference-profile-file-fd=", _))))),
+                  _,
+                  _))
+      .WillOnce(DoAll(WithArg<0>(WriteToFdFlag("--dump-output-to-fd=", "dump")),
+                      Return(ProfmanResult::kSuccess)));
+
+  bool result;
+  EXPECT_TRUE(artd_
+                  ->mergeProfiles({profile_0_path},
+                                  std::nullopt,
+                                  &output_profile,
+                                  {dex_file_},
+                                  {.dumpOnly = true},
+                                  &result)
+                  .isOk());
+  EXPECT_TRUE(result);
+  EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
+  CheckContent(output_profile.profilePath.tmpPath, "dump");
+}
+
+TEST_F(ArtdTest, mergeProfilesWithOptionsDumpClassesAndMethods) {
+  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("--dump-classes-and-methods"),
+                                    Not(Contains(Flag("--reference-profile-file-fd=", _))))),
+                  _,
+                  _))
+      .WillOnce(DoAll(WithArg<0>(WriteToFdFlag("--dump-output-to-fd=", "dump")),
+                      Return(ProfmanResult::kSuccess)));
+
+  bool result;
+  EXPECT_TRUE(artd_
+                  ->mergeProfiles({profile_0_path},
+                                  std::nullopt,
+                                  &output_profile,
+                                  {dex_file_},
+                                  {.dumpClassesAndMethods = true},
+                                  &result)
+                  .isOk());
+  EXPECT_TRUE(result);
+  EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
+  CheckContent(output_profile.profilePath.tmpPath, "dump");
+}
+
 }  // namespace
 }  // namespace artd
 }  // namespace art
diff --git a/artd/binder/com/android/server/art/IArtd.aidl b/artd/binder/com/android/server/art/IArtd.aidl
index 237f8a9..4e45657 100644
--- a/artd/binder/com/android/server/art/IArtd.aidl
+++ b/artd/binder/com/android/server/art/IArtd.aidl
@@ -88,6 +88,11 @@
      * writes the merge result to `outputProfile` and fills `outputProfile.profilePath.id` and
      * `outputProfile.profilePath.tmpPath` if a merge has been performed.
      *
+     * When `options.forceMerge`, `options.dumpOnly`, or `options.dumpClassesAndMethods` is set,
+     * `referenceProfile` must not be set. I.e., all inputs must be provided by `profiles`. This is
+     * because the merge will always happen, and hence no reference profile is needed to calculate
+     * the diff.
+     *
      * Throws fatal and non-fatal errors.
      */
     boolean mergeProfiles(in List<com.android.server.art.ProfilePath> profiles,
diff --git a/artd/binder/com/android/server/art/MergeProfileOptions.aidl b/artd/binder/com/android/server/art/MergeProfileOptions.aidl
index fb7db80..2d007f9 100644
--- a/artd/binder/com/android/server/art/MergeProfileOptions.aidl
+++ b/artd/binder/com/android/server/art/MergeProfileOptions.aidl
@@ -32,4 +32,8 @@
     boolean forceMerge;
     /** --boot-image-merge */
     boolean forBootImage;
+    /** --dump-only */
+    boolean dumpOnly;
+    /** --dump-classes-and-methods */
+    boolean dumpClassesAndMethods;
 }
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index bf77989..61cfc16 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -549,6 +549,32 @@
     public ParcelFileDescriptor snapshotAppProfile(
             @NonNull PackageManagerLocal.FilteredSnapshot snapshot, @NonNull String packageName,
             @Nullable String splitName) throws SnapshotProfileException {
+        var options = new MergeProfileOptions();
+        options.forceMerge = true;
+        return snapshotOrDumpAppProfile(snapshot, packageName, splitName, options);
+    }
+
+    /**
+     * Same as above, but outputs in text format.
+     *
+     * @hide
+     */
+    @NonNull
+    public ParcelFileDescriptor dumpAppProfile(
+            @NonNull PackageManagerLocal.FilteredSnapshot snapshot, @NonNull String packageName,
+            @Nullable String splitName, boolean dumpClassesAndMethods)
+            throws SnapshotProfileException {
+        var options = new MergeProfileOptions();
+        options.dumpOnly = !dumpClassesAndMethods;
+        options.dumpClassesAndMethods = dumpClassesAndMethods;
+        return snapshotOrDumpAppProfile(snapshot, packageName, splitName, options);
+    }
+
+    @NonNull
+    private ParcelFileDescriptor snapshotOrDumpAppProfile(
+            @NonNull PackageManagerLocal.FilteredSnapshot snapshot, @NonNull String packageName,
+            @Nullable String splitName, @NonNull MergeProfileOptions options)
+            throws SnapshotProfileException {
         PackageState pkgState = Utils.getPackageStateOrThrow(snapshot, packageName);
         AndroidPackage pkg = Utils.getPackageOrThrow(pkgState);
         PrimaryDexInfo dexInfo = PrimaryDexUtils.getDexInfoBySplitName(pkg, splitName);
@@ -561,8 +587,7 @@
         OutputProfile output = PrimaryDexUtils.buildOutputProfile(
                 pkgState, dexInfo, Process.SYSTEM_UID, Process.SYSTEM_UID, false /* isPublic */);
 
-        return mergeProfilesAndGetFd(
-                profiles, output, List.of(dexInfo.dexPath()), false /* forBootImage */);
+        return mergeProfilesAndGetFd(profiles, output, List.of(dexInfo.dexPath()), options);
     }
 
     /**
@@ -619,7 +644,10 @@
                                         .flatMap(classpath -> Arrays.stream(classpath.split(":")))
                                         .collect(Collectors.toList());
 
-        return mergeProfilesAndGetFd(profiles, output, dexPaths, true /* forBootImage */);
+        var options = new MergeProfileOptions();
+        options.forceMerge = true;
+        options.forBootImage = true;
+        return mergeProfilesAndGetFd(profiles, output, dexPaths, options);
     }
 
     /**
@@ -733,13 +761,9 @@
 
     @NonNull
     private ParcelFileDescriptor mergeProfilesAndGetFd(@NonNull List<ProfilePath> profiles,
-            @NonNull OutputProfile output, @NonNull List<String> dexPaths, boolean forBootImage)
-            throws SnapshotProfileException {
+            @NonNull OutputProfile output, @NonNull List<String> dexPaths,
+            @NonNull MergeProfileOptions options) throws SnapshotProfileException {
         try {
-            var options = new MergeProfileOptions();
-            options.forceMerge = true;
-            options.forBootImage = forBootImage;
-
             boolean hasContent = false;
             try {
                 hasContent = mInjector.getArtd().mergeProfiles(
diff --git a/libartservice/service/java/com/android/server/art/ArtShellCommand.java b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
index c282e8c..378412c 100644
--- a/libartservice/service/java/com/android/server/art/ArtShellCommand.java
+++ b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
@@ -19,6 +19,7 @@
 import static android.os.ParcelFileDescriptor.AutoCloseInputStream;
 
 import static com.android.server.art.ArtManagerLocal.SnapshotProfileException;
+import static com.android.server.art.PrimaryDexUtils.PrimaryDexInfo;
 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;
@@ -31,6 +32,10 @@
 import android.os.CancellationSignal;
 import android.os.ParcelFileDescriptor;
 import android.os.Process;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.StructStat;
+import android.util.Log;
 
 import androidx.annotation.RequiresApi;
 
@@ -43,6 +48,8 @@
 import com.android.server.art.model.OptimizeParams;
 import com.android.server.art.model.OptimizeResult;
 import com.android.server.pm.PackageManagerLocal;
+import com.android.server.pm.pkg.AndroidPackage;
+import com.android.server.pm.pkg.PackageState;
 
 import libcore.io.Streams;
 
@@ -51,6 +58,8 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.PrintWriter;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.UUID;
@@ -66,6 +75,9 @@
 public final class ArtShellCommand extends BasicShellCommandHandler {
     private static final String TAG = "ArtShellCommand";
 
+    /** The default location for profile dumps. */
+    private final static String PROFILE_DEBUG_LOCATION = "/data/misc/profman";
+
     private final ArtManagerLocal mArtManagerLocal;
     private final PackageManagerLocal mPackageManagerLocal;
     private final DexUseManagerLocal mDexUseManager;
@@ -270,26 +282,66 @@
                     }
                 }
                 case "snapshot-app-profile": {
-                    String outputPath = getNextArgRequired();
+                    String packageName = getNextArgRequired();
+                    String splitName = getNextArg();
+                    String outputRelativePath = String.format("%s%s.prof", packageName,
+                            splitName != null ? String.format("-split_%s.apk", splitName) : "");
                     ParcelFileDescriptor fd;
                     try {
-                        fd = mArtManagerLocal.snapshotAppProfile(
-                                snapshot, getNextArgRequired(), getNextOption());
+                        fd = mArtManagerLocal.snapshotAppProfile(snapshot, packageName, splitName);
                     } catch (SnapshotProfileException e) {
                         throw new RuntimeException(e);
                     }
-                    writeFdContentsToFile(fd, outputPath);
+                    writeProfileFdContentsToFile(fd, outputRelativePath);
                     return 0;
                 }
                 case "snapshot-boot-image-profile": {
-                    String outputPath = getNextArgRequired();
+                    String outputRelativePath = "android.prof";
                     ParcelFileDescriptor fd;
                     try {
                         fd = mArtManagerLocal.snapshotBootImageProfile(snapshot);
                     } catch (SnapshotProfileException e) {
                         throw new RuntimeException(e);
                     }
-                    writeFdContentsToFile(fd, outputPath);
+                    writeProfileFdContentsToFile(fd, outputRelativePath);
+                    return 0;
+                }
+                case "dump-profiles": {
+                    boolean dumpClassesAndMethods = false;
+                    String opt;
+                    while ((opt = getNextOption()) != null) {
+                        switch (opt) {
+                            case "--dump-classes-and-methods": {
+                                dumpClassesAndMethods = true;
+                                break;
+                            }
+                            default:
+                                pw.println("Error: Unknown option: " + opt);
+                                return 1;
+                        }
+                    }
+                    String packageName = getNextArgRequired();
+                    PackageState pkgState = Utils.getPackageStateOrThrow(snapshot, packageName);
+                    AndroidPackage pkg = Utils.getPackageOrThrow(pkgState);
+                    for (PrimaryDexInfo dexInfo : PrimaryDexUtils.getDexInfo(pkg)) {
+                        if (!dexInfo.hasCode()) {
+                            continue;
+                        }
+                        String profileName = PrimaryDexUtils.getProfileName(dexInfo.splitName());
+                        // The path is intentionally inconsistent with the one for
+                        // "snapshot-app-profile". The is to match the behavior of the legacy PM
+                        // shell command.
+                        String outputRelativePath =
+                                String.format("%s-%s.prof.txt", packageName, profileName);
+                        ParcelFileDescriptor fd;
+                        try {
+                            fd = mArtManagerLocal.dumpAppProfile(snapshot, packageName,
+                                    dexInfo.splitName(), dumpClassesAndMethods);
+                        } catch (SnapshotProfileException e) {
+                            throw new RuntimeException(e);
+                        }
+                        writeProfileFdContentsToFile(fd, outputRelativePath);
+                    }
                     return 0;
                 }
                 default:
@@ -375,11 +427,22 @@
         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.");
+        pw.println("  snapshot-app-profile PACKAGE_NAME [SPLIT_NAME]");
+        pw.println("    Snapshot the profile of the given app and save it to");
+        pw.println("    '" + PROFILE_DEBUG_LOCATION + "'.");
+        pw.println("    If SPLIT_NAME is empty, the command is for the base APK, and the output");
+        pw.println("    filename is 'PACKAGE_NAME.prof'. Otherwise, the command is for the given");
+        pw.println("    split, and the output filename is");
+        pw.println("    'PACKAGE_NAME-split_SPLIT_NAME.apk.prof'.");
+        pw.println("  snapshot-boot-image-profile");
+        pw.println("    Snapshot the boot image profile and save it to");
+        pw.println("    '" + PROFILE_DEBUG_LOCATION + "/android.prof'.");
+        pw.println("  dump-profiles [--dump-classes-and-methods] PACKAGE_NAME");
+        pw.println("    Dump the profiles of the given app in text format and save the outputs to");
+        pw.println("    '" + PROFILE_DEBUG_LOCATION + "'.");
+        pw.println("    The profile of the base APK is dumped to 'PACKAGE_NAME-primary.prof.txt'");
+        pw.println("    The profile of a split APK is dumped to");
+        pw.println("    'PACKAGE_NAME-SPLIT_NAME.split.prof.txt'");
     }
 
     private void enforceRoot() {
@@ -423,12 +486,30 @@
         }
     }
 
-    private void writeFdContentsToFile(
-            @NonNull ParcelFileDescriptor fd, @NonNull String outputPath) {
+    private void writeProfileFdContentsToFile(
+            @NonNull ParcelFileDescriptor fd, @NonNull String outputRelativePath) {
+        try {
+            StructStat st = Os.stat(PROFILE_DEBUG_LOCATION);
+            if (st.st_uid != Process.SYSTEM_UID || st.st_gid != Process.SHELL_UID
+                    || (st.st_mode & 0007) != 0) {
+                throw new RuntimeException(
+                        String.format("%s has wrong permissions: uid=%d, gid=%d, mode=%o",
+                                PROFILE_DEBUG_LOCATION, st.st_uid, st.st_gid, st.st_mode));
+            }
+        } catch (ErrnoException e) {
+            throw new RuntimeException("Unable to stat " + PROFILE_DEBUG_LOCATION, e);
+        }
+        Path outputPath = Paths.get(PROFILE_DEBUG_LOCATION, outputRelativePath);
         try (InputStream inputStream = new AutoCloseInputStream(fd);
-                OutputStream outputStream = new FileOutputStream(outputPath)) {
+                FileOutputStream outputStream = new FileOutputStream(outputPath.toFile())) {
+            // The system server doesn't have the permission to chown the file to "shell", so we
+            // make it readable by everyone and put it in a directory that is only accessible by
+            // "shell", which is created by system/core/rootdir/init.rc. The permissions are
+            // verified by the code above.
+            Os.fchmod(outputStream.getFD(), 0644);
             Streams.copy(inputStream, outputStream);
-        } catch (IOException e) {
+        } catch (IOException | ErrnoException e) {
+            Utils.deleteIfExistsSafe(outputPath);
             throw new RuntimeException(e);
         }
     }
diff --git a/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java b/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java
index 5af9f0a..ada6488 100644
--- a/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java
+++ b/libartservice/service/java/com/android/server/art/PrimaryDexUtils.java
@@ -324,7 +324,7 @@
     }
 
     @NonNull
-    private static String getProfileName(@Nullable String splitName) {
+    public static String getProfileName(@Nullable String splitName) {
         return splitName == null ? "primary" : splitName + ".split";
     }
 
diff --git a/libartservice/service/java/com/android/server/art/Utils.java b/libartservice/service/java/com/android/server/art/Utils.java
index 64a33ab..f104f98 100644
--- a/libartservice/service/java/com/android/server/art/Utils.java
+++ b/libartservice/service/java/com/android/server/art/Utils.java
@@ -23,6 +23,7 @@
 import android.os.SystemProperties;
 import android.os.UserManager;
 import android.text.TextUtils;
+import android.util.Log;
 import android.util.SparseArray;
 
 import com.android.server.art.model.OptimizeParams;
@@ -35,6 +36,9 @@
 
 import com.google.auto.value.AutoValue;
 
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -47,6 +51,7 @@
 
 /** @hide */
 public final class Utils {
+    public static final String TAG = "ArtServiceUtils";
     public static final String PLATFORM_PACKAGE_NAME = "android";
 
     private Utils() {}
@@ -284,6 +289,14 @@
         return Math.max(lastUsedAtMs, lastFirstInstallTimeMs);
     }
 
+    public static void deleteIfExistsSafe(@NonNull Path path) {
+        try {
+            Files.deleteIfExists(path);
+        } catch (IOException e) {
+            Log.e(TAG, "Failed to delete file '" + path + "'", e);
+        }
+    }
+
     @AutoValue
     public abstract static class Abi {
         static @NonNull Abi create(
diff --git a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
index 44b0498..f23644e 100644
--- a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
+++ b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
@@ -641,6 +641,30 @@
     }
 
     @Test
+    public void testDumpAppProfile() throws Exception {
+        var options = new MergeProfileOptions();
+        options.dumpOnly = true;
+
+        when(mArtd.mergeProfiles(any(), isNull(), any(), any(), deepEq(options)))
+                .thenReturn(false); // A non-empty merge is tested in `testSnapshotAppProfile`.
+
+        ParcelFileDescriptor fd = mArtManagerLocal.dumpAppProfile(
+                mSnapshot, PKG_NAME, null /* splitName */, false /* dumpClassesAndMethods */);
+    }
+
+    @Test
+    public void testDumpAppProfileDumpClassesAndMethods() throws Exception {
+        var options = new MergeProfileOptions();
+        options.dumpClassesAndMethods = true;
+
+        when(mArtd.mergeProfiles(any(), isNull(), any(), any(), deepEq(options)))
+                .thenReturn(false); // A non-empty merge is tested in `testSnapshotAppProfile`.
+
+        ParcelFileDescriptor fd = mArtManagerLocal.dumpAppProfile(
+                mSnapshot, PKG_NAME, null /* splitName */, true /* dumpClassesAndMethods */);
+    }
+
+    @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");