Implement app downgrading.

Also:
- Filter the default package list by last active time and sort it in
  descending order.

Bug: 255565888
Test: atest ArtServiceTests
Test: -
  1. Fill the storage space by `fallocate`.
  2. adb shell setprop pm.dexopt.downgrade_after_inactive_days 1
  3. adb shell pm art bg-dexopt-job
  4. See some apps being downgraded and the other apps being optimized.
Ignore-AOSP-First: ART Services.
Change-Id: I8594f67aa10da5bc907c92bb7b0d1aaf095d3c33
diff --git a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
index b02bfdc..bf77989 100644
--- a/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
+++ b/libartservice/service/java/com/android/server/art/ArtManagerLocal.java
@@ -42,8 +42,12 @@
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
+import android.os.SystemProperties;
 import android.os.UserManager;
+import android.os.storage.StorageManager;
 import android.text.TextUtils;
+import android.util.Log;
+import android.util.Pair;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.LocalManagerRegistry;
@@ -62,16 +66,22 @@
 
 import java.io.File;
 import java.io.FileNotFoundException;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /**
  * This class provides a system API for functionality provided by the ART module.
@@ -89,6 +99,7 @@
     private static final String TAG = "ArtService";
     private static final String[] CLASSPATHS_FOR_BOOT_IMAGE_PROFILE = {
             "BOOTCLASSPATH", "SYSTEMSERVERCLASSPATH", "STANDALONE_SYSTEMSERVER_JARS"};
+    private static final long DOWNGRADE_THRESHOLD_ABOVE_LOW_BYTES = 500_000_000;
 
     @NonNull private final Injector mInjector;
 
@@ -314,6 +325,17 @@
      * When this operation ends (either completed or cancelled), callbacks added by {@link
      * #addOptimizePackageDoneCallback(Executor, OptimizePackageDoneCallback)} are called.
      *
+     * If the storage is nearly low, and {@code reason} is {@link ReasonMapping#REASON_BG_DEXOPT},
+     * it may also downgrade some inactive packages to a less optimized compiler filter, specified
+     * by the system property {@code pm.dexopt.inactive} (typically "verify"), to free up some
+     * space. This feature is only enabled when the system property {@code
+     * pm.dexopt.downgrade_after_inactive_days} is set. The space threshold to trigger this feature
+     * is the Storage Manager's low space threshold plus {@link
+     * #DOWNGRADE_THRESHOLD_ABOVE_LOW_BYTES}. The concurrency can be configured by system property
+     * {@code pm.dexopt.inactive.concurrency}. The packages in the list provided by
+     * {@link OptimizePackagesCallback} for {@link ReasonMapping#REASON_BG_DEXOPT} are never
+     * downgraded.
+     *
      * @param snapshot the snapshot from {@link PackageManagerLocal} to operate on
      * @param reason determines the default list of packages and options
      * @param cancellationSignal provides the ability to cancel this operation
@@ -330,7 +352,7 @@
     public OptimizeResult optimizePackages(@NonNull PackageManagerLocal.FilteredSnapshot snapshot,
             @NonNull @BatchOptimizeReason String reason,
             @NonNull CancellationSignal cancellationSignal,
-            @Nullable @CallbackExecutor Executor processCallbackExecutor,
+            @Nullable @CallbackExecutor Executor progressCallbackExecutor,
             @Nullable Consumer<OperationProgress> progressCallback) {
         List<String> defaultPackages =
                 Collections.unmodifiableList(getDefaultPackages(snapshot, reason));
@@ -350,9 +372,15 @@
         ExecutorService dexoptExecutor =
                 Executors.newFixedThreadPool(ReasonMapping.getConcurrencyForReason(reason));
         try {
+            if (reason.equals(ReasonMapping.REASON_BG_DEXOPT)) {
+                maybeDowngradePackages(snapshot,
+                        new HashSet<>(params.getPackages()) /* excludedPackages */,
+                        cancellationSignal, dexoptExecutor);
+            }
+            Log.i(TAG, "Optimizing packages");
             return mInjector.getDexOptHelper().dexopt(snapshot, params.getPackages(),
                     params.getOptimizeParams(), cancellationSignal, dexoptExecutor,
-                    processCallbackExecutor, progressCallback);
+                    progressCallbackExecutor, progressCallback);
         } finally {
             dexoptExecutor.shutdown();
         }
