Support profile merging.

Bug: 248318911
Test: m test-art-host-gtest-art_artd_tests
Test: atest ArtServiceTests
Ignore-AOSP-First: ART Services.
Change-Id: I0a6cb02ae31593f68648500e838882236881d349
diff --git a/artd/artd.cc b/artd/artd.cc
index 20dbe16..bf2f7b3 100644
--- a/artd/artd.cc
+++ b/artd/artd.cc
@@ -16,6 +16,7 @@
 
 #include "artd.h"
 
+#include <fcntl.h>
 #include <stdlib.h>
 #include <sys/stat.h>
 #include <sys/types.h>
@@ -90,9 +91,11 @@
 using ::android::base::Error;
 using ::android::base::Join;
 using ::android::base::make_scope_guard;
+using ::android::base::ReadFileToString;
 using ::android::base::Result;
 using ::android::base::Split;
 using ::android::base::StringReplace;
+using ::android::base::WriteStringToFd;
 using ::art::tools::CmdlineBuilder;
 using ::ndk::ScopedAStatus;
 
@@ -287,6 +290,24 @@
   return static_cast<ArtdCancellationSignal*>(input);
 }
 
+Result<void> CopyFile(const std::string& src_path, const NewFile& dst_file) {
+  std::string content;
+  if (!ReadFileToString(src_path, &content)) {
+    return Errorf("Failed to read file '{}': {}", src_path, strerror(errno));
+  }
+  if (!WriteStringToFd(content, dst_file.Fd())) {
+    return Errorf("Failed to write file '{}': {}", dst_file.TempPath(), strerror(errno));
+  }
+  if (fsync(dst_file.Fd()) != 0) {
+    return Errorf("Failed to flush file '{}': {}", dst_file.TempPath(), strerror(errno));
+  }
+  if (lseek(dst_file.Fd(), /*offset=*/0, SEEK_SET) != 0) {
+    return Errorf(
+        "Failed to reset the offset for file '{}': {}", dst_file.TempPath(), strerror(errno));
+  }
+  return {};
+}
+
 class FdLogger {
  public:
   void Add(const NewFile& file) { fd_mapping_.emplace_back(file.Fd(), file.TempPath()); }
@@ -403,14 +424,16 @@
   args.Add("--apk-fd=%d", dex_file->Fd());
   fd_logger.Add(*dex_file);
 
-  LOG(DEBUG) << "Running profman: " << Join(args.Get(), /*separator=*/" ")
-             << "\nOpened FDs: " << fd_logger;
+  LOG(INFO) << "Running profman: " << Join(args.Get(), /*separator=*/" ")
+            << "\nOpened FDs: " << fd_logger;
 
   Result<int> result = ExecAndReturnCode(args.Get(), kShortTimeoutSec);
   if (!result.ok()) {
     return NonFatal("Failed to run profman: " + result.error().message());
   }
 
+  LOG(INFO) << "profman returned code {}"_format(result.value());
+
   if (result.value() != ProfmanResult::kSkipCompilationSmallDelta &&
       result.value() != ProfmanResult::kSkipCompilationEmptyProfiles) {
     return NonFatal("profman returned an unexpected code: {}"_format(result.value()));
@@ -456,14 +479,16 @@
   args.Add("--reference-profile-file-fd=%d", dst->Fd());
   fd_logger.Add(*dst);
 
-  LOG(DEBUG) << "Running profman: " << Join(args.Get(), /*separator=*/" ")
-             << "\nOpened FDs: " << fd_logger;
+  LOG(INFO) << "Running profman: " << Join(args.Get(), /*separator=*/" ")
+            << "\nOpened FDs: " << fd_logger;
 
   Result<int> result = ExecAndReturnCode(args.Get(), kShortTimeoutSec);
   if (!result.ok()) {
     return NonFatal("Failed to run profman: " + result.error().message());
   }
 
+  LOG(INFO) << "profman returned code {}"_format(result.value());
+
   if (result.value() == ProfmanResult::kCopyAndUpdateNoMatch) {
     *_aidl_return = false;
     return ScopedAStatus::ok();
@@ -497,7 +522,7 @@
   std::string profile_path = OR_RETURN_FATAL(BuildProfileOrDmPath(in_profile));
 
   std::error_code ec;
-  if (!std::filesystem::remove(profile_path, ec)) {
+  if (!std::filesystem::remove(profile_path, ec) && ec.value() != ENOENT) {
     LOG(ERROR) << "Failed to remove '{}': {}"_format(profile_path, ec.message());
   }
 
@@ -518,6 +543,103 @@
   return ScopedAStatus::ok();
 }
 
+ndk::ScopedAStatus Artd::mergeProfiles(const std::vector<ProfilePath>& in_profiles,
+                                       const std::optional<ProfilePath>& in_referenceProfile,
+                                       OutputProfile* in_outputProfile,
+                                       const std::string& in_dexFile,
+                                       bool* _aidl_return) {
+  std::vector<std::string> profile_paths;
+  for (const ProfilePath& profile : in_profiles) {
+    std::string profile_path = OR_RETURN_FATAL(BuildProfileOrDmPath(profile));
+    if (profile.getTag() == ProfilePath::dexMetadataPath) {
+      return Fatal("Does not support DM file, got '{}'"_format(profile_path));
+    }
+    profile_paths.push_back(std::move(profile_path));
+  }
+  std::string output_profile_path =
+      OR_RETURN_FATAL(BuildRefProfilePath(in_outputProfile->profilePath.refProfilePath));
+  OR_RETURN_FATAL(ValidateDexPath(in_dexFile));
+
+  CmdlineBuilder args;
+  FdLogger fd_logger;
+  args.Add(OR_RETURN_FATAL(GetArtExec()))
+      .Add("--drop-capabilities")
+      .Add("--")
+      .Add(OR_RETURN_FATAL(GetProfman()));
+
+  std::vector<std::unique_ptr<File>> profile_files;
+  for (const std::string& profile_path : profile_paths) {
+    Result<std::unique_ptr<File>> profile_file = OpenFileForReading(profile_path);
+    if (!profile_file.ok()) {
+      if (profile_file.error().code() == ENOENT) {
+        // Skip non-existing file.
+        continue;
+      }
+      return NonFatal(
+          "Failed to open profile '{}': {}"_format(profile_path, profile_file.error().message()));
+    }
+    args.Add("--profile-file-fd=%d", profile_file.value()->Fd());
+    fd_logger.Add(*profile_file.value());
+    profile_files.push_back(std::move(profile_file.value()));
+  }
+
+  if (profile_files.empty()) {
+    LOG(INFO) << "Merge skipped because there are no existing profiles";
+    *_aidl_return = false;
+    return ScopedAStatus::ok();
+  }
+
+  std::unique_ptr<NewFile> output_profile_file =
+      OR_RETURN_NON_FATAL(NewFile::Create(output_profile_path, in_outputProfile->fsPermission));
+
+  if (in_referenceProfile.has_value()) {
+    std::string reference_profile_path =
+        OR_RETURN_FATAL(BuildProfileOrDmPath(*in_referenceProfile));
+    if (in_referenceProfile->getTag() == ProfilePath::dexMetadataPath) {
+      return Fatal("Does not support DM file, got '{}'"_format(reference_profile_path));
+    }
+    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());
+  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);
+
+  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"));
+
+  LOG(INFO) << "Running profman: " << Join(args.Get(), /*separator=*/" ")
+            << "\nOpened FDs: " << fd_logger;
+
+  Result<int> result = ExecAndReturnCode(args.Get(), kShortTimeoutSec);
+  if (!result.ok()) {
+    return NonFatal("Failed to run profman: " + result.error().message());
+  }
+
+  LOG(INFO) << "profman returned code {}"_format(result.value());
+
+  if (result.value() == ProfmanResult::kSkipCompilationSmallDelta ||
+      result.value() == ProfmanResult::kSkipCompilationEmptyProfiles) {
+    *_aidl_return = false;
+    return ScopedAStatus::ok();
+  }
+
+  if (result.value() != ProfmanResult::kCompile) {
+    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();
+  return ScopedAStatus::ok();
+}
+
 ndk::ScopedAStatus Artd::getDexoptNeeded(const std::string& in_dexFile,
                                          const std::string& in_instructionSet,
                                          const std::string& in_classLoaderContext,
@@ -709,6 +831,9 @@
     }
     return NonFatal("Failed to run dex2oat: " + result.error().message());
   }
+
+  LOG(INFO) << "dex2oat returned code {}"_format(result.value());
+
   if (result.value() != 0) {
     return NonFatal("dex2oat returned an unexpected code: %d"_format(result.value()));
   }
diff --git a/artd/artd.h b/artd/artd.h
index dd4b022..9c65ed2 100644
--- a/artd/artd.h
+++ b/artd/artd.h
@@ -104,6 +104,13 @@
       const aidl::com::android::server::art::ProfilePath& in_profile,
       aidl::com::android::server::art::FileVisibility* _aidl_return) override;
 
+  ndk::ScopedAStatus mergeProfiles(
+      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,
+      bool* _aidl_return) override;
+
   ndk::ScopedAStatus getArtifactsVisibility(
       const aidl::com::android::server::art::ArtifactsPath& in_artifactsPath,
       aidl::com::android::server::art::FileVisibility* _aidl_return) override;
diff --git a/artd/artd_test.cc b/artd/artd_test.cc
index 63d13c2..f6b24a4 100644
--- a/artd/artd_test.cc
+++ b/artd/artd_test.cc
@@ -16,7 +16,9 @@
 
 #include "artd.h"
 
+#include <fcntl.h>
 #include <sys/types.h>
+#include <unistd.h>
 
 #include <algorithm>
 #include <chrono>
@@ -70,6 +72,7 @@
 using ::android::base::Error;
 using ::android::base::make_scope_guard;
 using ::android::base::ParseInt;
+using ::android::base::ReadFdToString;
 using ::android::base::ReadFileToString;
 using ::android::base::Result;
 using ::android::base::ScopeGuard;
@@ -94,6 +97,7 @@
 using ::testing::SetArgPointee;
 using ::testing::WithArg;
 
+using CurProfilePath = ProfilePath::CurProfilePath;
 using RefProfilePath = ProfilePath::RefProfilePath;
 using TmpRefProfilePath = ProfilePath::TmpRefProfilePath;
 
@@ -112,13 +116,21 @@
   EXPECT_EQ(actual_content, expected_content);
 }
 
