Schedule a maintenance job to clean up stale jobs.

+ Fix a bug in BlobMetadata.hasLeases().

Bug: 148754858
Test: atest ./services/tests/mockingservicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java
Test: adb shell cmd jobscheduler run --force android 191934935
Change-Id: I13499dd0327414bee62b8395c7e1fd349e325dd4
diff --git a/apex/blobstore/TEST_MAPPING b/apex/blobstore/TEST_MAPPING
index cfe19a5..25a1537 100644
--- a/apex/blobstore/TEST_MAPPING
+++ b/apex/blobstore/TEST_MAPPING
@@ -4,7 +4,7 @@
       "name": "CtsBlobStoreTestCases"
     },
     {
-      "name": "FrameworksServicesTests",
+      "name": "FrameworksMockingServicesTests",
       "options": [
         {
           "include-filter": "com.android.server.blob"
diff --git a/apex/blobstore/framework/java/android/app/blob/BlobHandle.java b/apex/blobstore/framework/java/android/app/blob/BlobHandle.java
index f110b36..d339afa 100644
--- a/apex/blobstore/framework/java/android/app/blob/BlobHandle.java
+++ b/apex/blobstore/framework/java/android/app/blob/BlobHandle.java
@@ -257,6 +257,11 @@
         return Base64.encodeToString(digest, Base64.NO_WRAP);
     }
 
+    /** @hide */
+    public boolean isExpired() {
+        return expiryTimeMillis != 0 && expiryTimeMillis < System.currentTimeMillis();
+    }
+
     public static final @NonNull Creator<BlobHandle> CREATOR = new Creator<BlobHandle>() {
         @Override
         public @NonNull BlobHandle createFromParcel(@NonNull Parcel source) {
diff --git a/apex/blobstore/service/java/com/android/server/blob/BlobMetadata.java b/apex/blobstore/service/java/com/android/server/blob/BlobMetadata.java
index aba3e8c..c12e0ec 100644
--- a/apex/blobstore/service/java/com/android/server/blob/BlobMetadata.java
+++ b/apex/blobstore/service/java/com/android/server/blob/BlobMetadata.java
@@ -64,9 +64,9 @@
 
     private final Context mContext;
 
-    public final long blobId;
-    public final BlobHandle blobHandle;
-    public final int userId;
+    private final long mBlobId;
+    private final BlobHandle mBlobHandle;
+    private final int mUserId;
 
     @GuardedBy("mMetadataLock")
     private final ArraySet<Committer> mCommitters = new ArraySet<>();
@@ -90,9 +90,21 @@
 
     BlobMetadata(Context context, long blobId, BlobHandle blobHandle, int userId) {
         mContext = context;
-        this.blobId = blobId;
-        this.blobHandle = blobHandle;
-        this.userId = userId;
+        this.mBlobId = blobId;
+        this.mBlobHandle = blobHandle;
+        this.mUserId = userId;
+    }
+
+    long getBlobId() {
+        return mBlobId;
+    }
+
+    BlobHandle getBlobHandle() {
+        return mBlobHandle;
+    }
+
+    int getUserId() {
+        return mUserId;
     }
 
     void addCommitter(@NonNull Committer committer) {
@@ -159,7 +171,7 @@
 
     boolean hasLeases() {
         synchronized (mMetadataLock) {
-            return mLeasees.isEmpty();
+            return !mLeasees.isEmpty();
         }
     }
 
@@ -196,7 +208,7 @@
 
     File getBlobFile() {
         if (mBlobFile == null) {
-            mBlobFile = BlobStoreConfig.getBlobFile(blobId);
+            mBlobFile = BlobStoreConfig.getBlobFile(mBlobId);
         }
         return mBlobFile;
     }
@@ -244,7 +256,7 @@
     void dump(IndentingPrintWriter fout, DumpArgs dumpArgs) {
         fout.println("blobHandle:");
         fout.increaseIndent();
-        blobHandle.dump(fout, dumpArgs.shouldDumpFull());
+        mBlobHandle.dump(fout, dumpArgs.shouldDumpFull());
         fout.decreaseIndent();
 
         fout.println("Committers:");
@@ -274,11 +286,11 @@
 
     void writeToXml(XmlSerializer out) throws IOException {
         synchronized (mMetadataLock) {
-            XmlUtils.writeLongAttribute(out, ATTR_ID, blobId);
-            XmlUtils.writeIntAttribute(out, ATTR_USER_ID, userId);
+            XmlUtils.writeLongAttribute(out, ATTR_ID, mBlobId);
+            XmlUtils.writeIntAttribute(out, ATTR_USER_ID, mUserId);
 
             out.startTag(null, TAG_BLOB_HANDLE);
-            blobHandle.writeToXml(out);
+            mBlobHandle.writeToXml(out);
             out.endTag(null, TAG_BLOB_HANDLE);
 
             for (int i = 0, count = mCommitters.size(); i < count; ++i) {
diff --git a/apex/blobstore/service/java/com/android/server/blob/BlobStoreConfig.java b/apex/blobstore/service/java/com/android/server/blob/BlobStoreConfig.java
index eb414b0..60fa23d 100644
--- a/apex/blobstore/service/java/com/android/server/blob/BlobStoreConfig.java
+++ b/apex/blobstore/service/java/com/android/server/blob/BlobStoreConfig.java
@@ -21,6 +21,7 @@
 import android.util.Slog;
 
 import java.io.File;
+import java.util.concurrent.TimeUnit;
 
 class BlobStoreConfig {
     public static final String TAG = "BlobStore";
@@ -32,6 +33,20 @@
     private static final String SESSIONS_INDEX_FILE_NAME = "sessions_index.xml";
     private static final String BLOBS_INDEX_FILE_NAME = "blobs_index.xml";
 
+    /**
+     * Job Id for idle maintenance job ({@link BlobStoreIdleJobService}).
+     */
+    public static final int IDLE_JOB_ID = 0xB70B1D7; // 191934935L
+    /**
+     * Max time period (in millis) between each idle maintenance job run.
+     */
+    public static final long IDLE_JOB_PERIOD_MILLIS = TimeUnit.DAYS.toMillis(1);
+
+    /**
+     * Timeout in millis after which sessions with no updates will be deleted.
+     */
+    public static final long SESSION_EXPIRY_TIMEOUT_MILLIS = TimeUnit.DAYS.toMillis(7);
+
     @Nullable
     public static File prepareBlobFile(long sessionId) {
         final File blobsDir = prepareBlobsDir();
diff --git a/apex/blobstore/service/java/com/android/server/blob/BlobStoreIdleJobService.java b/apex/blobstore/service/java/com/android/server/blob/BlobStoreIdleJobService.java
new file mode 100644
index 0000000..1e2a964
--- /dev/null
+++ b/apex/blobstore/service/java/com/android/server/blob/BlobStoreIdleJobService.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 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.blob;
+
+import static com.android.server.blob.BlobStoreConfig.IDLE_JOB_ID;
+import static com.android.server.blob.BlobStoreConfig.IDLE_JOB_PERIOD_MILLIS;
+
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobScheduler;
+import android.app.job.JobService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.AsyncTask;
+
+import com.android.server.LocalServices;
+
+/**
+ * Maintenance job to clean up stale sessions and blobs.
+ */
+public class BlobStoreIdleJobService extends JobService {
+    @Override
+    public boolean onStartJob(final JobParameters params) {
+        AsyncTask.execute(() -> {
+            final BlobStoreManagerInternal blobStoreManagerInternal = LocalServices.getService(
+                    BlobStoreManagerInternal.class);
+            blobStoreManagerInternal.onIdleMaintenance();
+            jobFinished(params, false);
+        });
+        return false;
+    }
+
+    @Override
+    public boolean onStopJob(final JobParameters params) {
+        return false;
+    }
+
+    static void schedule(Context context) {
+        final JobScheduler jobScheduler = (JobScheduler) context.getSystemService(
+                Context.JOB_SCHEDULER_SERVICE);
+        final JobInfo job = new JobInfo.Builder(IDLE_JOB_ID,
+                new ComponentName(context, BlobStoreIdleJobService.class))
+                        .setRequiresDeviceIdle(true)
+                        .setRequiresCharging(true)
+                        .setPeriodic(IDLE_JOB_PERIOD_MILLIS)
+                        .build();
+        jobScheduler.schedule(job);
+    }
+}
diff --git a/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerInternal.java b/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerInternal.java
new file mode 100644
index 0000000..5358245
--- /dev/null
+++ b/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerInternal.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2020 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.blob;
+
+/**
+ * BlobStoreManager local system service interface.
+ *
+ * Only for use within the system server.
+ */
+public abstract class BlobStoreManagerInternal {
+    /**
+     * Triggered from idle maintenance job to cleanup stale blobs and sessions.
+     */
+    public abstract void onIdleMaintenance();
+}
diff --git a/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerService.java b/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerService.java
index 13f095e..775cd04 100644
--- a/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerService.java
+++ b/apex/blobstore/service/java/com/android/server/blob/BlobStoreManagerService.java
@@ -28,6 +28,7 @@
 import static android.os.UserHandle.USER_NULL;
 
 import static com.android.server.blob.BlobStoreConfig.CURRENT_XML_VERSION;
+import static com.android.server.blob.BlobStoreConfig.SESSION_EXPIRY_TIMEOUT_MILLIS;
 import static com.android.server.blob.BlobStoreConfig.TAG;
 import static com.android.server.blob.BlobStoreSession.STATE_ABANDONED;
 import static com.android.server.blob.BlobStoreSession.STATE_COMMITTED;
@@ -61,6 +62,7 @@
 import android.os.UserHandle;
 import android.os.UserManagerInternal;
 import android.util.ArrayMap;
+import android.util.ArraySet;
 import android.util.AtomicFile;
 import android.util.ExceptionUtils;
 import android.util.LongSparseArray;
@@ -96,6 +98,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 
 /**
  * Service responsible for maintaining and facilitating access to data blobs published by apps.
@@ -115,6 +118,10 @@
     @GuardedBy("mBlobsLock")
     private final SparseArray<ArrayMap<BlobHandle, BlobMetadata>> mBlobsMap = new SparseArray<>();
 
+    // Contains all ids that are currently in use.
+    @GuardedBy("mBlobsLock")
+    private final ArraySet<Long> mKnownBlobIds = new ArraySet<>();
+
     private final Context mContext;
     private final Handler mHandler;
     private final Injector mInjector;
@@ -151,6 +158,7 @@
     @Override
     public void onStart() {
         publishBinderService(Context.BLOB_STORE_SERVICE, new Stub());
+        LocalServices.addService(BlobStoreManagerInternal.class, new LocalService());
 
         mPackageManagerInternal = LocalServices.getService(PackageManagerInternal.class);
         registerReceivers();
@@ -164,6 +172,8 @@
                 readBlobSessionsLocked(allPackages);
                 readBlobsInfoLocked(allPackages);
             }
+        } else if (phase == PHASE_BOOT_COMPLETED) {
+            BlobStoreIdleJobService.schedule(mContext);
         }
     }
 
@@ -215,6 +225,40 @@
         }
     }
 
+    @VisibleForTesting
+    void addKnownIdsForTest(long... knownIds) {
+        synchronized (mBlobsLock) {
+            for (long id : knownIds) {
+                mKnownBlobIds.add(id);
+            }
+        }
+    }
+
+    @VisibleForTesting
+    Set<Long> getKnownIdsForTest() {
+        synchronized (mBlobsLock) {
+            return mKnownBlobIds;
+        }
+    }
+
+    @GuardedBy("mBlobsLock")
+    private void addSessionForUserLocked(BlobStoreSession session, int userId) {
+        getUserSessionsLocked(userId).put(session.getSessionId(), session);
+        mKnownBlobIds.add(session.getSessionId());
+    }
+
+    @GuardedBy("mBlobsLock")
+    private void addBlobForUserLocked(BlobMetadata blobMetadata, int userId) {
+        addBlobForUserLocked(blobMetadata, getUserBlobsLocked(userId));
+    }
+
+    @GuardedBy("mBlobsLock")
+    private void addBlobForUserLocked(BlobMetadata blobMetadata,
+            ArrayMap<BlobHandle, BlobMetadata> userBlobs) {
+        userBlobs.put(blobMetadata.getBlobHandle(), blobMetadata);
+        mKnownBlobIds.add(blobMetadata.getBlobId());
+    }
+
     private long createSessionInternal(BlobHandle blobHandle,
             int callingUid, String callingPackage) {
         synchronized (mBlobsLock) {
@@ -223,7 +267,7 @@
             final BlobStoreSession session = new BlobStoreSession(mContext,
                     sessionId, blobHandle, callingUid, callingPackage,
                     mSessionStateChangeListener);
-            getUserSessionsLocked(UserHandle.getUserId(callingUid)).put(sessionId, session);
+            addSessionForUserLocked(session, UserHandle.getUserId(callingUid));
             writeBlobSessionsAsync();
             return sessionId;
         }
@@ -329,6 +373,7 @@
                     session.getSessionFile().delete();
                     getUserSessionsLocked(UserHandle.getUserId(session.getOwnerUid()))
                             .remove(session.getSessionId());
+                    mKnownBlobIds.remove(session.getSessionId());
                     break;
                 case STATE_COMMITTED:
                     session.verifyBlobData();
@@ -340,7 +385,7 @@
                     if (blob == null) {
                         blob = new BlobMetadata(mContext,
                                 session.getSessionId(), session.getBlobHandle(), userId);
-                        userBlobs.put(session.getBlobHandle(), blob);
+                        addBlobForUserLocked(blob, userBlobs);
                     }
                     final Committer newCommitter = new Committer(session.getOwnerPackageName(),
                             session.getOwnerUid(), session.getBlobAccessMode());
@@ -437,8 +482,8 @@
                     if (userPackages != null
                             && session.getOwnerPackageName().equals(
                                     userPackages.get(session.getOwnerUid()))) {
-                        getUserSessionsLocked(UserHandle.getUserId(session.getOwnerUid())).put(
-                                session.getSessionId(), session);
+                        addSessionForUserLocked(session,
+                                UserHandle.getUserId(session.getOwnerUid()));
                     } else {
                         // Unknown package or the session data does not belong to this package.
                         session.getSessionFile().delete();
@@ -510,16 +555,16 @@
 
                 if (TAG_BLOB.equals(in.getName())) {
                     final BlobMetadata blobMetadata = BlobMetadata.createFromXml(mContext, in);
-                    final SparseArray<String> userPackages = allPackages.get(blobMetadata.userId);
+                    final SparseArray<String> userPackages = allPackages.get(
+                            blobMetadata.getUserId());
                     if (userPackages == null) {
                         blobMetadata.getBlobFile().delete();
                     } else {
-                        getUserBlobsLocked(blobMetadata.userId).put(
-                                blobMetadata.blobHandle, blobMetadata);
+                        addBlobForUserLocked(blobMetadata, blobMetadata.getUserId());
                         blobMetadata.removeInvalidCommitters(userPackages);
                         blobMetadata.removeInvalidLeasees(userPackages);
                     }
-                    mCurrentMaxSessionId = Math.max(mCurrentMaxSessionId, blobMetadata.blobId);
+                    mCurrentMaxSessionId = Math.max(mCurrentMaxSessionId, blobMetadata.getBlobId());
                 }
             }
         } catch (Exception e) {
@@ -614,6 +659,7 @@
                 if (session.getOwnerUid() == uid
                         && session.getOwnerPackageName().equals(packageName)) {
                     session.getSessionFile().delete();
+                    mKnownBlobIds.remove(session.getSessionId());
                     indicesToRemove.add(i);
                 }
             }
@@ -633,6 +679,7 @@
                 // Delete the blob if it doesn't have any active leases.
                 if (!blobMetadata.hasLeases()) {
                     blobMetadata.getBlobFile().delete();
+                    mKnownBlobIds.remove(blobMetadata.getBlobId());
                     indicesToRemove.add(i);
                 }
             }
@@ -651,6 +698,7 @@
                 for (int i = 0, count = userSessions.size(); i < count; ++i) {
                     final BlobStoreSession session = userSessions.valueAt(i);
                     session.getSessionFile().delete();
+                    mKnownBlobIds.remove(session.getSessionId());
                 }
             }
 
@@ -660,11 +708,95 @@
                 for (int i = 0, count = userBlobs.size(); i < count; ++i) {
                     final BlobMetadata blobMetadata = userBlobs.valueAt(i);
                     blobMetadata.getBlobFile().delete();
+                    mKnownBlobIds.remove(blobMetadata.getBlobId());
                 }
             }
         }
     }
 
+    @GuardedBy("mBlobsLock")
+    @VisibleForTesting
+    void handleIdleMaintenanceLocked() {
+        // Cleanup any left over data on disk that is not part of index.
+        final ArrayList<File> filesToDelete = new ArrayList<>();
+        final File blobsDir = BlobStoreConfig.getBlobsDir();
+        if (blobsDir.exists()) {
+            for (File file : blobsDir.listFiles()) {
+                try {
+                    final long id = Long.parseLong(file.getName());
+                    if (mKnownBlobIds.indexOf(id) < 0) {
+                        filesToDelete.add(file);
+                    }
+                } catch (NumberFormatException e) {
+                    filesToDelete.add(file);
+                }
+            }
+            for (int i = 0, count = filesToDelete.size(); i < count; ++i) {
+                filesToDelete.get(i).delete();
+            }
+        }
+
+        // Cleanup any stale blobs.
+        for (int i = 0, userCount = mBlobsMap.size(); i < userCount; ++i) {
+            final ArrayMap<BlobHandle, BlobMetadata> userBlobs = mBlobsMap.valueAt(i);
+            userBlobs.entrySet().removeIf(entry -> {
+                final BlobHandle blobHandle = entry.getKey();
+                final BlobMetadata blobMetadata = entry.getValue();
+                boolean shouldRemove = false;
+
+                // Cleanup expired data blobs.
+                if (blobHandle.isExpired()) {
+                    shouldRemove = true;
+                }
+
+                // Cleanup blobs with no active leases.
+                // TODO: Exclude blobs which were just committed.
+                if (!blobMetadata.hasLeases()) {
+                    shouldRemove = true;
+                }
+
+                if (shouldRemove) {
+                    blobMetadata.getBlobFile().delete();
+                    mKnownBlobIds.remove(blobMetadata.getBlobId());
+                }
+                return shouldRemove;
+            });
+        }
+        writeBlobsInfoAsync();
+
+        // Cleanup any stale sessions.
+        final ArrayList<Integer> indicesToRemove = new ArrayList<>();
+        for (int i = 0, userCount = mSessions.size(); i < userCount; ++i) {
+            final LongSparseArray<BlobStoreSession> userSessions = mSessions.valueAt(i);
+            indicesToRemove.clear();
+            for (int j = 0, sessionsCount = userSessions.size(); j < sessionsCount; ++j) {
+                final BlobStoreSession blobStoreSession = userSessions.valueAt(j);
+                boolean shouldRemove = false;
+
+                // Cleanup sessions which haven't been modified in a while.
+                if (blobStoreSession.getSessionFile().lastModified()
+                        < System.currentTimeMillis() - SESSION_EXPIRY_TIMEOUT_MILLIS) {
+                    shouldRemove = true;
+                }
+
+                // Cleanup sessions with already expired data.
+                if (blobStoreSession.getBlobHandle().isExpired()) {
+                    shouldRemove = true;
+                }
+
+                if (shouldRemove) {
+                    blobStoreSession.getSessionFile().delete();
+                    mKnownBlobIds.remove(blobStoreSession.getSessionId());
+                    indicesToRemove.add(j);
+                }
+            }
+            for (int j = 0; j < indicesToRemove.size(); ++j) {
+                userSessions.removeAt(indicesToRemove.get(j));
+            }
+        }
+        writeBlobSessionsAsync();
+    }
+
     void runClearAllSessions(@UserIdInt int userId) {
         synchronized (mBlobsLock) {
             if (userId == UserHandle.USER_ALL) {
@@ -727,10 +859,10 @@
             fout.increaseIndent();
             for (int j = 0, blobsCount = userBlobs.size(); j < blobsCount; ++j) {
                 final BlobMetadata blobMetadata = userBlobs.valueAt(j);
-                if (!dumpArgs.shouldDumpBlob(blobMetadata.blobId)) {
+                if (!dumpArgs.shouldDumpBlob(blobMetadata.getBlobId())) {
                     continue;
                 }
-                fout.println("Blob #" + blobMetadata.blobId);
+                fout.println("Blob #" + blobMetadata.getBlobId());
                 fout.increaseIndent();
                 blobMetadata.dump(fout, dumpArgs);
                 fout.decreaseIndent();
@@ -1042,6 +1174,15 @@
         }
     }
 
+    private class LocalService extends BlobStoreManagerInternal {
+        @Override
+        public void onIdleMaintenance() {
+            synchronized (mBlobsLock) {
+                handleIdleMaintenanceLocked();
+            }
+        }
+    }
+
     @VisibleForTesting
     static class Injector {
         public Handler initializeMessageHandler() {
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index 3a1b63d..1225560 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -5289,6 +5289,10 @@
                  android:permission="android.permission.BIND_JOB_SERVICE" >
         </service>
 
+        <service android:name="com.android.server.blob.BlobStoreIdleJobService"
+                 android:permission="android.permission.BIND_JOB_SERVICE">
+        </service>
+
         <service android:name="com.android.server.pm.PackageManagerShellCommandDataLoader">
             <intent-filter>
                 <action android:name="android.intent.action.LOAD_DATA" />
diff --git a/services/tests/mockingservicestests/Android.bp b/services/tests/mockingservicestests/Android.bp
index 3d9f11f..339ff6b 100644
--- a/services/tests/mockingservicestests/Android.bp
+++ b/services/tests/mockingservicestests/Android.bp
@@ -22,6 +22,7 @@
         "services.net",
         "service-jobscheduler",
         "service-permission",
+        "service-blobstore",
         "androidx.test.runner",
         "mockito-target-extended-minus-junit4",
         "platform-test-annotations",
diff --git a/services/tests/mockingservicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java b/services/tests/mockingservicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java
new file mode 100644
index 0000000..16dde42
--- /dev/null
+++ b/services/tests/mockingservicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright 2020 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.blob;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
+import static com.android.server.blob.BlobStoreConfig.SESSION_EXPIRY_TIMEOUT_MILLIS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import android.app.blob.BlobHandle;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.UserHandle;
+import android.platform.test.annotations.Presubmit;
+import android.util.ArrayMap;
+import android.util.LongSparseArray;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.blob.BlobStoreManagerService.Injector;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+import java.io.File;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+@Presubmit
+public class BlobStoreManagerServiceTest {
+    private Context mContext;
+    private Handler mHandler;
+    private BlobStoreManagerService mService;
+
+    private MockitoSession mMockitoSession;
+
+    @Mock
+    private File mBlobsDir;
+
+    private LongSparseArray<BlobStoreSession> mUserSessions;
+    private ArrayMap<BlobHandle, BlobMetadata> mUserBlobs;
+
+    private static final String TEST_PKG1 = "com.example1";
+    private static final String TEST_PKG2 = "com.example2";
+    private static final String TEST_PKG3 = "com.example3";
+
+    private static final int TEST_UID1 = 10001;
+    private static final int TEST_UID2 = 10002;
+    private static final int TEST_UID3 = 10003;
+
+    @Before
+    public void setUp() {
+        // Share classloader to allow package private access.
+        System.setProperty("dexmaker.share_classloader", "true");
+
+        mMockitoSession = mockitoSession()
+                .initMocks(this)
+                .strictness(Strictness.LENIENT)
+                .mockStatic(BlobStoreConfig.class)
+                .startMocking();
+
+        doReturn(mBlobsDir).when(() -> BlobStoreConfig.getBlobsDir());
+        doReturn(true).when(mBlobsDir).exists();
+        doReturn(new File[0]).when(mBlobsDir).listFiles();
+
+        mContext = InstrumentationRegistry.getTargetContext();
+        mHandler = new TestHandler(Looper.getMainLooper());
+        mService = new BlobStoreManagerService(mContext, new TestInjector());
+        mUserSessions = new LongSparseArray<>();
+        mUserBlobs = new ArrayMap<>();
+
+        mService.addUserSessionsForTest(mUserSessions, UserHandle.myUserId());
+        mService.addUserBlobsForTest(mUserBlobs, UserHandle.myUserId());
+    }
+
+    @After
+    public void tearDown() {
+        if (mMockitoSession != null) {
+            mMockitoSession.finishMocking();
+        }
+    }
+
+    @Test
+    public void testHandlePackageRemoved() throws Exception {
+        // Setup sessions
+        final File sessionFile1 = mock(File.class);
+        final long sessionId1 = 11;
+        final BlobStoreSession session1 = createBlobStoreSessionMock(TEST_PKG1, TEST_UID1,
+                sessionId1, sessionFile1);
+        mUserSessions.append(sessionId1, session1);
+
+        final File sessionFile2 = mock(File.class);
+        final long sessionId2 = 25;
+        final BlobStoreSession session2 = createBlobStoreSessionMock(TEST_PKG2, TEST_UID2,
+                sessionId2, sessionFile2);
+        mUserSessions.append(sessionId2, session2);
+
+        final File sessionFile3 = mock(File.class);
+        final long sessionId3 = 37;
+        final BlobStoreSession session3 = createBlobStoreSessionMock(TEST_PKG3, TEST_UID3,
+                sessionId3, sessionFile3);
+        mUserSessions.append(sessionId3, session3);
+
+        final File sessionFile4 = mock(File.class);
+        final long sessionId4 = 48;
+        final BlobStoreSession session4 = createBlobStoreSessionMock(TEST_PKG1, TEST_UID1,
+                sessionId4, sessionFile4);
+        mUserSessions.append(sessionId4, session4);
+
+        // Setup blobs
+        final long blobId1 = 978;
+        final File blobFile1 = mock(File.class);
+        final BlobHandle blobHandle1 = BlobHandle.createWithSha256("digest1".getBytes(),
+                "label1", System.currentTimeMillis(), "tag1");
+        final BlobMetadata blobMetadata1 = createBlobMetadataMock(blobId1, blobFile1, true);
+        mUserBlobs.put(blobHandle1, blobMetadata1);
+
+        final long blobId2 = 347;
+        final File blobFile2 = mock(File.class);
+        final BlobHandle blobHandle2 = BlobHandle.createWithSha256("digest2".getBytes(),
+                "label2", System.currentTimeMillis(), "tag2");
+        final BlobMetadata blobMetadata2 = createBlobMetadataMock(blobId2, blobFile2, false);
+        mUserBlobs.put(blobHandle2, blobMetadata2);
+
+        mService.addKnownIdsForTest(sessionId1, sessionId2, sessionId3, sessionId4,
+                blobId1, blobId2);
+
+        // Invoke test method
+        mService.handlePackageRemoved(TEST_PKG1, TEST_UID1);
+
+        // Verify sessions are removed
+        verify(sessionFile1).delete();
+        verify(sessionFile2, never()).delete();
+        verify(sessionFile3, never()).delete();
+        verify(sessionFile4).delete();
+
+        assertThat(mUserSessions.size()).isEqualTo(2);
+        assertThat(mUserSessions.get(sessionId1)).isNull();
+        assertThat(mUserSessions.get(sessionId2)).isNotNull();
+        assertThat(mUserSessions.get(sessionId3)).isNotNull();
+        assertThat(mUserSessions.get(sessionId4)).isNull();
+
+        // Verify blobs are removed
+        verify(blobMetadata1).removeCommitter(TEST_PKG1, TEST_UID1);
+        verify(blobMetadata1).removeLeasee(TEST_PKG1, TEST_UID1);
+        verify(blobMetadata2).removeCommitter(TEST_PKG1, TEST_UID1);
+        verify(blobMetadata2).removeLeasee(TEST_PKG1, TEST_UID1);
+
+        verify(blobFile1, never()).delete();
+        verify(blobFile2).delete();
+
+        assertThat(mUserBlobs.size()).isEqualTo(1);
+        assertThat(mUserBlobs.get(blobHandle1)).isNotNull();
+        assertThat(mUserBlobs.get(blobHandle2)).isNull();
+
+        assertThat(mService.getKnownIdsForTest()).containsExactly(
+                sessionId2, sessionId3, blobId1);
+    }
+
+    @Test
+    public void testHandleIdleMaintenance_deleteUnknownBlobs() throws Exception {
+        // Setup blob files
+        final long testId1 = 286;
+        final File file1 = mock(File.class);
+        doReturn(String.valueOf(testId1)).when(file1).getName();
+        final long testId2 = 349;
+        final File file2 = mock(File.class);
+        doReturn(String.valueOf(testId2)).when(file2).getName();
+        final long testId3 = 7355;
+        final File file3 = mock(File.class);
+        doReturn(String.valueOf(testId3)).when(file3).getName();
+
+        doReturn(new File[] {file1, file2, file3}).when(mBlobsDir).listFiles();
+        mService.addKnownIdsForTest(testId1, testId3);
+
+        // Invoke test method
+        mService.handleIdleMaintenanceLocked();
+
+        // Verify unknown blobs are delete
+        verify(file1, never()).delete();
+        verify(file2).delete();
+        verify(file3, never()).delete();
+    }
+
+    @Test
+    public void testHandleIdleMaintenance_deleteStaleSessions() throws Exception {
+        // Setup sessions
+        final File sessionFile1 = mock(File.class);
+        doReturn(System.currentTimeMillis() - SESSION_EXPIRY_TIMEOUT_MILLIS + 1000)
+                .when(sessionFile1).lastModified();
+        final long sessionId1 = 342;
+        final BlobHandle blobHandle1 = mock(BlobHandle.class);
+        doReturn(System.currentTimeMillis() - 1000).when(blobHandle1).getExpiryTimeMillis();
+        final BlobStoreSession session1 = createBlobStoreSessionMock(TEST_PKG1, TEST_UID1,
+                sessionId1, sessionFile1, blobHandle1);
+        mUserSessions.append(sessionId1, session1);
+
+        final File sessionFile2 = mock(File.class);
+        doReturn(System.currentTimeMillis() - 20000)
+                .when(sessionFile2).lastModified();
+        final long sessionId2 = 4597;
+        final BlobHandle blobHandle2 = mock(BlobHandle.class);
+        doReturn(System.currentTimeMillis() + 20000).when(blobHandle2).getExpiryTimeMillis();
+        final BlobStoreSession session2 = createBlobStoreSessionMock(TEST_PKG2, TEST_UID2,
+                sessionId2, sessionFile2, blobHandle2);
+        mUserSessions.append(sessionId2, session2);
+
+        final File sessionFile3 = mock(File.class);
+        doReturn(System.currentTimeMillis() - SESSION_EXPIRY_TIMEOUT_MILLIS - 2000)
+                .when(sessionFile3).lastModified();
+        final long sessionId3 = 9484;
+        final BlobHandle blobHandle3 = mock(BlobHandle.class);
+        doReturn(System.currentTimeMillis() + 30000).when(blobHandle3).getExpiryTimeMillis();
+        final BlobStoreSession session3 = createBlobStoreSessionMock(TEST_PKG3, TEST_UID3,
+                sessionId3, sessionFile3, blobHandle3);
+        mUserSessions.append(sessionId3, session3);
+
+        mService.addKnownIdsForTest(sessionId1, sessionId2, sessionId3);
+
+        // Invoke test method
+        mService.handleIdleMaintenanceLocked();
+
+        // Verify stale sessions are removed
+        verify(sessionFile1).delete();
+        verify(sessionFile2, never()).delete();
+        verify(sessionFile3).delete();
+
+        assertThat(mUserSessions.size()).isEqualTo(1);
+        assertThat(mUserSessions.get(sessionId2)).isNotNull();
+
+        assertThat(mService.getKnownIdsForTest()).containsExactly(sessionId2);
+    }
+
+    @Test
+    public void testHandleIdleMaintenance_deleteStaleBlobs() throws Exception {
+        // Setup blobs
+        final long blobId1 = 3489;
+        final File blobFile1 = mock(File.class);
+        final BlobHandle blobHandle1 = BlobHandle.createWithSha256("digest1".getBytes(),
+                "label1", System.currentTimeMillis() - 2000, "tag1");
+        final BlobMetadata blobMetadata1 = createBlobMetadataMock(blobId1, blobFile1, true);
+        mUserBlobs.put(blobHandle1, blobMetadata1);
+
+        final long blobId2 = 78974;
+        final File blobFile2 = mock(File.class);
+        final BlobHandle blobHandle2 = BlobHandle.createWithSha256("digest2".getBytes(),
+                "label2", System.currentTimeMillis() + 30000, "tag2");
+        final BlobMetadata blobMetadata2 = createBlobMetadataMock(blobId2, blobFile2, true);
+        mUserBlobs.put(blobHandle2, blobMetadata2);
+
+        final long blobId3 = 97;
+        final File blobFile3 = mock(File.class);
+        final BlobHandle blobHandle3 = BlobHandle.createWithSha256("digest3".getBytes(),
+                "label3", System.currentTimeMillis() + 4400000, "tag3");
+        final BlobMetadata blobMetadata3 = createBlobMetadataMock(blobId3, blobFile3, false);
+        mUserBlobs.put(blobHandle3, blobMetadata3);
+
+        mService.addKnownIdsForTest(blobId1, blobId2, blobId3);
+
+        // Invoke test method
+        mService.handleIdleMaintenanceLocked();
+
+        // Verify stale blobs are removed
+        verify(blobFile1).delete();
+        verify(blobFile2, never()).delete();
+        verify(blobFile3).delete();
+
+        assertThat(mUserBlobs.size()).isEqualTo(1);
+        assertThat(mUserBlobs.get(blobHandle2)).isNotNull();
+
+        assertThat(mService.getKnownIdsForTest()).containsExactly(blobId2);
+    }
+
+    private BlobStoreSession createBlobStoreSessionMock(String ownerPackageName, int ownerUid,
+            long sessionId, File sessionFile) {
+        return createBlobStoreSessionMock(ownerPackageName, ownerUid, sessionId, sessionFile,
+                mock(BlobHandle.class));
+    }
+    private BlobStoreSession createBlobStoreSessionMock(String ownerPackageName, int ownerUid,
+            long sessionId, File sessionFile, BlobHandle blobHandle) {
+        final BlobStoreSession session = mock(BlobStoreSession.class);
+        doReturn(ownerPackageName).when(session).getOwnerPackageName();
+        doReturn(ownerUid).when(session).getOwnerUid();
+        doReturn(sessionId).when(session).getSessionId();
+        doReturn(sessionFile).when(session).getSessionFile();
+        doReturn(blobHandle).when(session).getBlobHandle();
+        return session;
+    }
+
+    private BlobMetadata createBlobMetadataMock(long blobId, File blobFile, boolean hasLeases) {
+        final BlobMetadata blobMetadata = mock(BlobMetadata.class);
+        doReturn(blobId).when(blobMetadata).getBlobId();
+        doReturn(blobFile).when(blobMetadata).getBlobFile();
+        doReturn(hasLeases).when(blobMetadata).hasLeases();
+        return blobMetadata;
+    }
+
+    private class TestHandler extends Handler {
+        TestHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void dispatchMessage(Message msg) {
+            // Ignore all messages
+        }
+    }
+
+    private class TestInjector extends Injector {
+        @Override
+        public Handler initializeMessageHandler() {
+            return mHandler;
+        }
+    }
+}
diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp
index 8381205..f990810 100644
--- a/services/tests/servicestests/Android.bp
+++ b/services/tests/servicestests/Android.bp
@@ -46,7 +46,6 @@
         "service-appsearch",
         "service-jobscheduler",
         "service-permission",
-        "service-blobstore",
         // TODO: remove once Android migrates to JUnit 4.12,
         // which provides assertThrows
         "testng",
diff --git a/services/tests/servicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java b/services/tests/servicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java
deleted file mode 100644
index ff728e7..0000000
--- a/services/tests/servicestests/src/com/android/server/blob/BlobStoreManagerServiceTest.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * Copyright 2020 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.blob;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import android.app.blob.BlobHandle;
-import android.content.Context;
-import android.os.Handler;
-import android.os.Looper;
-import android.os.Message;
-import android.os.UserHandle;
-import android.platform.test.annotations.Presubmit;
-import android.util.ArrayMap;
-import android.util.LongSparseArray;
-
-import androidx.test.InstrumentationRegistry;
-import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
-
-import com.android.server.blob.BlobStoreManagerService.Injector;
-import com.android.server.blob.BlobStoreManagerService.SessionStateChangeListener;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.io.File;
-
-@RunWith(AndroidJUnit4.class)
-@SmallTest
-@Presubmit
-public class BlobStoreManagerServiceTest {
-    private Context mContext;
-    private Handler mHandler;
-    private BlobStoreManagerService mService;
-
-    private LongSparseArray<BlobStoreSession> mUserSessions;
-    private ArrayMap<BlobHandle, BlobMetadata> mUserBlobs;
-
-    private SessionStateChangeListener mStateChangeListener;
-
-    private static final String TEST_PKG1 = "com.example1";
-    private static final String TEST_PKG2 = "com.example2";
-    private static final String TEST_PKG3 = "com.example3";
-
-    private static final int TEST_UID1 = 10001;
-    private static final int TEST_UID2 = 10002;
-    private static final int TEST_UID3 = 10003;
-
-    @Before
-    public void setUp() {
-        // Share classloader to allow package private access.
-        System.setProperty("dexmaker.share_classloader", "true");
-
-        mContext = InstrumentationRegistry.getTargetContext();
-        mHandler = new TestHandler(Looper.getMainLooper());
-        mService = new BlobStoreManagerService(mContext, new TestInjector());
-        mUserSessions = new LongSparseArray<>();
-        mUserBlobs = new ArrayMap<>();
-
-        mService.addUserSessionsForTest(mUserSessions, UserHandle.myUserId());
-        mService.addUserBlobsForTest(mUserBlobs, UserHandle.myUserId());
-
-        mStateChangeListener = mService.new SessionStateChangeListener();
-    }
-
-    @Test
-    public void testHandlePackageRemoved() throws Exception {
-        // Setup sessions
-        final File sessionFile1 = mock(File.class);
-        final long sessionId1 = 11;
-        final BlobStoreSession session1 = createBlobStoreSessionMock(TEST_PKG1, TEST_UID1,
-                sessionId1, sessionFile1);
-        mUserSessions.append(sessionId1, session1);
-
-        final File sessionFile2 = mock(File.class);
-        final long sessionId2 = 25;
-        final BlobStoreSession session2 = createBlobStoreSessionMock(TEST_PKG2, TEST_UID2,
-                sessionId2, sessionFile2);
-        mUserSessions.append(sessionId2, session2);
-
-        final File sessionFile3 = mock(File.class);
-        final long sessionId3 = 37;
-        final BlobStoreSession session3 = createBlobStoreSessionMock(TEST_PKG3, TEST_UID3,
-                sessionId3, sessionFile3);
-        mUserSessions.append(sessionId3, session3);
-
-        final File sessionFile4 = mock(File.class);
-        final long sessionId4 = 48;
-        final BlobStoreSession session4 = createBlobStoreSessionMock(TEST_PKG1, TEST_UID1,
-                sessionId4, sessionFile4);
-        mUserSessions.append(sessionId4, session4);
-
-        // Setup blobs
-        final File blobFile1 = mock(File.class);
-        final BlobHandle blobHandle1 = BlobHandle.createWithSha256("digest1".getBytes(),
-                "label1", System.currentTimeMillis(), "tag1");
-        final BlobMetadata blobMetadata1 = createBlobMetadataMock(blobFile1, true);
-        mUserBlobs.put(blobHandle1, blobMetadata1);
-
-        final File blobFile2 = mock(File.class);
-        final BlobHandle blobHandle2 = BlobHandle.createWithSha256("digest2".getBytes(),
-                "label2", System.currentTimeMillis(), "tag2");
-        final BlobMetadata blobMetadata2 = createBlobMetadataMock(blobFile2, false);
-        mUserBlobs.put(blobHandle2, blobMetadata2);
-
-        // Invoke test method
-        mService.handlePackageRemoved(TEST_PKG1, TEST_UID1);
-
-        // Verify sessions are removed
-        verify(sessionFile1).delete();
-        verify(sessionFile2, never()).delete();
-        verify(sessionFile3, never()).delete();
-        verify(sessionFile4).delete();
-
-        assertThat(mUserSessions.size()).isEqualTo(2);
-        assertThat(mUserSessions.get(sessionId1)).isNull();
-        assertThat(mUserSessions.get(sessionId2)).isNotNull();
-        assertThat(mUserSessions.get(sessionId3)).isNotNull();
-        assertThat(mUserSessions.get(sessionId4)).isNull();
-
-        // Verify blobs are removed
-        verify(blobMetadata1).removeCommitter(TEST_PKG1, TEST_UID1);
-        verify(blobMetadata1).removeLeasee(TEST_PKG1, TEST_UID1);
-        verify(blobMetadata2).removeCommitter(TEST_PKG1, TEST_UID1);
-        verify(blobMetadata2).removeLeasee(TEST_PKG1, TEST_UID1);
-
-        verify(blobFile1, never()).delete();
-        verify(blobFile2).delete();
-
-        assertThat(mUserBlobs.size()).isEqualTo(1);
-        assertThat(mUserBlobs.get(blobHandle1)).isNotNull();
-        assertThat(mUserBlobs.get(blobHandle2)).isNull();
-    }
-
-    private BlobStoreSession createBlobStoreSessionMock(String ownerPackageName, int ownerUid,
-            long sessionId, File sessionFile) {
-        final BlobStoreSession session = mock(BlobStoreSession.class);
-        when(session.getOwnerPackageName()).thenReturn(ownerPackageName);
-        when(session.getOwnerUid()).thenReturn(ownerUid);
-        when(session.getSessionId()).thenReturn(sessionId);
-        when(session.getSessionFile()).thenReturn(sessionFile);
-        return session;
-    }
-
-    private BlobMetadata createBlobMetadataMock(File blobFile, boolean hasLeases) {
-        final BlobMetadata blobMetadata = mock(BlobMetadata.class);
-        when(blobMetadata.getBlobFile()).thenReturn(blobFile);
-        when(blobMetadata.hasLeases()).thenReturn(hasLeases);
-        return blobMetadata;
-    }
-
-    private class TestHandler extends Handler {
-        TestHandler(Looper looper) {
-            super(looper);
-        }
-
-        @Override
-        public void dispatchMessage(Message msg) {
-            // Ignore all messages
-        }
-    }
-
-    private class TestInjector extends Injector {
-        @Override
-        public Handler initializeMessageHandler() {
-            return mHandler;
-        }
-    }
-}