summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libartservice/service/java/com/android/server/art/ArtManagerLocal.java2
-rw-r--r--libartservice/service/java/com/android/server/art/ArtShellCommand.java115
-rw-r--r--libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java374
-rw-r--r--libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java123
4 files changed, 571 insertions, 43 deletions
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index 93100084a6..e202c4fecc 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -190,7 +190,7 @@ public final class ArtManagerLocal {
public int handleShellCommand(@NonNull Binder target, @NonNull ParcelFileDescriptor in,
@NonNull ParcelFileDescriptor out, @NonNull ParcelFileDescriptor err,
@NonNull String[] args) {
- return new ArtShellCommand(this, mInjector.getPackageManagerLocal(), mInjector.getContext())
+ return new ArtShellCommand(this, mInjector.getPackageManagerLocal())
.exec(target, in.getFileDescriptor(), out.getFileDescriptor(),
err.getFileDescriptor(), args);
}
diff --git a/libartservice/service/java/com/android/server/art/ArtShellCommand.java b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
index 21b24c7205..d21d5d7068 100644
--- a/libartservice/service/java/com/android/server/art/ArtShellCommand.java
+++ b/libartservice/service/java/com/android/server/art/ArtShellCommand.java
@@ -31,7 +31,6 @@ import static com.android.server.art.model.DexoptStatus.DexContainerFileDexoptSt
import android.annotation.NonNull;
import android.annotation.Nullable;
-import android.content.Context;
import android.os.Binder;
import android.os.Build;
import android.os.CancellationSignal;
@@ -44,6 +43,7 @@ import android.system.StructStat;
import androidx.annotation.RequiresApi;
import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
import com.android.modules.utils.BasicShellCommandHandler;
import com.android.modules.utils.build.SdkLevel;
import com.android.server.art.model.ArtFlags;
@@ -93,19 +93,20 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
/** 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 Context mContext;
+ @NonNull private final Injector mInjector;
@GuardedBy("sCancellationSignalMap")
@NonNull
private static final Map<String, CancellationSignal> sCancellationSignalMap = new HashMap<>();
public ArtShellCommand(@NonNull ArtManagerLocal artManagerLocal,
- @NonNull PackageManagerLocal packageManagerLocal, @NonNull Context context) {
- mArtManagerLocal = artManagerLocal;
- mPackageManagerLocal = packageManagerLocal;
- mContext = context;
+ @NonNull PackageManagerLocal packageManagerLocal) {
+ this(new Injector(artManagerLocal, packageManagerLocal));
+ }
+
+ @VisibleForTesting
+ public ArtShellCommand(@NonNull Injector injector) {
+ mInjector = injector;
}
@Override
@@ -113,7 +114,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
// Apps shouldn't call ART Service shell commands, not even for dexopting themselves.
enforceRootOrShell();
PrintWriter pw = getOutPrintWriter();
- try (var snapshot = mPackageManagerLocal.withFilteredSnapshot()) {
+ try (var snapshot = mInjector.getPackageManagerLocal().withFilteredSnapshot()) {
switch (cmd) {
case "compile":
return handleCompile(pw, snapshot);
@@ -183,9 +184,10 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
String packageName = getNextArg();
if (packageName != null) {
- mArtManagerLocal.dumpPackage(pw, snapshot, packageName, verifySdmSignatures);
+ mInjector.getArtManagerLocal().dumpPackage(
+ pw, snapshot, packageName, verifySdmSignatures);
} else {
- mArtManagerLocal.dump(pw, snapshot, verifySdmSignatures);
+ mInjector.getArtManagerLocal().dump(pw, snapshot, verifySdmSignatures);
}
return 0;
}
@@ -193,7 +195,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
return handleCleanup(pw, snapshot);
}
case "clear-app-profiles": {
- mArtManagerLocal.clearAppProfiles(snapshot, getNextArgRequired());
+ mInjector.getArtManagerLocal().clearAppProfiles(snapshot, getNextArgRequired());
pw.println("Profiles cleared");
return 0;
}
@@ -371,7 +373,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
// For compat only. Combining this with dexopt usually produces in undesired
// results.
for (String packageName : packageNames) {
- mArtManagerLocal.clearAppProfiles(snapshot, packageName);
+ mInjector.getArtManagerLocal().clearAppProfiles(snapshot, packageName);
}
}
return dexoptPackages(pw, snapshot, packageNames, paramsBuilder.build(), verbose);
@@ -411,7 +413,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
}
CompletableFuture<BackgroundDexoptJob.Result> runningJob =
- mArtManagerLocal.getRunningBackgroundDexoptJob();
+ mInjector.getArtManagerLocal().getRunningBackgroundDexoptJob();
if (runningJob != null) {
pw.println("Another job already running. Waiting for it to finish... To cancel it, "
+ "run 'pm bg-dexopt-job --cancel'. in a separate shell.");
@@ -419,7 +421,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
Utils.getFuture(runningJob);
}
CompletableFuture<BackgroundDexoptJob.Result> future =
- mArtManagerLocal.startBackgroundDexoptJobAndReturnFuture();
+ mInjector.getArtManagerLocal().startBackgroundDexoptJobAndReturnFuture();
pw.println("Job running... To cancel it, run 'pm bg-dexopt-job --cancel'. in a "
+ "separate shell.");
pw.flush();
@@ -445,7 +447,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
// This operation requires the uid to be "system" (1000).
long identityToken = Binder.clearCallingIdentity();
try {
- mArtManagerLocal.scheduleBackgroundDexoptJob();
+ mInjector.getArtManagerLocal().scheduleBackgroundDexoptJob();
} finally {
Binder.restoreCallingIdentity(identityToken);
}
@@ -456,7 +458,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
// This operation requires the uid to be "system" (1000).
long identityToken = Binder.clearCallingIdentity();
try {
- mArtManagerLocal.unscheduleBackgroundDexoptJob();
+ mInjector.getArtManagerLocal().unscheduleBackgroundDexoptJob();
} finally {
Binder.restoreCallingIdentity(identityToken);
}
@@ -470,22 +472,22 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
}
private int handleCancelBgDexoptJob(@NonNull PrintWriter pw) {
- mArtManagerLocal.cancelBackgroundDexoptJob();
+ mInjector.getArtManagerLocal().cancelBackgroundDexoptJob();
pw.println("Background dexopt job cancelled");
return 0;
}
private int handleCleanup(
@NonNull PrintWriter pw, @NonNull PackageManagerLocal.FilteredSnapshot snapshot) {
- long freedBytes = mArtManagerLocal.cleanup(snapshot);
+ long freedBytes = mInjector.getArtManagerLocal().cleanup(snapshot);
pw.printf("Freed %d bytes\n", freedBytes);
return 0;
}
private int handleDeleteDexopt(
@NonNull PrintWriter pw, @NonNull PackageManagerLocal.FilteredSnapshot snapshot) {
- DeleteResult result =
- mArtManagerLocal.deleteDexoptArtifacts(snapshot, getNextArgRequired());
+ DeleteResult result = mInjector.getArtManagerLocal().deleteDexoptArtifacts(
+ snapshot, getNextArgRequired());
pw.printf("Freed %d bytes\n", result.getFreedBytes());
return 0;
}
@@ -548,7 +550,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@NonNull PrintWriter pw, @NonNull PackageManagerLocal.FilteredSnapshot snapshot)
throws SnapshotProfileException {
String outputRelativePath = "android.prof";
- ParcelFileDescriptor fd = mArtManagerLocal.snapshotBootImageProfile(snapshot);
+ ParcelFileDescriptor fd = mInjector.getArtManagerLocal().snapshotBootImageProfile(snapshot);
writeProfileFdContentsToFile(pw, fd, outputRelativePath);
return 0;
}
@@ -559,7 +561,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
String outputRelativePath = String.format("%s%s.prof", packageName,
splitName != null ? String.format("-split_%s.apk", splitName) : "");
ParcelFileDescriptor fd =
- mArtManagerLocal.snapshotAppProfile(snapshot, packageName, splitName);
+ mInjector.getArtManagerLocal().snapshotAppProfile(snapshot, packageName, splitName);
writeProfileFdContentsToFile(pw, fd, outputRelativePath);
return 0;
}
@@ -594,7 +596,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
pw.println("Waiting for app processes to flush profiles...");
pw.flush();
long startTimeMs = System.currentTimeMillis();
- if (mArtManagerLocal.flushProfiles(snapshot, packageName)) {
+ if (mInjector.getArtManagerLocal().flushProfiles(snapshot, packageName)) {
pw.printf("App processes flushed profiles in %dms\n",
System.currentTimeMillis() - startTimeMs);
} else {
@@ -607,7 +609,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
// is to match the behavior of the legacy PM shell command.
String outputRelativePath =
String.format("%s-%s.prof.txt", packageName, profileName);
- ParcelFileDescriptor fd = mArtManagerLocal.dumpAppProfile(
+ ParcelFileDescriptor fd = mInjector.getArtManagerLocal().dumpAppProfile(
snapshot, packageName, dexInfo.splitName(), dumpClassesAndMethods);
writeProfileFdContentsToFile(pw, fd, outputRelativePath);
}
@@ -654,8 +656,9 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
ExecutorService progressCallbackExecutor = Executors.newSingleThreadExecutor();
try (var signal = new WithCancellationSignal(pw, true /* verbose */)) {
- Map<Integer, DexoptResult> results = mArtManagerLocal.dexoptPackages(snapshot,
- finalReason, signal.get(), progressCallbackExecutor, progressCallbacks);
+ Map<Integer, DexoptResult> results =
+ mInjector.getArtManagerLocal().dexoptPackages(snapshot, finalReason,
+ signal.get(), progressCallbackExecutor, progressCallbacks);
Utils.executeAndWait(progressCallbackExecutor, () -> {
for (@BatchDexoptPass int pass : ArtFlags.BATCH_DEXOPT_PASSES) {
@@ -682,7 +685,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
return 1;
}
- int uid = Binder.getCallingUid();
+ int uid = mInjector.getCallingUid();
if (uid != Process.ROOT_UID) {
throw new SecurityException("Only root can call 'on-ota-staged'");
}
@@ -706,11 +709,11 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
return 1;
}
- if (mArtManagerLocal.getPreRebootDexoptJob().isAsyncForOta()) {
+ if (mInjector.getArtManagerLocal().getPreRebootDexoptJob().isAsyncForOta()) {
return handleSchedulePrDexoptJob(pw, otaSlot);
} else {
- // Don't map snapshots when running synchronously. `update_engine` maps snapshots for
- // us.
+ // Don't map snapshots when running synchronously. `update_engine` maps snapshots
+ // for us.
return handleRunPrDexoptJob(pw, otaSlot, false /* mapSnapshotsForOta */);
}
}
@@ -752,7 +755,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
return 1;
}
- if (otaSlot != null && Binder.getCallingUid() != Process.ROOT_UID) {
+ if (otaSlot != null && mInjector.getCallingUid() != Process.ROOT_UID) {
throw new SecurityException("Only root can specify '--slot'");
}
@@ -777,7 +780,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
private int handleTestPrDexoptJob(@NonNull PrintWriter pw) {
try {
- mArtManagerLocal.getPreRebootDexoptJob().test();
+ mInjector.getArtManagerLocal().getPreRebootDexoptJob().test();
pw.println("Success");
return 0;
} catch (Exception e) {
@@ -790,7 +793,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
private int handleRunPrDexoptJob(
@NonNull PrintWriter pw, @Nullable String otaSlot, boolean mapSnapshotsForOta) {
- PreRebootDexoptJob job = mArtManagerLocal.getPreRebootDexoptJob();
+ PreRebootDexoptJob job = mInjector.getArtManagerLocal().getPreRebootDexoptJob();
CompletableFuture<Void> future = job.onUpdateReadyStartNow(otaSlot, mapSnapshotsForOta);
if (future == null) {
@@ -844,7 +847,8 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
private int handleSchedulePrDexoptJob(@NonNull PrintWriter pw, @Nullable String otaSlot) {
- int code = mArtManagerLocal.getPreRebootDexoptJob().onUpdateReadyImpl(otaSlot);
+ int code =
+ mInjector.getArtManagerLocal().getPreRebootDexoptJob().onUpdateReadyImpl(otaSlot);
switch (code) {
case ArtFlags.SCHEDULE_SUCCESS:
pw.println("Pre-reboot Dexopt job scheduled");
@@ -863,7 +867,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
private int handleCancelPrDexoptJob(@NonNull PrintWriter pw) {
- mArtManagerLocal.getPreRebootDexoptJob().cancelAny();
+ mInjector.getArtManagerLocal().getPreRebootDexoptJob().cancelAny();
pw.println("Pre-reboot Dexopt job cancelled");
return 0;
}
@@ -889,7 +893,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
// Variables used in lambda needs to be effectively final.
String finalInputReason = inputReason;
- mArtManagerLocal.setBatchDexoptStartCallback(
+ mInjector.getArtManagerLocal().setBatchDexoptStartCallback(
Runnable::run, (snapshot, reason, defaultPackages, builder, cancellationSignal) -> {
if (reason.equals(finalInputReason)) {
if (!packages.isEmpty()) {
@@ -1109,7 +1113,7 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
}
private void enforceRootOrShell() {
- final int uid = Binder.getCallingUid();
+ final int uid = mInjector.getCallingUid();
if (uid != Process.ROOT_UID && uid != Process.SHELL_UID) {
throw new SecurityException("ART service shell commands need root or shell access");
}
@@ -1175,8 +1179,8 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@NonNull List<String> packageNames, boolean verbose) {
try (var signal = new WithCancellationSignal(pw, verbose)) {
for (String packageName : packageNames) {
- DexoptResult result =
- mArtManagerLocal.resetDexoptStatus(snapshot, packageName, signal.get());
+ DexoptResult result = mInjector.getArtManagerLocal().resetDexoptStatus(
+ snapshot, packageName, signal.get());
printDexoptResult(pw, result, verbose, packageNames.size() > 1);
}
}
@@ -1188,8 +1192,8 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
@NonNull List<String> packageNames, @NonNull DexoptParams params, boolean verbose) {
try (var signal = new WithCancellationSignal(pw, verbose)) {
for (String packageName : packageNames) {
- DexoptResult result =
- mArtManagerLocal.dexoptPackage(snapshot, packageName, params, signal.get());
+ DexoptResult result = mInjector.getArtManagerLocal().dexoptPackage(
+ snapshot, packageName, params, signal.get());
printDexoptResult(pw, result, verbose, packageNames.size() > 1);
}
}
@@ -1302,4 +1306,31 @@ public final class ArtShellCommand extends BasicShellCommandHandler {
}
}
}
+
+ /** Injector pattern for testing purpose. */
+ @VisibleForTesting
+ public static class Injector {
+ @NonNull private final ArtManagerLocal mArtManagerLocal;
+ @NonNull private final PackageManagerLocal mPackageManagerLocal;
+
+ public Injector(@NonNull ArtManagerLocal artManagerLocal,
+ @NonNull PackageManagerLocal packageManagerLocal) {
+ mArtManagerLocal = artManagerLocal;
+ mPackageManagerLocal = packageManagerLocal;
+ }
+
+ @NonNull
+ public ArtManagerLocal getArtManagerLocal() {
+ return mArtManagerLocal;
+ }
+
+ @NonNull
+ public PackageManagerLocal getPackageManagerLocal() {
+ return mPackageManagerLocal;
+ }
+
+ public int getCallingUid() {
+ return Binder.getCallingUid();
+ }
+ }
}
diff --git a/libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java b/libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java
new file mode 100644
index 0000000000..7b934bd7e4
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/ArtShellCommandTest.java
@@ -0,0 +1,374 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.art;
+
+import static com.android.server.art.PreRebootDexoptJob.JOB_ID;
+import static com.android.server.art.prereboot.PreRebootDriver.PreRebootResult;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.isNull;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobScheduler;
+import android.os.CancellationSignal;
+import android.os.Process;
+import android.os.SystemProperties;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.server.art.prereboot.PreRebootDriver;
+import com.android.server.art.prereboot.PreRebootStatsReporter;
+import com.android.server.art.testing.CommandExecution;
+import com.android.server.art.testing.StaticMockitoRule;
+import com.android.server.pm.PackageManagerLocal;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@SmallTest
+@RunWith(MockitoJUnitRunner.StrictStubs.class)
+public class ArtShellCommandTest {
+ private static final long TIMEOUT_SEC = 10;
+
+ @Rule
+ public StaticMockitoRule mockitoRule = new StaticMockitoRule(
+ SystemProperties.class, BackgroundDexoptJobService.class, ArtJni.class);
+
+ @Mock private BackgroundDexoptJobService mJobService;
+ @Mock private PreRebootDriver mPreRebootDriver;
+ @Mock private PreRebootStatsReporter mPreRebootStatsReporter;
+ @Mock private JobScheduler mJobScheduler;
+ @Mock private PreRebootDexoptJob.Injector mPreRebootDexoptJobInjector;
+ @Mock private ArtManagerLocal.Injector mArtManagerLocalInjector;
+ @Mock private PackageManagerLocal mPackageManagerLocal;
+ @Mock private ArtShellCommand.Injector mInjector;
+
+ private PreRebootDexoptJob mPreRebootDexoptJob;
+ private ArtManagerLocal mArtManagerLocal;
+ private JobInfo mJobInfo;
+ private JobParameters mJobParameters;
+
+ @Before
+ public void setUp() throws Exception {
+ lenient()
+ .when(SystemProperties.getBoolean(eq("dalvik.vm.enable_pr_dexopt"), anyBoolean()))
+ .thenReturn(true);
+
+ lenient().when(mJobScheduler.schedule(any())).thenAnswer(invocation -> {
+ mJobInfo = invocation.<JobInfo>getArgument(0);
+ mJobParameters = mock(JobParameters.class);
+ assertThat(mJobInfo.getId()).isEqualTo(JOB_ID);
+ lenient().when(mJobParameters.getExtras()).thenReturn(mJobInfo.getExtras());
+ return JobScheduler.RESULT_SUCCESS;
+ });
+
+ lenient()
+ .doAnswer(invocation -> {
+ mJobInfo = null;
+ mJobParameters = null;
+ return null;
+ })
+ .when(mJobScheduler)
+ .cancel(JOB_ID);
+
+ lenient().when(mJobScheduler.getPendingJob(JOB_ID)).thenAnswer(invocation -> {
+ return mJobInfo;
+ });
+
+ lenient()
+ .when(mPreRebootDexoptJobInjector.getPreRebootDriver())
+ .thenReturn(mPreRebootDriver);
+ lenient()
+ .when(mPreRebootDexoptJobInjector.getStatsReporter())
+ .thenReturn(mPreRebootStatsReporter);
+ lenient().when(mPreRebootDexoptJobInjector.getJobScheduler()).thenReturn(mJobScheduler);
+ mPreRebootDexoptJob = new PreRebootDexoptJob(mPreRebootDexoptJobInjector);
+
+ lenient().when(BackgroundDexoptJobService.getJob(JOB_ID)).thenReturn(mPreRebootDexoptJob);
+
+ lenient()
+ .when(mArtManagerLocalInjector.getPreRebootDexoptJob())
+ .thenReturn(mPreRebootDexoptJob);
+ mArtManagerLocal = new ArtManagerLocal(mArtManagerLocalInjector);
+
+ lenient().when(mInjector.getArtManagerLocal()).thenReturn(mArtManagerLocal);
+ lenient().when(mInjector.getPackageManagerLocal()).thenReturn(mPackageManagerLocal);
+ }
+
+ @Test
+ public void testOnOtaStagedPermission() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "on-ota-staged", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(-1);
+ assertThat(outputs).contains("Only root can call 'on-ota-staged'");
+ }
+ }
+
+ @Test
+ public void testOnOtaStagedSync() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any()))
+ .thenReturn(new PreRebootResult(true /* success */));
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "on-ota-staged", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Job finished. See logs for details");
+ }
+ }
+
+ @Test
+ public void testOnOtaStagedSyncFatalError() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any()))
+ .thenThrow(RuntimeException.class);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "on-ota-staged", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Job encountered a fatal error");
+ }
+ }
+
+ @Test
+ public void testOnOtaStagedSyncCancelledByCommand() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any()))
+ .thenAnswer(invocation -> {
+ Semaphore dexoptCancelled = new Semaphore(0 /* permits */);
+ var cancellationSignal = invocation.<CancellationSignal>getArgument(2);
+ cancellationSignal.setOnCancelListener(() -> dexoptCancelled.release());
+ assertThat(dexoptCancelled.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue();
+ return new PreRebootResult(true /* success */);
+ });
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "on-ota-staged", "--slot", "_b")) {
+ assertThat(execution.getStdout().readLine()).contains("Job running...");
+
+ try (var execution2 = new CommandExecution(
+ createHandler(), "art", "pr-dexopt-job", "--cancel")) {
+ int exitCode2 = execution2.waitAndGetExitCode();
+ String outputs2 = getOutputs(execution2);
+ assertWithMessage(outputs2).that(exitCode2).isEqualTo(0);
+ assertThat(outputs2).contains("Pre-reboot Dexopt job cancelled");
+ }
+
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Job finished. See logs for details");
+ }
+ }
+
+ @Test
+ public void testOnOtaStagedSyncCancelledByBrokenPipe() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ when(mPreRebootDriver.run(eq("_b"), eq(false) /* mapSnapshotsForOta */, any()))
+ .thenAnswer(invocation -> {
+ Semaphore dexoptCancelled = new Semaphore(0 /* permits */);
+ var cancellationSignal = invocation.<CancellationSignal>getArgument(2);
+ cancellationSignal.setOnCancelListener(() -> dexoptCancelled.release());
+ assertThat(dexoptCancelled.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS)).isTrue();
+ return new PreRebootResult(true /* success */);
+ });
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "on-ota-staged", "--slot", "_b")) {
+ assertThat(execution.getStdout().readLine()).contains("Job running...");
+
+ execution.closeStdin();
+
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Job finished. See logs for details");
+ }
+ }
+
+ @Test
+ public void testOnOtaStagedAsyncLegacy() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ when(SystemProperties.getBoolean(eq("dalvik.vm.pr_dexopt_async_for_ota"), anyBoolean()))
+ .thenReturn(true);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "on-ota-staged", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Pre-reboot Dexopt job scheduled");
+ }
+
+ when(mPreRebootDriver.run(eq("_b"), eq(true) /* mapSnapshotsForOta */, any()))
+ .thenReturn(new PreRebootResult(true /* success */));
+
+ mPreRebootDexoptJob.onStartJobImpl(mJobService, mJobParameters);
+ mPreRebootDexoptJob.waitForRunningJob();
+ }
+
+ @Test
+ public void testPrDexoptJobRunMainline() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID);
+
+ when(mPreRebootDriver.run(
+ isNull() /* otaSlot */, anyBoolean() /* mapSnapshotsForOta */, any()))
+ .thenReturn(new PreRebootResult(true /* success */));
+
+ try (var execution =
+ new CommandExecution(createHandler(), "art", "pr-dexopt-job", "--run")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Job finished. See logs for details");
+ }
+ }
+
+ @Test
+ public void testPrDexoptJobRunOtaPermission() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "pr-dexopt-job", "--run", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(-1);
+ assertThat(outputs).contains("Only root can specify '--slot'");
+ }
+ }
+
+ @Test
+ public void testPrDexoptJobRunOta() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ when(mPreRebootDriver.run(eq("_b"), eq(true) /* mapSnapshotsForOta */, any()))
+ .thenReturn(new PreRebootResult(true /* success */));
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "pr-dexopt-job", "--run", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Job finished. See logs for details");
+ }
+ }
+
+ @Test
+ public void testPrDexoptJobScheduleMainline() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "pr-dexopt-job", "--schedule")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Pre-reboot Dexopt job scheduled");
+ }
+
+ when(mPreRebootDriver.run(
+ isNull() /* otaSlot */, anyBoolean() /* mapSnapshotsForOta */, any()))
+ .thenReturn(new PreRebootResult(true /* success */));
+
+ mPreRebootDexoptJob.onStartJobImpl(mJobService, mJobParameters);
+ mPreRebootDexoptJob.waitForRunningJob();
+ }
+
+ @Test
+ public void testPrDexoptJobScheduleOtaPermission() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.SHELL_UID);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "pr-dexopt-job", "--schedule", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(-1);
+ assertThat(outputs).contains("Only root can specify '--slot'");
+ }
+ }
+
+ @Test
+ public void testPrDexoptJobScheduleOta() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ try (var execution = new CommandExecution(
+ createHandler(), "art", "pr-dexopt-job", "--schedule", "--slot", "_b")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Pre-reboot Dexopt job scheduled");
+ }
+
+ when(mPreRebootDriver.run(eq("_b"), eq(true) /* mapSnapshotsForOta */, any()))
+ .thenReturn(new PreRebootResult(true /* success */));
+
+ mPreRebootDexoptJob.onStartJobImpl(mJobService, mJobParameters);
+ mPreRebootDexoptJob.waitForRunningJob();
+ }
+
+ @Test
+ public void testPrDexoptJobCancelJobNotFound() throws Exception {
+ when(mInjector.getCallingUid()).thenReturn(Process.ROOT_UID);
+
+ try (var execution =
+ new CommandExecution(createHandler(), "art", "pr-dexopt-job", "--cancel")) {
+ int exitCode = execution.waitAndGetExitCode();
+ String outputs = getOutputs(execution);
+ assertWithMessage(outputs).that(exitCode).isEqualTo(0);
+ assertThat(outputs).contains("Pre-reboot Dexopt job cancelled");
+ }
+ }
+
+ private ArtShellCommand createHandler() {
+ return new ArtShellCommand(mInjector);
+ }
+
+ private String getOutputs(CommandExecution execution) {
+ return Stream.concat(execution.getStdout().lines(), execution.getStderr().lines())
+ .collect(Collectors.joining("\n"));
+ }
+}
diff --git a/libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java b/libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java
new file mode 100644
index 0000000000..7391145ecb
--- /dev/null
+++ b/libartservice/service/javatests/com/android/server/art/testing/CommandExecution.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.art.testing;
+
+import static org.mockito.Mockito.mock;
+
+import android.os.Binder;
+import android.os.ParcelFileDescriptor;
+
+import com.android.modules.utils.BasicShellCommandHandler;
+import com.android.server.art.Utils;
+
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.util.concurrent.CompletableFuture;
+
+/** A harness to test a {@link BasicShellCommandHandler}. */
+public class CommandExecution implements AutoCloseable {
+ private ParcelFileDescriptor[] mStdinPipe = null;
+ private ParcelFileDescriptor[] mStdoutPipe = null;
+ private ParcelFileDescriptor[] mStderrPipe = null;
+ private PrintWriter mStdinWriter = null;
+ private BufferedReader mStdoutReader = null;
+ private BufferedReader mStderrReader = null;
+ private CompletableFuture<Integer> mFuture = null;
+
+ public CommandExecution(BasicShellCommandHandler commandHandler, String... args)
+ throws Exception {
+ try {
+ mStdinPipe = ParcelFileDescriptor.createPipe();
+ mStdoutPipe = ParcelFileDescriptor.createPipe();
+ mStderrPipe = ParcelFileDescriptor.createPipe();
+ mStdinWriter = new PrintWriter(new FileOutputStream(mStdinPipe[1].getFileDescriptor()));
+ mStdoutReader = new BufferedReader(
+ new InputStreamReader(new FileInputStream(mStdoutPipe[0].getFileDescriptor())));
+ mStderrReader = new BufferedReader(
+ new InputStreamReader(new FileInputStream(mStderrPipe[0].getFileDescriptor())));
+ mFuture = CompletableFuture.supplyAsync(() -> exec(commandHandler, args));
+ } catch (Exception e) {
+ close();
+ throw e;
+ }
+ }
+
+ private int exec(BasicShellCommandHandler commandHandler, String... args) {
+ int exitCode = commandHandler.exec(mock(Binder.class), mStdinPipe[0].getFileDescriptor(),
+ mStdoutPipe[1].getFileDescriptor(), mStderrPipe[1].getFileDescriptor(), args);
+ try {
+ mStdinPipe[0].close();
+ mStdoutPipe[1].close();
+ mStderrPipe[1].close();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return exitCode;
+ }
+
+ public int waitAndGetExitCode() {
+ return Utils.getFuture(mFuture);
+ }
+
+ public PrintWriter getStdin() {
+ return mStdinWriter;
+ }
+
+ public void closeStdin() throws Exception {
+ mStdinWriter.close();
+ mStdinPipe[1].close();
+ }
+
+ public BufferedReader getStdout() {
+ return mStdoutReader;
+ }
+
+ public BufferedReader getStderr() {
+ return mStderrReader;
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (mStdinWriter != null) {
+ mStdinWriter.close();
+ }
+ if (mStdoutReader != null) {
+ mStdoutReader.close();
+ }
+ if (mStderrReader != null) {
+ mStderrReader.close();
+ }
+ // Note that we still need to close the FDs after closing the streams. See the
+ // Android-specific warning at
+ // https://developer.android.com/reference/java/io/FileInputStream#FileInputStream(java.io.FileDescriptor)
+ if (mStdinPipe != null) {
+ mStdinPipe[0].close();
+ mStdinPipe[1].close();
+ }
+ if (mStdoutPipe != null) {
+ mStdoutPipe[0].close();
+ mStdoutPipe[1].close();
+ }
+ if (mStderrPipe != null) {
+ mStderrPipe[0].close();
+ mStderrPipe[1].close();
+ }
+ }
+}