-// Writes `content` to the FD specified by the `flag`.
-ACTION_P(WriteToFdFlag, flag, content) {
-  for (const std::string& arg : arg0) {
+void WriteToFdFlagImpl(const std::vector<std::string> args,
+                       const std::string& flag,
+                       const std::string& content,
+                       bool assume_empty) {
+  for (const std::string& arg : args) {
     std::string_view value(arg);
     if (android::base::ConsumePrefix(&value, flag)) {
       int fd;
       ASSERT_TRUE(ParseInt(std::string(value), &fd));
+      if (assume_empty) {
+        ASSERT_EQ(lseek(fd, /*offset=*/0, SEEK_CUR), 0);
+      } else {
+        ASSERT_EQ(ftruncate(fd, /*length=*/0), 0);
+        ASSERT_EQ(lseek(fd, /*offset=*/0, SEEK_SET), 0);
+      }
       ASSERT_TRUE(WriteStringToFd(content, fd));
       return;
     }
@@ -126,6 +138,16 @@
   FAIL() << "Flag '{}' not found"_format(flag);
 }
 
+// Writes `content` to the FD specified by the `flag`.
+ACTION_P(WriteToFdFlag, flag, content) {
+  WriteToFdFlagImpl(arg0, flag, content, /*assume_empty=*/true);
+}
+
+// Clears any existing content and writes `content` to the FD specified by the `flag`.
+ACTION_P(ClearAndWriteToFdFlag, flag, content) {
+  WriteToFdFlagImpl(arg0, flag, content, /*assume_empty=*/false);
+}
+
 // Matches a flag that starts with `flag` and whose value matches `matcher`.
 MATCHER_P2(Flag, flag, matcher, "") {
   std::string_view value(arg);
@@ -159,6 +181,19 @@
   return ExplainMatchResult(matcher, std::string(path, static_cast<size_t>(len)), result_listener);
 }
 
+// Matches an FD of a file whose content matches `matcher`.
+MATCHER_P(FdHasContent, matcher, "") {
+  int fd;
+  if (!ParseInt(arg, &fd)) {
+    return false;
+  }
+  std::string actual_content;
+  if (!ReadFdToString(fd, &actual_content)) {
+    return false;
+  }
+  return ExplainMatchResult(matcher, actual_content, result_listener);
+}
+
 // Matches a container that, when split by `separator`, the first part matches `head_matcher`, and
 // the second part matches `tail_matcher`.
 MATCHER_P3(WhenSplitBy, separator, head_matcher, tail_matcher, "") {
@@ -259,10 +294,14 @@
     clc_2_ = GetTestDexFileName("Nested");
     class_loader_context_ = "PCL[{}:{}]"_format(clc_1_, clc_2_);
     compiler_filter_ = "speed";
-    profile_path_ =
-        TmpRefProfilePath{.refProfilePath = RefProfilePath{.packageName = "com.android.foo",
-                                                           .profileName = "primary"},
-                          .id = "12345"};
+    TmpRefProfilePath tmp_ref_profile_path{
+        .refProfilePath =
+            RefProfilePath{.packageName = "com.android.foo", .profileName = "primary"},
+        .id = "12345"};
+    profile_path_ = tmp_ref_profile_path;
+    std::filesystem::create_directories(
+        std::filesystem::path(OR_FATAL(BuildTmpRefProfilePath(tmp_ref_profile_path)))
+            .parent_path());
   }
 
   void TearDown() override {
@@ -298,7 +337,7 @@
   void CreateFile(const std::string& filename, const std::string& content = "") {
     std::filesystem::path path(filename);
     std::filesystem::create_directories(path.parent_path());
-    WriteStringToFile(content, filename);
+    ASSERT_TRUE(WriteStringToFile(content, filename));
   }
 
   std::shared_ptr<Artd> artd_;
@@ -351,9 +390,9 @@
 TEST_F(ArtdTest, deleteArtifacts) {
   std::string oat_dir = scratch_path_ + "/a/oat/arm64";
   std::filesystem::create_directories(oat_dir);
-  WriteStringToFile("abcd", oat_dir + "/b.odex");  // 4 bytes.
-  WriteStringToFile("ab", oat_dir + "/b.vdex");    // 2 bytes.
-  WriteStringToFile("a", oat_dir + "/b.art");      // 1 byte.
+  ASSERT_TRUE(WriteStringToFile("abcd", oat_dir + "/b.odex"));  // 4 bytes.
+  ASSERT_TRUE(WriteStringToFile("ab", oat_dir + "/b.vdex"));    // 2 bytes.
+  ASSERT_TRUE(WriteStringToFile("a", oat_dir + "/b.art"));      // 1 byte.
 
   int64_t result = -1;
   EXPECT_TRUE(artd_->deleteArtifacts(artifacts_path_, &result).isOk());
@@ -368,8 +407,8 @@
   // Missing VDEX file.
   std::string oat_dir = android_data_ + "/dalvik-cache/arm64";
   std::filesystem::create_directories(oat_dir);
-  WriteStringToFile("abcd", oat_dir + "/a@b.apk@classes.dex");  // 4 bytes.
-  WriteStringToFile("a", oat_dir + "/a@b.apk@classes.art");     // 1 byte.
+  ASSERT_TRUE(WriteStringToFile("abcd", oat_dir + "/a@b.apk@classes.dex"));  // 4 bytes.
+  ASSERT_TRUE(WriteStringToFile("a", oat_dir + "/a@b.apk@classes.art"));     // 1 byte.
 
   auto scoped_set_logger = ScopedSetLogger(mock_logger_.AsStdFunction());
   EXPECT_CALL(mock_logger_, Call(_, _, _, _, _, HasSubstr("Failed to get the file size"))).Times(0);
@@ -402,9 +441,9 @@
 TEST_F(ArtdTest, deleteArtifactsPermissionDenied) {
   std::string oat_dir = scratch_path_ + "/a/oat/arm64";
   std::filesystem::create_directories(oat_dir);
-  WriteStringToFile("abcd", oat_dir + "/b.odex");  // 4 bytes.
-  WriteStringToFile("ab", oat_dir + "/b.vdex");    // 2 bytes.
-  WriteStringToFile("a", oat_dir + "/b.art");      // 1 byte.
+  ASSERT_TRUE(WriteStringToFile("abcd", oat_dir + "/b.odex"));  // 4 bytes.
+  ASSERT_TRUE(WriteStringToFile("ab", oat_dir + "/b.vdex"));    // 2 bytes.
+  ASSERT_TRUE(WriteStringToFile("a", oat_dir + "/b.art"));      // 1 byte.
 
   auto scoped_set_logger = ScopedSetLogger(mock_logger_.AsStdFunction());
   EXPECT_CALL(mock_logger_, Call(_, _, _, _, _, HasSubstr("Failed to get the file size"))).Times(3);
@@ -422,8 +461,8 @@
   std::string oat_dir = scratch_path_ + "/a/oat/arm64";
   std::filesystem::create_directories(oat_dir);
   std::filesystem::create_directories(oat_dir + "/b.vdex");
-  WriteStringToFile("abcd", oat_dir + "/b.odex");  // 4 bytes.
-  WriteStringToFile("a", oat_dir + "/b.art");      // 1 byte.
+  ASSERT_TRUE(WriteStringToFile("abcd", oat_dir + "/b.odex"));  // 4 bytes.
+  ASSERT_TRUE(WriteStringToFile("a", oat_dir + "/b.art"));      // 1 byte.
 
   auto scoped_set_logger = ScopedSetLogger(mock_logger_.AsStdFunction());
   EXPECT_CALL(mock_logger_,
@@ -1092,6 +1131,11 @@
   EXPECT_FALSE(std::filesystem::exists(profile_file));
 }
 
+TEST_F(ArtdTest, deleteProfileDoesNotExist) {
+  std::string profile_file = OR_FATAL(BuildProfileOrDmPath(profile_path_.value()));
+  EXPECT_TRUE(artd_->deleteProfile(profile_path_.value()).isOk());
+}
+
 TEST_F(ArtdTest, deleteProfileFailed) {
   auto scoped_set_logger = ScopedSetLogger(mock_logger_.AsStdFunction());
   EXPECT_CALL(
@@ -1186,6 +1230,123 @@
   EXPECT_THAT(status.getMessage(), ContainsRegex(R"re(Failed to get status of .*b\.odex)re"));
 }
 
+TEST_F(ArtdTest, mergeProfiles) {
+  const TmpRefProfilePath& reference_profile_path =
+      profile_path_->get<ProfilePath::tmpRefProfilePath>();
+  std::string reference_profile_file = OR_FATAL(BuildTmpRefProfilePath(reference_profile_path));
+  CreateFile(reference_profile_file, "abc");
+
+  // Doesn't exist.
+  CurProfilePath profile_0_path{
+      .userId = 0, .packageName = "com.android.foo", .profileName = "primary"};
+  std::string profile_0_file = OR_FATAL(BuildCurProfilePath(profile_0_path));
+
+  CurProfilePath profile_1_path{
+      .userId = 1, .packageName = "com.android.foo", .profileName = "primary"};
+  std::string profile_1_file = OR_FATAL(BuildCurProfilePath(profile_1_path));
+  CreateFile(profile_1_file, "def");
+
+  OutputProfile output_profile{.profilePath = reference_profile_path,
+                               .fsPermission = FsPermission{.uid = -1, .gid = -1}};
+  output_profile.profilePath.id = "";
+
+  CreateFile(dex_file_);
+
+  EXPECT_CALL(
+      *mock_exec_utils_,
+      DoExecAndReturnCode(
+          WhenSplitBy("--",
+                      AllOf(Contains(art_root_ + "/bin/art_exec"), Contains("--drop-capabilities")),
+                      AllOf(Contains(art_root_ + "/bin/profman"),
+                            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_))))),
+          _,
+          _))
+      .WillOnce(DoAll(WithArg<0>(ClearAndWriteToFdFlag("--reference-profile-file-fd=", "merged")),
+                      Return(ProfmanResult::kCompile)));
+
+  bool result;
+  EXPECT_TRUE(artd_
+                  ->mergeProfiles({profile_0_path, profile_1_path},
+                                  reference_profile_path,
+                                  &output_profile,
+                                  dex_file_,
+                                  &result)
+                  .isOk());
+  EXPECT_TRUE(result);
+  EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
+  CheckContent(OR_FATAL(BuildTmpRefProfilePath(output_profile.profilePath)), "merged");
+}
+
+TEST_F(ArtdTest, mergeProfilesEmptyReferenceProfile) {
+  CurProfilePath profile_0_path{
+      .userId = 0, .packageName = "com.android.foo", .profileName = "primary"};
+  std::string profile_0_file = OR_FATAL(BuildCurProfilePath(profile_0_path));
+  CreateFile(profile_0_file, "def");
+
+  OutputProfile output_profile{.profilePath = profile_path_->get<ProfilePath::tmpRefProfilePath>(),
+                               .fsPermission = FsPermission{.uid = -1, .gid = -1}};
+  output_profile.profilePath.id = "";
+
+  CreateFile(dex_file_);
+
+  EXPECT_CALL(
+      *mock_exec_utils_,
+      DoExecAndReturnCode(
+          WhenSplitBy("--",
+                      AllOf(Contains(art_root_ + "/bin/art_exec"), Contains("--drop-capabilities")),
+                      AllOf(Contains(art_root_ + "/bin/profman"),
+                            Contains(Flag("--profile-file-fd=", FdOf(profile_0_file))),
+                            Contains(Flag("--reference-profile-file-fd=", FdHasContent(""))),
+                            Contains(Flag("--apk-fd=", FdOf(dex_file_))))),
+          _,
+          _))
+      .WillOnce(DoAll(WithArg<0>(WriteToFdFlag("--reference-profile-file-fd=", "merged")),
+                      Return(ProfmanResult::kCompile)));
+
+  bool result;
+  EXPECT_TRUE(
+      artd_->mergeProfiles({profile_0_path}, std::nullopt, &output_profile, dex_file_, &result)
+          .isOk());
+  EXPECT_TRUE(result);
+  EXPECT_THAT(output_profile.profilePath.id, Not(IsEmpty()));
+  CheckContent(OR_FATAL(BuildTmpRefProfilePath(output_profile.profilePath)), "merged");
+}
+
+TEST_F(ArtdTest, mergeProfilesProfilesDontExist) {
+  const TmpRefProfilePath& reference_profile_path =
+      profile_path_->get<ProfilePath::tmpRefProfilePath>();
+  std::string reference_profile_file = OR_FATAL(BuildTmpRefProfilePath(reference_profile_path));
+  CreateFile(reference_profile_file, "abc");
+
+  // Doesn't exist.
+  CurProfilePath profile_0_path{
+      .userId = 0, .packageName = "com.android.foo", .profileName = "primary"};
+  std::string profile_0_file = OR_FATAL(BuildCurProfilePath(profile_0_path));
+
+  // Doesn't exist.
+  CurProfilePath profile_1_path{
+      .userId = 1, .packageName = "com.android.foo", .profileName = "primary"};
+  std::string profile_1_file = OR_FATAL(BuildCurProfilePath(profile_1_path));
+
+  OutputProfile output_profile{.profilePath = reference_profile_path,
+                               .fsPermission = FsPermission{.uid = -1, .gid = -1}};
+  output_profile.profilePath.id = "";
+
+  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_FALSE(result);
+  EXPECT_THAT(output_profile.profilePath.id, IsEmpty());
+}
+
 }  // 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 04de116..7bed2ff 100644
--- a/artd/binder/com/android/server/art/IArtd.aidl
+++ b/artd/binder/com/android/server/art/IArtd.aidl
@@ -63,7 +63,7 @@
     void commitTmpProfile(in com.android.server.art.ProfilePath.TmpRefProfilePath profile);
 
     /**
-     * Deletes the profile.
+     * Deletes the profile. Does nothing of the profile doesn't exist.
      *
      * Operates on the whole DM file if given one.
      *
@@ -82,6 +82,19 @@
             in com.android.server.art.ProfilePath profile);
 
     /**
+     * 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.
+     *
+     * 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);
+
+    /**
      * Returns the visibility of the artifacts.
      *
      * Throws fatal and non-fatal errors.
diff --git a/artd/binder/com/android/server/art/ProfilePath.aidl b/artd/binder/com/android/server/art/ProfilePath.aidl
index e12d94a..afd0fd6 100644
--- a/artd/binder/com/android/server/art/ProfilePath.aidl
+++ b/artd/binder/com/android/server/art/ProfilePath.aidl
@@ -25,6 +25,7 @@
     RefProfilePath refProfilePath;
     TmpRefProfilePath tmpRefProfilePath;
     PrebuiltProfilePath prebuiltProfilePath;
+    CurProfilePath curProfilePath;
     /** Represents a profile in the dex metadata file. */
     com.android.server.art.DexMetadataPath dexMetadataPath;
 
@@ -54,4 +55,14 @@
         /** The path to the dex file that the profile is next to. */
         @utf8InCpp String dexPath;
     }
+
+    /** Represents a current profile. */
+    parcelable CurProfilePath {
+        /** The user ID of the user that owns the profile. */
+        int userId;
+        /** The name of the package. */
+        @utf8InCpp String packageName;
+        /** The stem of the profile file */
+        @utf8InCpp String profileName;
+    }
 }
diff --git a/artd/path_utils.cc b/artd/path_utils.cc
index 0cebdfb..8314241 100644
--- a/artd/path_utils.cc
+++ b/artd/path_utils.cc
@@ -43,6 +43,7 @@
 
 using ::fmt::literals::operator""_format;  // NOLINT
 
+using CurProfilePath = ProfilePath::CurProfilePath;
 using PrebuiltProfilePath = ProfilePath::PrebuiltProfilePath;
 using RefProfilePath = ProfilePath::RefProfilePath;
 using TmpRefProfilePath = ProfilePath::TmpRefProfilePath;
@@ -164,6 +165,15 @@
   return prebuilt_profile_path.dexPath + ".prof";
 }
 