@@ -623,37 +651,84 @@
         return mInjector.getBackgroundDexOptJob();
     }
 
+    private void maybeDowngradePackages(@NonNull PackageManagerLocal.FilteredSnapshot snapshot,
+            @NonNull Set<String> excludedPackages, @NonNull CancellationSignal cancellationSignal,
+            @NonNull Executor executor) {
+        if (shouldDowngrade()) {
+            List<String> packages = getDefaultPackages(snapshot, ReasonMapping.REASON_INACTIVE)
+                                            .stream()
+                                            .filter(pkg -> !excludedPackages.contains(pkg))
+                                            .collect(Collectors.toList());
+            if (!packages.isEmpty()) {
+                Log.i(TAG, "Storage is low. Downgrading inactive packages");
+                mInjector.getDexOptHelper().dexopt(snapshot, packages,
+                        new OptimizeParams.Builder(ReasonMapping.REASON_INACTIVE).build(),
+                        cancellationSignal, executor, null /* processCallbackExecutor */,
+                        null /* progressCallback */);
+            } else {
+                Log.i(TAG,
+                        "Storage is low, but downgrading is disabled or there's nothing to "
+                                + "downgrade");
+            }
+        }
+    }
+
+    private boolean shouldDowngrade() {
+        try {
+            return mInjector.getStorageManager().getAllocatableBytes(StorageManager.UUID_DEFAULT)
+                    < DOWNGRADE_THRESHOLD_ABOVE_LOW_BYTES;
+        } catch (IOException e) {
+            Log.e(TAG, "Failed to check storage. Assuming storage not low", e);
+            return false;
+        }
+    }
+
+    /** Returns the list of packages to process for the given reason. */
     @NonNull