+Result<std::string> BuildCurProfilePath(const CurProfilePath& cur_profile_path) {
+  OR_RETURN(ValidatePathElement(cur_profile_path.packageName, "packageName"));
+  OR_RETURN(ValidatePathElementSubstring(cur_profile_path.profileName, "profileName"));
+  return "{}/misc/profiles/cur/{}/{}/{}.prof"_format(OR_RETURN(GetAndroidDataOrError()),
+                                                     cur_profile_path.userId,
+                                                     cur_profile_path.packageName,
+                                                     cur_profile_path.profileName);
+}
+
 Result<std::string> BuildDexMetadataPath(const DexMetadataPath& dex_metadata_path) {
   OR_RETURN(ValidateDexPath(dex_metadata_path.dexPath));
   return ReplaceFileExtension(dex_metadata_path.dexPath, "dm");
@@ -182,6 +192,8 @@
       return BuildTmpRefProfilePath(profile_path.get<ProfilePath::tmpRefProfilePath>());
     case ProfilePath::prebuiltProfilePath:
       return BuildPrebuiltProfilePath(profile_path.get<ProfilePath::prebuiltProfilePath>());
+    case ProfilePath::curProfilePath:
+      return BuildCurProfilePath(profile_path.get<ProfilePath::curProfilePath>());
     case ProfilePath::dexMetadataPath:
       return BuildDexMetadataPath(profile_path.get<ProfilePath::dexMetadataPath>());
       // No default. All cases should be explicitly handled, or the compilation will fail.
diff --git a/artd/path_utils.h b/artd/path_utils.h
index f3a6359..5b2b1d3 100644
--- a/artd/path_utils.h
+++ b/artd/path_utils.h
@@ -51,6 +51,9 @@
 android::base::Result<std::string> BuildPrebuiltProfilePath(
     const aidl::com::android::server::art::ProfilePath::PrebuiltProfilePath& prebuilt_profile_path);
 
+android::base::Result<std::string> BuildCurProfilePath(
+    const aidl::com::android::server::art::ProfilePath::CurProfilePath& cur_profile_path);
+
 android::base::Result<std::string> BuildDexMetadataPath(
     const aidl::com::android::server::art::DexMetadataPath& dex_metadata_path);
 
diff --git a/artd/path_utils_test.cc b/artd/path_utils_test.cc
index 29a9cb8..8188000 100644
--- a/artd/path_utils_test.cc
+++ b/artd/path_utils_test.cc
@@ -33,6 +33,7 @@
 using ::android::base::testing::HasValue;
 using ::android::base::testing::WithMessage;
 
+using CurProfilePath = ProfilePath::CurProfilePath;
 using PrebuiltProfilePath = ProfilePath::PrebuiltProfilePath;
 using RefProfilePath = ProfilePath::RefProfilePath;
 using TmpRefProfilePath = ProfilePath::TmpRefProfilePath;
@@ -186,6 +187,12 @@
               HasValue("/a/b.apk.prof"));
 }
 
+TEST_F(PathUtilsTest, BuildCurProfilePath) {
+  EXPECT_THAT(BuildCurProfilePath(CurProfilePath{
+                  .userId = 1, .packageName = "com.android.foo", .profileName = "primary"}),
+              HasValue(android_data_ + "/misc/profiles/cur/1/com.android.foo/primary.prof"));
+}
+
 TEST_F(PathUtilsTest, BuildDexMetadataPath) {
   EXPECT_THAT(BuildDexMetadataPath(DexMetadataPath{.dexPath = "/a/b.apk"}), HasValue("/a/b.dm"));
 }
@@ -207,6 +214,9 @@
       HasValue(android_data_ + "/misc/profiles/ref/com.android.foo/primary.prof.12345.tmp"));
   EXPECT_THAT(BuildProfileOrDmPath(PrebuiltProfilePath{.dexPath = "/a/b.apk"}),
               HasValue("/a/b.apk.prof"));
+  EXPECT_THAT(BuildProfileOrDmPath(CurProfilePath{
+                  .userId = 1, .packageName = "com.android.foo", .profileName = "primary"}),
+              HasValue(android_data_ + "/misc/profiles/cur/1/com.android.foo/primary.prof"));
   EXPECT_THAT(BuildProfileOrDmPath(DexMetadataPath{.dexPath = "/a/b.apk"}), HasValue("/a/b.dm"));
 }
 