-    private List<String> getDefaultPackages(@NonNull PackageManagerLocal.FilteredSnapshot snapshot,
-            @NonNull @BatchOptimizeReason String reason) {
-        final List<String> packages;
+    private List<String> getDefaultPackages(
+            @NonNull PackageManagerLocal.FilteredSnapshot snapshot, @NonNull String reason) {
+        // Filter out hibernating packages even if the reason is REASON_INACTIVE. This is because
+        // artifacts for hibernating packages are already deleted.
+        Stream<PackageState> packages = snapshot.getPackageStates().values().stream().filter(
+                pkgState
+                -> Utils.canOptimizePackage(pkgState, mInjector.getAppHibernationManager()));
         switch (reason) {
             case ReasonMapping.REASON_BOOT_AFTER_MAINLINE_UPDATE:
-                packages =
-                        snapshot.getPackageStates()
-                                .values()
-                                .stream()
-                                .filter(pkgState
-                                        -> mInjector.isSystemUiPackage(pkgState.getPackageName()))
-                                .filter(pkgState
-                                        -> Utils.canOptimizePackage(
-                                                pkgState, mInjector.getAppHibernationManager()))
-                                .map(PackageState::getPackageName)
-                                .collect(Collectors.toList());
+                packages = packages.filter(
+                        pkgState -> mInjector.isSystemUiPackage(pkgState.getPackageName()));
+                break;
+            case ReasonMapping.REASON_INACTIVE:
+                packages = filterAndSortByLastActiveTime(
+                        packages, false /* keepRecent */, false /* descending */);
                 break;
             default:
-                // TODO(b/258818709): Filter packages by last active time.
-                packages = snapshot.getPackageStates()
-                                   .values()
-                                   .stream()
-                                   .filter(pkgState
-                                           -> Utils.canOptimizePackage(
-                                                   pkgState, mInjector.getAppHibernationManager()))
-                                   .map(PackageState::getPackageName)
-                                   .collect(Collectors.toList());
-                break;
+                // Actually, the sorting is only needed for background dexopt, but we do it for all
+                // cases for simplicity.
+                packages = filterAndSortByLastActiveTime(
+                        packages, true /* keepRecent */, true /* descending */);
         }
-        return packages;
+        return packages.map(PackageState::getPackageName).collect(Collectors.toList());
+    }
+
+    @NonNull
+    private Stream<PackageState> filterAndSortByLastActiveTime(
+            @NonNull Stream<PackageState> packages, boolean keepRecent, boolean descending) {
+        // "pm.dexopt.downgrade_after_inactive_days" is repurposed to also determine whether to
+        // optimize a package.
+        long inactiveMs = TimeUnit.DAYS.toMillis(SystemProperties.getInt(
+                "pm.dexopt.downgrade_after_inactive_days", Integer.MAX_VALUE /* def */));
+        long currentTimeMs = mInjector.getCurrentTimeMillis();
+        long thresholdTimeMs = currentTimeMs - inactiveMs;
+        return packages
+                .map(pkgState
+                        -> Pair.create(pkgState,
+                                Utils.getPackageLastActiveTime(pkgState,
+                                        mInjector.getDexUseManager(), mInjector.getUserManager())))
+                .filter(keepRecent ? (pair -> pair.second > thresholdTimeMs)
+                                   : (pair -> pair.second <= thresholdTimeMs))
+                .sorted(descending ? Comparator.comparingLong(pair -> - pair.second)
+                                   : Comparator.comparingLong(pair -> pair.second))
+                .map(pair -> pair.first);
     }
 
     @NonNull
@@ -787,6 +862,7 @@
                 getAppHibernationManager();
                 getUserManager();
                 getDexUseManager();
+                getStorageManager();
                 ArtModuleServiceInitializer.getArtModuleServiceManager();
             } else {
                 mPackageManagerLocal = null;
@@ -845,5 +921,14 @@
         public boolean isSystemUiPackage(@NonNull String packageName) {
             return packageName.equals(mContext.getString(R.string.config_systemUi));
         }
+
+        public long getCurrentTimeMillis() {
+            return System.currentTimeMillis();
+        }
+
+        @NonNull
+        public StorageManager getStorageManager() {
+            return Objects.requireNonNull(mContext.getSystemService(StorageManager.class));
+        }
     }
 }