diff --git a/libartservice/service/java/com/android/server/art/AidlUtils.java b/libartservice/service/java/com/android/server/art/AidlUtils.java
index b28e1ff..26d5df8 100644
--- a/libartservice/service/java/com/android/server/art/AidlUtils.java
+++ b/libartservice/service/java/com/android/server/art/AidlUtils.java
@@ -18,6 +18,7 @@
 
 import static com.android.server.art.OutputArtifacts.PermissionSettings;
 import static com.android.server.art.OutputArtifacts.PermissionSettings.SeContext;
+import static com.android.server.art.ProfilePath.CurProfilePath;
 import static com.android.server.art.ProfilePath.PrebuiltProfilePath;
 import static com.android.server.art.ProfilePath.RefProfilePath;
 import static com.android.server.art.ProfilePath.TmpRefProfilePath;
@@ -109,6 +110,16 @@
     }
 
     @NonNull
+    public static ProfilePath buildProfilePathForCur(
+            int userId, @NonNull String packageName, @NonNull String profileName) {
+        var curProfilePath = new CurProfilePath();
+        curProfilePath.userId = userId;
+        curProfilePath.packageName = packageName;
+        curProfilePath.profileName = profileName;
+        return ProfilePath.curProfilePath(curProfilePath);
+    }
+
+    @NonNull
     public static OutputProfile buildOutputProfile(@NonNull String packageName,
             @NonNull String profileName, int uid, int gid, boolean isPublic) {
         var outputProfile = new OutputProfile();
diff --git a/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java b/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
index fa2af7a..cb7e9ff 100644
--- a/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
+++ b/libartservice/service/java/com/android/server/art/PrimaryDexOptimizer.java
@@ -36,6 +36,7 @@
 import android.os.ServiceSpecificException;
 import android.os.SystemProperties;
 import android.os.UserHandle;
+import android.os.UserManager;
 import android.text.TextUtils;
 import android.util.Log;
 import android.util.Pair;
@@ -46,6 +47,7 @@
 import com.android.server.art.model.OptimizeResult;
 import com.android.server.art.wrapper.AndroidPackageApi;
 import com.android.server.art.wrapper.PackageState;
+import com.android.server.art.wrapper.PackageUserState;
 
 import com.google.auto.value.AutoValue;
 
@@ -126,7 +128,17 @@
                             profile = pair.first;
                             isOtherReadable = pair.second;
                         }
-                        // TODO(jiakaiz): Merge profiles.
+                        ProfilePath mergedProfile =
+                                mergeProfiles(pkgState, dexInfo, uid, sharedGid, profile);
+                        if (mergedProfile != null) {
+                            if (profile != null
+                                    && profile.getTag() == ProfilePath.tmpRefProfilePath) {
+                                mInjector.getArtd().deleteProfile(profile);
+                            }
+                            profile = mergedProfile;
+                            isOtherReadable = false;
+                            profileMerged = true;
+                        }
                     }
                     if (profile == null) {
                         // A profile guided optimization with no profile is essentially 'verify',
@@ -140,10 +152,10 @@
                 }
                 boolean isProfileGuidedCompilerFilter =
                         DexFile.isProfileGuidedCompilerFilter(compilerFilter);
-                assert isProfileGuidedCompilerFilter == (profile != null);
+                Utils.check(isProfileGuidedCompilerFilter == (profile != null));
 
                 boolean canBePublic = !isProfileGuidedCompilerFilter || isOtherReadable;
-                assert Utils.implies(needsToBeShared, canBePublic);
+                Utils.check(Utils.implies(needsToBeShared, canBePublic));
                 PermissionSettings permissionSettings =
                         getPermissionSettings(sharedGid, canBePublic);
 
@@ -227,7 +239,18 @@
                             profile = null;
                         }
                     }
-                    // TODO(jiakaiz): If profileMerged is true, clear current profiles.
+                    if (profileMerged) {
+                        // Note that this is just an optimization, to reduce the amount of data that
+                        // the runtime writes on every profile save. The profile merge result on the
+                        // next run won't change regardless of whether the cleanup is done or not
+                        // because profman only looks at the diff.
+                        // A caveat is that it may delete more than what has been merged, if the
+                        // runtime writes additional entries between the merge and the cleanup, but
+                        // this is fine because the runtime writes all JITed classes and methods on
+                        // every save and the additional entries will likely be written back on the
+                        // next save.
+                        cleanupCurProfiles(pkgState, dexInfo);
+                    }
                 }
             } finally {
                 if (profile != null && profile.getTag() == ProfilePath.tmpRefProfilePath) {
@@ -503,7 +526,8 @@
         }
     }
 
-    boolean commitProfileChanges(@NonNull TmpRefProfilePath profile) throws RemoteException {
+    private boolean commitProfileChanges(@NonNull TmpRefProfilePath profile)
+            throws RemoteException {
         try {
             mInjector.getArtd().commitTmpProfile(profile);
             return true;
@@ -518,6 +542,52 @@
         }
     }
 
+    @Nullable
+    private ProfilePath mergeProfiles(@NonNull PackageState pkgState,
+            @NonNull DetailedPrimaryDexInfo dexInfo, int uid, int gid,
+            @Nullable ProfilePath referenceProfile) throws RemoteException {
+        String profileName = getProfileName(dexInfo.splitName());
+        OutputProfile output = AidlUtils.buildOutputProfile(
+                pkgState.getPackageName(), profileName, uid, gid, false /* isPublic */);
+
+        try {
+            if (mInjector.getArtd().mergeProfiles(getCurProfiles(pkgState, dexInfo),
+                        referenceProfile, output, dexInfo.dexPath())) {
+                return ProfilePath.tmpRefProfilePath(output.profilePath);
+            }
+        } catch (ServiceSpecificException e) {
+            Log.e(TAG,
+                    String.format("Failed to merge profiles [packageName = %s, profileName = %s]",
+                            pkgState.getPackageName(), getProfileName(dexInfo.splitName())),
+                    e);
+        }
+
+        return null;
+    }
+
+    private void cleanupCurProfiles(@NonNull PackageState pkgState,
+            @NonNull DetailedPrimaryDexInfo dexInfo) throws RemoteException {
+        for (ProfilePath profile : getCurProfiles(pkgState, dexInfo)) {
+            mInjector.getArtd().deleteProfile(profile);
+        }
+    }
+
+    @NonNull
+    private List<ProfilePath> getCurProfiles(
+            @NonNull PackageState pkgState, @NonNull DetailedPrimaryDexInfo dexInfo) {
+        List<ProfilePath> profiles = new ArrayList<>();
+        for (UserHandle handle :
+                mInjector.getUserManager().getUserHandles(true /* excludeDying */)) {
+            int userId = handle.getIdentifier();
+            PackageUserState userState = pkgState.getUserStateOrDefault(userId);
+            if (userState.isInstalled()) {
+                profiles.add(AidlUtils.buildProfilePathForCur(
+                        userId, pkgState.getPackageName(), getProfileName(dexInfo.splitName())));
+            }
+        }
+        return profiles;
+    }
+
     @AutoValue
     abstract static class DexoptTarget {
         abstract @NonNull DetailedPrimaryDexInfo dexInfo();
@@ -581,6 +651,11 @@
         }
 
         @NonNull
+        UserManager getUserManager() {
+            return mContext.getSystemService(UserManager.class);
+        }
+
+        @NonNull
         public IArtd getArtd() {
             return Utils.getArtd();
         }
diff --git a/libartservice/service/java/com/android/server/art/Utils.java b/libartservice/service/java/com/android/server/art/Utils.java
index 134dc9f..66c9704 100644
--- a/libartservice/service/java/com/android/server/art/Utils.java
+++ b/libartservice/service/java/com/android/server/art/Utils.java
@@ -107,6 +107,13 @@
         return cond1 ? cond2 : true;
     }
 
+    public static void check(boolean cond) {
+        // This cannot be replaced with `assert` because `assert` is not enabled in Android.
+        if (!cond) {
+            throw new IllegalStateException("Check failed");
+        }
+    }
+
     @AutoValue
     public abstract static class Abi {
         static @NonNull Abi create(
diff --git a/libartservice/service/java/com/android/server/art/wrapper/PackageState.java b/libartservice/service/java/com/android/server/art/wrapper/PackageState.java
index c591274..03c5f97 100644
--- a/libartservice/service/java/com/android/server/art/wrapper/PackageState.java
+++ b/libartservice/service/java/com/android/server/art/wrapper/PackageState.java
@@ -141,4 +141,16 @@
             throw new RuntimeException(e);
         }
     }