diff --git a/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java b/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java
index 9c3a3c7..f56375b 100644
--- a/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java
+++ b/libartservice/service/java/com/android/server/art/BackgroundDexOptJob.java
@@ -196,7 +196,6 @@
     private CompletedResult run(@NonNull CancellationSignal cancellationSignal) {
         // TODO(b/254013427): Cleanup dex use info.
         // TODO(b/254013425): Cleanup unused secondary dex file artifacts.
-        // TODO(b/255565888): Downgrade inactive apps.
         long startTimeMs = SystemClock.uptimeMillis();
         OptimizeResult dexoptResult;
         try (var snapshot = mInjector.getPackageManagerLocal().withFilteredSnapshot()) {
diff --git a/libartservice/service/java/com/android/server/art/DexOptimizer.java b/libartservice/service/java/com/android/server/art/DexOptimizer.java
index c9b5f8d..6f3f7f1 100644
--- a/libartservice/service/java/com/android/server/art/DexOptimizer.java
+++ b/libartservice/service/java/com/android/server/art/DexOptimizer.java
@@ -652,6 +652,12 @@
 
         public Injector(@NonNull Context context) {
             mContext = context;
+
+            // Call the getters for various dependencies, to ensure correct initialization order.
+            getUserManager();
+            getDexUseManager();
+            getStorageManager();
+            ArtModuleServiceInitializer.getArtModuleServiceManager();
         }
 
         public boolean isSystemUiPackage(@NonNull String packageName) {
diff --git a/libartservice/service/java/com/android/server/art/Utils.java b/libartservice/service/java/com/android/server/art/Utils.java
index 7752ba7..64a33ab 100644
--- a/libartservice/service/java/com/android/server/art/Utils.java
+++ b/libartservice/service/java/com/android/server/art/Utils.java
@@ -21,6 +21,7 @@
 import android.apphibernation.AppHibernationManager;
 import android.os.ServiceManager;
 import android.os.SystemProperties;
+import android.os.UserManager;
 import android.text.TextUtils;
 import android.util.SparseArray;
 
@@ -268,6 +269,21 @@
         return true;
     }
 
+    public static long getPackageLastActiveTime(@NonNull PackageState pkgState,
+            @NonNull DexUseManagerLocal dexUseManager, @NonNull UserManager userManager) {
+        long lastUsedAtMs = dexUseManager.getPackageLastUsedAtMs(pkgState.getPackageName());
+        // The time where the last user installed the package the first time.
+        long lastFirstInstallTimeMs =
+                userManager.getUserHandles(true /* excludeDying */)
+                        .stream()
+                        .map(handle -> pkgState.getStateForUser(handle))
+                        .map(com.android.server.art.wrapper.PackageUserState::new)
+                        .map(userState -> userState.getFirstInstallTime())
+                        .max(Long::compare)
+                        .orElse(0l);
+        return Math.max(lastUsedAtMs, lastFirstInstallTimeMs);
+    }
+
     @AutoValue
     public abstract static class Abi {
         static @NonNull Abi create(
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..9b5f019
--- /dev/null
+++ b/libartservice/service/java/com/android/server/art/wrapper/PackageUserState.java
@@ -0,0 +1,39 @@
+
+/*
+ * 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 {
+    @NonNull private final com.android.server.pm.pkg.PackageUserState mPkgUserState;
+
+    public PackageUserState(@NonNull com.android.server.pm.pkg.PackageUserState pkgUserState) {
+        mPkgUserState = pkgUserState;
+    }
+
+    public long getFirstInstallTime() {
+        try {
+            return (long) mPkgUserState.getClass()
+                    .getMethod("getFirstInstallTime")
+                    .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 a18a08a..ec52551 100644
--- a/libartservice/service/java/com/android/server/art/wrapper/README.md
+++ b/libartservice/service/java/com/android/server/art/wrapper/README.md
@@ -6,3 +6,4 @@
 The mappings are:
 - `Environment`: `android.os.Environment`
 - `PackageState`: `com.android.server.pm.pkg.PackageState`
+- `PackageUserState`: `com.android.server.pm.pkg.PackageUserState`
diff --git a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
index dea9fda..44b0498 100644
--- a/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
+++ b/libartservice/service/javatests/com/android/server/art/ArtManagerLocalTest.java
@@ -31,7 +31,9 @@
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.anyLong;
 import static org.mockito.Mockito.argThat;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.isNull;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
@@ -49,6 +51,7 @@
 import android.os.SystemProperties;
 import android.os.UserHandle;
 import android.os.UserManager;
+import android.os.storage.StorageManager;
 
 import androidx.test.filters.SmallTest;
 
@@ -80,6 +83,7 @@
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
@@ -89,6 +93,12 @@
     private static final String PKG_NAME = "com.example.foo";
     private static final String PKG_NAME_SYS_UI = "com.android.systemui";
     private static final String PKG_NAME_HIBERNATING = "com.example.hibernating";
+    private static final int INACTIVE_DAYS = 1;
+    private static final long CURRENT_TIME_MS = 10000000000l;
+    private static final long RECENT_TIME_MS =
+            CURRENT_TIME_MS - TimeUnit.DAYS.toMillis(INACTIVE_DAYS) + 1;
+    private static final long NOT_RECENT_TIME_MS =
+            CURRENT_TIME_MS - TimeUnit.DAYS.toMillis(INACTIVE_DAYS) - 1;
 
     @Rule
     public StaticMockitoRule mockitoRule =
@@ -101,6 +111,8 @@
     @Mock private DexOptHelper mDexOptHelper;
     @Mock private AppHibernationManager mAppHibernationManager;
     @Mock private UserManager mUserManager;
+    @Mock private DexUseManagerLocal mDexUseManager;
+    @Mock private StorageManager mStorageManager;
     private PackageState mPkgState;
     private AndroidPackage mPkg;
     private Config mConfig;
@@ -130,6 +142,9 @@
         lenient().when(mInjector.getUserManager()).thenReturn(mUserManager);
         lenient().when(mInjector.isSystemUiPackage(any())).thenReturn(false);
         lenient().when(mInjector.isSystemUiPackage(PKG_NAME_SYS_UI)).thenReturn(true);
+        lenient().when(mInjector.getDexUseManager()).thenReturn(mDexUseManager);
+        lenient().when(mInjector.getCurrentTimeMillis()).thenReturn(CURRENT_TIME_MS);
+        lenient().when(mInjector.getStorageManager()).thenReturn(mStorageManager);
 
         lenient().when(SystemProperties.get(eq("pm.dexopt.install"))).thenReturn("speed-profile");
         lenient().when(SystemProperties.get(eq("pm.dexopt.bg-dexopt"))).thenReturn("speed-profile");
@@ -137,6 +152,7 @@
         lenient()
                 .when(SystemProperties.get(eq("pm.dexopt.boot-after-mainline-update")))
                 .thenReturn("verify");
+        lenient().when(SystemProperties.get(eq("pm.dexopt.inactive"))).thenReturn("verify");
         lenient()
                 .when(SystemProperties.getInt(eq("pm.dexopt.bg-dexopt.concurrency"), anyInt()))
                 .thenReturn(3);
@@ -144,6 +160,17 @@
                 .when(SystemProperties.getInt(
                         eq("pm.dexopt.boot-after-mainline-update.concurrency"), anyInt()))
                 .thenReturn(3);
+        lenient()
+                .when(SystemProperties.getInt(eq("pm.dexopt.inactive.concurrency"), anyInt()))
+                .thenReturn(3);
+        lenient()
+                .when(SystemProperties.getInt(
+                        eq("pm.dexopt.downgrade_after_inactive_days"), anyInt()))
+                .thenReturn(INACTIVE_DAYS);
+        lenient()
+                .when(SystemProperties.getLong(
+                        eq("pm.dexopt.storage_threshold_above_low_bytes"), anyLong()))
+                .thenReturn(1000l);
 
         // No ISA translation.
         lenient()
@@ -161,6 +188,11 @@
                 .when(mUserManager.getUserHandles(anyBoolean()))
                 .thenReturn(List.of(UserHandle.of(0), UserHandle.of(1)));
 
+        // All packages are by default recently used.
+        lenient().when(mDexUseManager.getPackageLastUsedAtMs(any())).thenReturn(RECENT_TIME_MS);
+
+        lenient().when(mStorageManager.getAllocatableBytes(any())).thenReturn(1000l);
+
         lenient().when(mPackageManagerLocal.withFilteredSnapshot()).thenReturn(mSnapshot);
         List<PackageState> pkgStates = createPackageStates();
         for (PackageState pkgState : pkgStates) {
@@ -345,23 +377,90 @@
 
     @Test
     public void testOptimizePackages() throws Exception {
-        var result = mock(OptimizeResult.class);
+        var optimizeResult = mock(OptimizeResult.class);
         var cancellationSignal = new CancellationSignal();
+        when(mDexUseManager.getPackageLastUsedAtMs(PKG_NAME_SYS_UI)).thenReturn(CURRENT_TIME_MS);
+        when(mStorageManager.getAllocatableBytes(any())).thenReturn(999l);
 
-        // It should use the default package list and params.
-        when(mDexOptHelper.dexopt(any(), inAnyOrder(PKG_NAME, PKG_NAME_SYS_UI), any(),
-                     same(cancellationSignal), any(), any(), any()))
-                .thenReturn(result);
+        // It should use the default package list and params. The list is sorted by last active
+        // time in descending order.
+        doReturn(optimizeResult)
+                .when(mDexOptHelper)
+                .dexopt(any(), deepEq(List.of(PKG_NAME_SYS_UI, PKG_NAME)),
+                        argThat(params -> params.getReason().equals("bg-dexopt")),
+                        same(cancellationSignal), any(), any(), any());
 
         assertThat(mArtManagerLocal.optimizePackages(mSnapshot, "bg-dexopt", cancellationSignal,
                            null /* processCallbackExecutor */, null /* processCallback */))
-                .isSameInstanceAs(result);
+                .isSameInstanceAs(optimizeResult);
+
+        // Nothing to downgrade.
+        verify(mDexOptHelper, never())
+                .dexopt(any(), any(), argThat(params -> params.getReason().equals("inactive")),
+                        any(), any(), any(), any());
+    }
+
+    @Test
+    public void testOptimizePackagesRecentlyInstalled() throws Exception {
+        // The package is recently installed but hasn't been used.
+        PackageUserState userState = mPkgState.getStateForUser(UserHandle.of(1));
+        when(userState.getFirstInstallTime()).thenReturn(RECENT_TIME_MS);
+        when(mDexUseManager.getPackageLastUsedAtMs(PKG_NAME)).thenReturn(0l);
+        when(mStorageManager.getAllocatableBytes(any())).thenReturn(999l);
+
+        var result = mock(OptimizeResult.class);
+        var cancellationSignal = new CancellationSignal();
+
+        // PKG_NAME should be optimized.
+        doReturn(result)
+                .when(mDexOptHelper)
+                .dexopt(any(), inAnyOrder(PKG_NAME, PKG_NAME_SYS_UI),
+                        argThat(params -> params.getReason().equals("bg-dexopt")), any(), any(),
+                        any(), any());
+
+        mArtManagerLocal.optimizePackages(mSnapshot, "bg-dexopt", cancellationSignal,
+                null /* processCallbackExecutor */, null /* processCallback */);
+
+        // PKG_NAME should not be downgraded.
+        verify(mDexOptHelper, never())
+                .dexopt(any(), any(), argThat(params -> params.getReason().equals("inactive")),
+                        any(), any(), any(), any());
+    }
+
+    @Test
+    public void testOptimizePackagesInactive() throws Exception {
+        // PKG_NAME is neither recently installed nor recently used.
+        PackageUserState userState = mPkgState.getStateForUser(UserHandle.of(1));
+        when(userState.getFirstInstallTime()).thenReturn(NOT_RECENT_TIME_MS);
+        when(mDexUseManager.getPackageLastUsedAtMs(PKG_NAME)).thenReturn(NOT_RECENT_TIME_MS);
+        when(mStorageManager.getAllocatableBytes(any())).thenReturn(999l);
+
+        var result = mock(OptimizeResult.class);
+        var cancellationSignal = new CancellationSignal();
+
+        // PKG_NAME should not be optimized.
+        doReturn(result)
+                .when(mDexOptHelper)
+                .dexopt(any(), deepEq(List.of(PKG_NAME_SYS_UI)),
+                        argThat(params -> params.getReason().equals("bg-dexopt")), any(), any(),
+                        any(), any());
+
+        // PKG_NAME should be downgraded.
+        doReturn(result)
+                .when(mDexOptHelper)
+                .dexopt(any(), deepEq(List.of(PKG_NAME)),
+                        argThat(params -> params.getReason().equals("inactive")), any(), any(),
+                        any(), any());
+
+        mArtManagerLocal.optimizePackages(mSnapshot, "bg-dexopt", cancellationSignal,
+                null /* processCallbackExecutor */, null /* processCallback */);
     }
 
     @Test
     public void testOptimizePackagesBootAfterMainlineUpdate() throws Exception {
         var result = mock(OptimizeResult.class);
         var cancellationSignal = new CancellationSignal();
+        lenient().when(mStorageManager.getAllocatableBytes(any())).thenReturn(999l);
 
         // It should only optimize system UI.
         when(mDexOptHelper.dexopt(
@@ -372,10 +471,21 @@
                            cancellationSignal, null /* processCallbackExecutor */,
                            null /* processCallback */))
                 .isSameInstanceAs(result);
+
+        // It should never downgrade apps, even if the storage is low.
+        verify(mDexOptHelper, never())
+                .dexopt(any(), any(), argThat(params -> params.getReason().equals("inactive")),
+                        any(), any(), any(), any());
     }
 
     @Test
     public void testOptimizePackagesOverride() throws Exception {
+        // PKG_NAME is neither recently installed nor recently used.
+        PackageUserState userState = mPkgState.getStateForUser(UserHandle.of(1));
+        when(userState.getFirstInstallTime()).thenReturn(NOT_RECENT_TIME_MS);
+        when(mDexUseManager.getPackageLastUsedAtMs(PKG_NAME)).thenReturn(NOT_RECENT_TIME_MS);
+        when(mStorageManager.getAllocatableBytes(any())).thenReturn(999l);
+
         var params = new OptimizeParams.Builder("bg-dexopt").build();
         var result = mock(OptimizeResult.class);
         var cancellationSignal = new CancellationSignal();
@@ -383,18 +493,23 @@
         mArtManagerLocal.setOptimizePackagesCallback(
                 ForkJoinPool.commonPool(), (snapshot, reason, defaultPackages, builder) -> {
                     assertThat(reason).isEqualTo("bg-dexopt");
-                    assertThat(defaultPackages).containsExactly(PKG_NAME, PKG_NAME_SYS_UI);
+                    assertThat(defaultPackages).containsExactly(PKG_NAME_SYS_UI);
                     builder.setPackages(List.of(PKG_NAME)).setOptimizeParams(params);
                 });
 
         // It should use the overridden package list and params.
-        when(mDexOptHelper.dexopt(any(), deepEq(List.of(PKG_NAME)), same(params),
-                     same(cancellationSignal), any(), any(), any()))
-                .thenReturn(result);
+        doReturn(result)
+                .when(mDexOptHelper)
+                .dexopt(any(), deepEq(List.of(PKG_NAME)), same(params), any(), any(), any(), any());
 
-        assertThat(mArtManagerLocal.optimizePackages(mSnapshot, "bg-dexopt", cancellationSignal,
-                           null /* processCallbackExecutor */, null /* processCallback */))
-                .isSameInstanceAs(result);
+        mArtManagerLocal.optimizePackages(mSnapshot, "bg-dexopt", cancellationSignal,
+                null /* processCallbackExecutor */, null /* processCallback */);
+
+        // It should not downgrade PKG_NAME because it's in the overridden package list. It should
+        // not downgrade PKG_NAME_SYS_UI either because it's not an inactive package.
+        verify(mDexOptHelper, never())
+                .dexopt(any(), any(), argThat(params2 -> params2.getReason().equals("inactive")),
+                        any(), any(), any(), any());
     }
 
     @Test
@@ -603,6 +718,8 @@
     private PackageUserState createPackageUserState() {
         PackageUserState pkgUserState = mock(PackageUserState.class);
         lenient().when(pkgUserState.isInstalled()).thenReturn(true);
+        // All packages are by default pre-installed.
+        lenient().when(pkgUserState.getFirstInstallTime()).thenReturn(0l);
         return pkgUserState;
     }
 
@@ -624,8 +741,10 @@
             lenient().when(pkgState.getAndroidPackage()).thenReturn(null);
         }
 
-        PackageUserState pkgUserState = createPackageUserState();
-        lenient().when(pkgState.getStateForUser(any())).thenReturn(pkgUserState);
+        PackageUserState pkgUserState0 = createPackageUserState();
+        lenient().when(pkgState.getStateForUser(UserHandle.of(0))).thenReturn(pkgUserState0);
+        PackageUserState pkgUserState1 = createPackageUserState();
+        lenient().when(pkgState.getStateForUser(UserHandle.of(1))).thenReturn(pkgUserState1);
 
         return pkgState;
     }