+
+    @NonNull
+    public PackageUserState getUserStateOrDefault(int userId) {
+        try {
+            Object userState = mPkgState.getClass()
+                                       .getMethod("getUserStateOrDefault", int.class)
+                                       .invoke(mPkgState, userId);
+            return userState != null ? new PackageUserState(userState) : null;
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
 }
diff --git a/libartservice/service/java/com/android/server/art/wrapper/PackageUserState.java b/libartservice/service/java/com/android/server/art/wrapper/PackageUserState.java
new file mode 100644
index 0000000..b21db11
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/wrapper/PackageUserState.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.art.wrapper;
+
+import android.annotation.NonNull;
+
+/** @hide */
+public class PackageUserState {
+    private final Object mPkgUserState;
+
+    PackageUserState(@NonNull Object pkgUserState) {
+        mPkgUserState = pkgUserState;
+    }
+
+    public boolean isInstalled() {
+        try {
+            return (boolean) mPkgUserState.getClass()
+                    .getMethod("isInstalled")
+                    .invoke(mPkgUserState);
+        } catch (ReflectiveOperationException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/libartservice/service/java/com/android/server/art/wrapper/README.md b/libartservice/service/java/com/android/server/art/wrapper/README.md
index 829fc1c..7d8c1f0 100644
--- a/libartservice/service/java/com/android/server/art/wrapper/README.md
+++ b/libartservice/service/java/com/android/server/art/wrapper/README.md
@@ -8,4 +8,5 @@
 - `AndroidPackageApi`: `com.android.server.pm.pkg.AndroidPackageApi`
 - `PackageManagerLocal`: `com.android.server.pm.PackageManagerLocal`
 - `PackageState`: `com.android.server.pm.pkg.PackageState`
+- `PackageUserState`: `com.android.server.pm.pkg.PackageUserState`
 - `SharedLibraryInfo`: `android.content.pm.SharedLibraryInfo`
diff --git a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
index 7cce870..f4ff71d 100644
--- a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
+++ b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTest.java
@@ -81,6 +81,9 @@
 
     private final int mDefaultDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_BETTER
             | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
+    private final int mBetterOrSameDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_BETTER
+            | DexoptTrigger.COMPILER_FILTER_IS_SAME
+            | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE;
     private final int mForceDexoptTrigger = DexoptTrigger.COMPILER_FILTER_IS_BETTER
             | DexoptTrigger.PRIMARY_BOOT_IMAGE_BECOMES_USABLE
             | DexoptTrigger.COMPILER_FILTER_IS_SAME | DexoptTrigger.COMPILER_FILTER_IS_WORSE;
@@ -239,6 +242,72 @@
     }
 
     @Test
+    public void testDexoptMergesProfiles() throws Exception {
+        when(mPkgState.getUserStateOrDefault(0 /* userId */)).thenReturn(mPkgUserStateInstalled);
+        when(mPkgState.getUserStateOrDefault(2 /* userId */)).thenReturn(mPkgUserStateInstalled);
+
+        when(mArtd.mergeProfiles(any(), any(), any(), any())).thenReturn(true);
+
+        makeProfileUsable(mRefProfile);
+        when(mArtd.getProfileVisibility(deepEq(mRefProfile)))
+                .thenReturn(FileVisibility.OTHER_READABLE);
+
+        mPrimaryDexOptimizer.dexopt(mPkgState, mPkg, mOptimizeParams, mCancellationSignal);
+
+        InOrder inOrder = inOrder(mArtd);
+
+        inOrder.verify(mArtd).mergeProfiles(
+                deepEq(List.of(
+                        AidlUtils.buildProfilePathForCur(0 /* userId */, PKG_NAME, "primary"),
+                        AidlUtils.buildProfilePathForCur(2 /* userId */, PKG_NAME, "primary"))),
+                deepEq(mRefProfile), deepEq(mPrivateOutputProfile), eq(mDexPath));
+
+        // It should use `mBetterOrSameDexoptTrigger` and the merged profile for both ISAs.
+        inOrder.verify(mArtd).getDexoptNeeded(eq(mDexPath), eq("arm64"), any(), eq("speed-profile"),
+                eq(mBetterOrSameDexoptTrigger));
+        checkDexoptWithPrivateProfile(inOrder.verify(mArtd), mDexPath, "arm64",
+                ProfilePath.tmpRefProfilePath(mPrivateOutputProfile.profilePath));
+
+        inOrder.verify(mArtd).getDexoptNeeded(eq(mDexPath), eq("arm"), any(), eq("speed-profile"),
+                eq(mBetterOrSameDexoptTrigger));
+        checkDexoptWithPrivateProfile(inOrder.verify(mArtd), mDexPath, "arm",
+                ProfilePath.tmpRefProfilePath(mPrivateOutputProfile.profilePath));
+
+        inOrder.verify(mArtd).commitTmpProfile(deepEq(mPrivateOutputProfile.profilePath));
+
+        inOrder.verify(mArtd).deleteProfile(
+                deepEq(AidlUtils.buildProfilePathForCur(0 /* userId */, PKG_NAME, "primary")));
+        inOrder.verify(mArtd).deleteProfile(
+                deepEq(AidlUtils.buildProfilePathForCur(2 /* userId */, PKG_NAME, "primary")));
+    }
+
+    @Test
+    public void testDexoptMergesProfilesMergeFailed() throws Exception {
+        when(mPkgState.getUserStateOrDefault(0 /* userId */)).thenReturn(mPkgUserStateInstalled);
+        when(mPkgState.getUserStateOrDefault(2 /* userId */)).thenReturn(mPkgUserStateInstalled);
+
+        when(mArtd.mergeProfiles(any(), any(), any(), any())).thenReturn(false);
+
+        makeProfileUsable(mRefProfile);
+        when(mArtd.getProfileVisibility(deepEq(mRefProfile)))
+                .thenReturn(FileVisibility.OTHER_READABLE);
+
+        mPrimaryDexOptimizer.dexopt(mPkgState, mPkg, mOptimizeParams, mCancellationSignal);
+
+        // It should still use "speed-profile", but with the existing reference profile only.
+        verify(mArtd).getDexoptNeeded(
+                eq(mDexPath), eq("arm64"), any(), eq("speed-profile"), eq(mDefaultDexoptTrigger));
+        checkDexoptWithPublicProfile(verify(mArtd), mDexPath, "arm64", mRefProfile);
+
+        verify(mArtd).getDexoptNeeded(
+                eq(mDexPath), eq("arm"), any(), eq("speed-profile"), eq(mDefaultDexoptTrigger));
+        checkDexoptWithPublicProfile(verify(mArtd), mDexPath, "arm", mRefProfile);
+
+        verify(mArtd, never()).deleteProfile(any());
+        verify(mArtd, never()).commitTmpProfile(any());
+    }
+
+    @Test
     public void testDexoptUsesDmProfile() throws Exception {
         makeProfileNotUsable(mRefProfile);
         makeProfileNotUsable(mPrebuiltProfile);
diff --git a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java
index 488f9fc..0aac42c 100644
--- a/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java
+++ b/libartservice/service/javatests/com/android/server/art/PrimaryDexOptimizerTestBase.java
@@ -20,6 +20,7 @@
 
 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.mock;
@@ -28,10 +29,12 @@
 import android.os.CancellationSignal;
 import android.os.SystemProperties;
 import android.os.UserHandle;
+import android.os.UserManager;
 
 import com.android.server.art.testing.StaticMockitoRule;
 import com.android.server.art.wrapper.AndroidPackageApi;
 import com.android.server.art.wrapper.PackageState;
+import com.android.server.art.wrapper.PackageUserState;
 
 import dalvik.system.PathClassLoader;
 
@@ -40,6 +43,7 @@
 import org.mockito.Mock;
 
 import java.util.ArrayList;
+import java.util.List;
 
 public class PrimaryDexOptimizerTestBase {
     protected static final String PKG_NAME = "com.example.foo";
@@ -50,8 +54,11 @@
 
     @Mock protected PrimaryDexOptimizer.Injector mInjector;
     @Mock protected IArtd mArtd;
+    @Mock protected UserManager mUserManager;
     protected PackageState mPkgState;
     protected AndroidPackageApi mPkg;
+    protected PackageUserState mPkgUserStateNotInstalled;
+    protected PackageUserState mPkgUserStateInstalled;
     protected CancellationSignal mCancellationSignal;
 
     protected PrimaryDexOptimizer mPrimaryDexOptimizer;
@@ -61,6 +68,7 @@
         lenient().when(mInjector.getArtd()).thenReturn(mArtd);
         lenient().when(mInjector.isSystemUiPackage(any())).thenReturn(false);
         lenient().when(mInjector.isUsedByOtherApps(any())).thenReturn(false);
+        lenient().when(mInjector.getUserManager()).thenReturn(mUserManager);
 
         lenient()
                 .when(SystemProperties.get("dalvik.vm.systemuicompilerfilter"))
@@ -71,6 +79,12 @@
         lenient().when(SystemProperties.get("dalvik.vm.appimageformat")).thenReturn("lz4");
         lenient().when(SystemProperties.get("pm.dexopt.shared")).thenReturn("speed");
 
+        lenient()
+                .when(mUserManager.getUserHandles(anyBoolean()))
+                .thenReturn(List.of(UserHandle.of(0), UserHandle.of(1), UserHandle.of(2)));
+
+        mPkgUserStateNotInstalled = createPackageUserState(false /* installed */);
+        mPkgUserStateInstalled = createPackageUserState(true /* installed */);
         mPkgState = createPackageState();
         mPkg = mPkgState.getAndroidPackage();
         mCancellationSignal = new CancellationSignal();
@@ -112,11 +126,20 @@
         lenient().when(pkgState.isSystem()).thenReturn(false);
         lenient().when(pkgState.isUpdatedSystemApp()).thenReturn(false);
         lenient().when(pkgState.getUsesLibraryInfos()).thenReturn(new ArrayList<>());
+        lenient()
+                .when(pkgState.getUserStateOrDefault(anyInt()))
+                .thenReturn(mPkgUserStateNotInstalled);
         AndroidPackageApi pkg = createPackage();
         lenient().when(pkgState.getAndroidPackage()).thenReturn(pkg);
         return pkgState;
     }
 
+    private PackageUserState createPackageUserState(boolean isInstalled) {
+        PackageUserState pkgUserState = mock(PackageUserState.class);
+        lenient().when(pkgUserState.isInstalled()).thenReturn(isInstalled);
+        return pkgUserState;
+    }
+
     protected GetDexoptNeededResult dexoptIsNotNeeded() {
         var result = new GetDexoptNeededResult();
         result.isDexoptNeeded = false;
diff --git a/libartservice/service/javatests/com/android/server/art/UtilsTest.java b/libartservice/service/javatests/com/android/server/art/UtilsTest.java
index a167919..f154800 100644
--- a/libartservice/service/javatests/com/android/server/art/UtilsTest.java
+++ b/libartservice/service/javatests/com/android/server/art/UtilsTest.java
@@ -72,4 +72,14 @@
         assertThat(Utils.implies(true, false)).isFalse();
         assertThat(Utils.implies(true, true)).isTrue();
     }
+
+    @Test
+    public void testCheck() {
+        Utils.check(true);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testCheckFailed() throws Exception {
+        Utils.check(false);
+    }
 }
diff --git a/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java b/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java
index e7582b1..9e51c87 100644
--- a/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java
+++ b/libartservice/service/javatests/com/android/server/art/testing/TestingUtils.java
@@ -26,6 +26,7 @@
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
+import java.util.List;
 
 public final class TestingUtils {
     private static final String TAG = "TestingUtils";
@@ -35,7 +36,7 @@
     /**
      * Recursively compares two objects using reflection. Returns true if the two objects are equal.
      * For simplicity, this method only supports types that every field is a primitive type, a
-     * string, or a supported type.
+     * string, a {@link List}, or a supported type.
      */
     public static boolean deepEquals(
             @Nullable Object a, @Nullable Object b, @NonNull StringBuilder errorMsg) {
@@ -48,6 +49,9 @@
                         a == null ? "null" : "nonnull", b == null ? "null" : "nonnull"));
                 return false;
             }
+            if (a instanceof List && b instanceof List) {
+                return listDeepEquals((List<?>) a, (List<?>) b, errorMsg);
+            }
             if (a.getClass() != b.getClass()) {
                 errorMsg.append(
                         String.format("Type mismatch: %s != %s", a.getClass(), b.getClass()));
@@ -117,4 +121,19 @@
                     return errorMsg.toString();
                 });
     }
+
+    private static boolean listDeepEquals(
+            @NonNull List<?> a, @NonNull List<?> b, @NonNull StringBuilder errorMsg) {
+        if (a.size() != b.size()) {
+            errorMsg.append(String.format("List length mismatch: %d != %d", a.size(), b.size()));
+            return false;
+        }
+        for (int i = 0; i < a.size(); i++) {
+            if (!deepEquals(a.get(i), b.get(i), errorMsg)) {
+                errorMsg.insert(0, String.format("Element %d mismatch: ", i));
+                return false;
+            }
+        }
+        return true;
+    };
 }
diff --git a/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java b/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java
index c1fe78b..5ef532c 100644
--- a/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java
+++ b/libartservice/service/javatests/com/android/server/art/testing/TestingUtilsTest.java
@@ -25,6 +25,8 @@
 import org.junit.runner.RunWith;
 
 import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
 
 @SmallTest
 @RunWith(AndroidJUnit4.class)
@@ -85,11 +87,42 @@
         TestingUtils.deepEquals(a, b);
     }
 
-    @Test(expected = UnsupportedOperationException.class)
-    public void testDeepEqualsContainerNotSupported() throws Exception {
+    @Test
+    public void testListDeepEquals() throws Exception {
+        var a = new ArrayList<Integer>();
+        a.add(1);
+        a.add(2);
+        a.add(3);
+        a.add(4);
+        a.add(5);
+        var b = List.of(1, 2, 3, 4, 5);
+        assertThat(TestingUtils.deepEquals(a, b)).isTrue();
+    }
+
+    @Test
+    public void testListDeepEqualsSizeMismatch() throws Exception {
         var a = new ArrayList<Integer>();
         a.add(1);
         var b = new ArrayList<Integer>();
+        b.add(1);
+        b.add(2);
+        assertThat(TestingUtils.deepEquals(a, b)).isFalse();
+    }
+
+    @Test
+    public void testListDeepEqualsElementMismatch() throws Exception {
+        var a = new ArrayList<Integer>();
+        a.add(1);
+        var b = new ArrayList<Integer>();
+        b.add(2);
+        assertThat(TestingUtils.deepEquals(a, b)).isFalse();
+    }
+
+    @Test(expected = UnsupportedOperationException.class)
+    public void testDeepEqualsOtherContainerNotSupported() throws Exception {
+        var a = new HashSet<Integer>();
+        a.add(1);
+        var b = new HashSet<Integer>();
         b.add(2);
         TestingUtils.deepEquals(a, b);
     }