[2/N] implementation of verifier controller and status tracker

+ VerifierController controls the binding of the verification service agent
+ VerificationStatusTracker manages the timeout status of a verification
+ If a verifier is connected, wait for the verifier response before starting the rest of the verification
+ Unit testing

Test: atest com.android.server.pm.verify.pkg.VerifierControllerTest
Test: atest com.android.server.pm.verify.pkg.VerificationStatusTrackerTest
Test: atest CtsPackageManagerTestCases:VerifierServiceTest

FLAG: android.content.pm.verification_service

BUG: 360129103
BUG: 360129657

Change-Id: I487c56a9e1d81c0367aa8309b792b7c61dfe9fb4
diff --git a/services/core/java/com/android/server/pm/PackageInstallerService.java b/services/core/java/com/android/server/pm/PackageInstallerService.java
index b1b1637..34d939b 100644
--- a/services/core/java/com/android/server/pm/PackageInstallerService.java
+++ b/services/core/java/com/android/server/pm/PackageInstallerService.java
@@ -126,6 +126,7 @@
 import com.android.server.SystemServiceManager;
 import com.android.server.pm.pkg.PackageStateInternal;
 import com.android.server.pm.utils.RequestThrottle;
+import com.android.server.pm.verify.pkg.VerifierController;
 
 import libcore.io.IoUtils;
 
@@ -213,6 +214,7 @@
     private final StagingManager mStagingManager;
 
     private AppOpsManager mAppOps;
+    private final VerifierController mVerifierController;
 
     private final HandlerThread mInstallThread;
     private final Handler mInstallHandler;
@@ -325,6 +327,7 @@
         mGentleUpdateHelper = new GentleUpdateHelper(
                 context, mInstallThread.getLooper(), new AppStateHelper(context));
         mPackageArchiver = new PackageArchiver(mContext, mPm);
+        mVerifierController = new VerifierController(mContext, mInstallHandler);
 
         LocalServices.getService(SystemServiceManager.class).startService(
                 new Lifecycle(context, this));
@@ -521,7 +524,8 @@
                         try {
                             session = PackageInstallerSession.readFromXml(in, mInternalCallback,
                                     mContext, mPm, mInstallThread.getLooper(), mStagingManager,
-                                    mSessionsDir, this, mSilentUpdatePolicy);
+                                    mSessionsDir, this, mSilentUpdatePolicy,
+                                    mVerifierController);
                         } catch (Exception e) {
                             Slog.e(TAG, "Could not read session", e);
                             continue;
@@ -1037,7 +1041,8 @@
                 mSilentUpdatePolicy, mInstallThread.getLooper(), mStagingManager, sessionId,
                 userId, callingUid, installSource, params, createdMillis, 0L, stageDir, stageCid,
                 null, null, false, false, false, false, null, SessionInfo.INVALID_ID,
-                false, false, false, PackageManager.INSTALL_UNKNOWN, "", null);
+                false, false, false, PackageManager.INSTALL_UNKNOWN, "", null,
+                mVerifierController);
 
         synchronized (mSessions) {
             mSessions.put(sessionId, session);
@@ -1047,6 +1052,7 @@
         mCallbacks.notifySessionCreated(session.sessionId, session.userId);
 
         mSettingsWriteRequest.schedule();
+
         if (LOGD) {
             Slog.d(TAG, "Created session id=" + sessionId + " staged=" + params.isStaged);
         }
diff --git a/services/core/java/com/android/server/pm/PackageInstallerSession.java b/services/core/java/com/android/server/pm/PackageInstallerSession.java
index ff8a69d..c581622 100644
--- a/services/core/java/com/android/server/pm/PackageInstallerSession.java
+++ b/services/core/java/com/android/server/pm/PackageInstallerSession.java
@@ -38,6 +38,7 @@
 import static android.content.pm.PackageManager.INSTALL_PARSE_FAILED_NO_CERTIFICATES;
 import static android.content.pm.PackageManager.INSTALL_STAGED;
 import static android.content.pm.PackageManager.INSTALL_SUCCEEDED;
+import static android.content.pm.verify.pkg.VerificationSession.VERIFICATION_INCOMPLETE_UNKNOWN;
 import static android.os.Process.INVALID_UID;
 import static android.provider.DeviceConfig.NAMESPACE_PACKAGE_MANAGER_SERVICE;
 import static android.system.OsConstants.O_CREAT;
@@ -87,6 +88,7 @@
 import android.content.pm.DataLoaderParams;
 import android.content.pm.DataLoaderParamsParcel;
 import android.content.pm.FileSystemControlParcel;
+import android.content.pm.Flags;
 import android.content.pm.IDataLoader;
 import android.content.pm.IDataLoaderStatusListener;
 import android.content.pm.IOnChecksumsReadyListener;
@@ -108,6 +110,7 @@
 import android.content.pm.PackageManager.PackageInfoFlags;
 import android.content.pm.PackageManagerInternal;
 import android.content.pm.SigningDetails;
+import android.content.pm.SigningInfo;
 import android.content.pm.dex.DexMetadataHelper;
 import android.content.pm.parsing.ApkLite;
 import android.content.pm.parsing.ApkLiteParseUtils;
@@ -115,6 +118,7 @@
 import android.content.pm.parsing.result.ParseResult;
 import android.content.pm.parsing.result.ParseTypeImpl;
 import android.content.pm.verify.domain.DomainSet;
+import android.content.pm.verify.pkg.VerificationStatus;
 import android.content.res.ApkAssets;
 import android.content.res.AssetManager;
 import android.content.res.Configuration;
@@ -122,6 +126,7 @@
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.icu.util.ULocale;
+import android.net.Uri;
 import android.os.Binder;
 import android.os.Build;
 import android.os.Bundle;
@@ -133,6 +138,7 @@
 import android.os.Message;
 import android.os.ParcelFileDescriptor;
 import android.os.ParcelableException;
+import android.os.PersistableBundle;
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.RevocableFileDescriptor;
@@ -190,6 +196,7 @@
 import com.android.server.pm.dex.DexManager;
 import com.android.server.pm.pkg.AndroidPackage;
 import com.android.server.pm.pkg.PackageStateInternal;
+import com.android.server.pm.verify.pkg.VerifierController;
 
 import libcore.io.IoUtils;
 import libcore.util.EmptyArray;
@@ -218,6 +225,7 @@
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Predicate;
+import java.util.function.Supplier;
 
 public class PackageInstallerSession extends IPackageInstallerSession.Stub {
     private static final String TAG = "PackageInstallerSession";
@@ -404,6 +412,7 @@
      * Note all calls must be done outside {@link #mLock} to prevent lock inversion.
      */
     private final StagingManager mStagingManager;
+    @NonNull private final VerifierController mVerifierController;
 
     final int sessionId;
     final int userId;
@@ -1156,7 +1165,8 @@
             boolean prepared, boolean committed, boolean destroyed, boolean sealed,
             @Nullable int[] childSessionIds, int parentSessionId, boolean isReady,
             boolean isFailed, boolean isApplied, int sessionErrorCode,
-            String sessionErrorMessage, DomainSet preVerifiedDomains) {
+            String sessionErrorMessage, DomainSet preVerifiedDomains,
+            @NonNull VerifierController verifierController) {
         mCallback = callback;
         mContext = context;
         mPm = pm;
@@ -1165,6 +1175,7 @@
         mSilentUpdatePolicy = silentUpdatePolicy;
         mHandler = new Handler(looper, mHandlerCallback);
         mStagingManager = stagingManager;
+        mVerifierController = verifierController;
 
         this.sessionId = sessionId;
         this.userId = userId;
@@ -1249,6 +1260,14 @@
                         "Archived installation can only use Streaming System DataLoader.");
             }
         }
+
+        if (Flags.verificationService()) {
+            // Start binding to the verification service, if not bound already.
+            mVerifierController.bindToVerifierServiceIfNeeded(() -> pm.snapshotComputer(), userId);
+            if (!TextUtils.isEmpty(params.appPackageName)) {
+                mVerifierController.notifyPackageNameAvailable(params.appPackageName);
+            }
+        }
     }
 
     PackageInstallerHistoricalSession createHistoricalSession() {
@@ -2821,7 +2840,35 @@
             // since installation is in progress.
             activate();
         }
+        if (Flags.verificationService()) {
+            final Supplier<Computer> snapshotSupplier = mPm::snapshotComputer;
+            if (mVerifierController.isVerifierInstalled(snapshotSupplier, userId)) {
+                // TODO: extract shared library declarations
+                final SigningInfo signingInfo;
+                synchronized (mLock) {
+                    signingInfo = new SigningInfo(mSigningDetails);
+                }
+                // Send the request to the verifier and wait for its response before the rest of
+                // the installation can proceed.
+                if (!mVerifierController.startVerificationSession(snapshotSupplier, userId,
+                        sessionId, params.appPackageName, Uri.fromFile(stageDir), signingInfo,
+                        /* declaredLibraries= */null, /* extensionParams= */ null,
+                        new VerifierCallback(), /* retry= */ false)) {
+                    // A verifier is installed but cannot be connected. Installation disallowed.
+                    onSessionVerificationFailure(INSTALL_FAILED_INTERNAL_ERROR,
+                            "A verifier agent is available on device but cannot be connected.");
+                }
+            } else {
+                // Verifier is not installed. Let the installation pass for now.
+                resumeVerify();
+            }
+        } else {
+            // New verification feature is not enabled. Proceed to the rest of the verification.
+            resumeVerify();
+        }
+    }
 
+    private void resumeVerify() {
         if (mVerificationInProgress) {
             Slog.w(TAG, "Verification is already in progress for session " + sessionId);
             return;
@@ -2856,6 +2903,66 @@
         }
     }
 
+    /**
+     * Used for the VerifierController to report status back.
+     */
+    public class VerifierCallback {
+        /**
+         * Called by the VerifierController when the connection has failed.
+         */
+        public void onConnectionFailed() {
+            mHandler.post(() -> {
+                onSessionVerificationFailure(INSTALL_FAILED_VERIFICATION_FAILURE,
+                            "A verifier agent is available on device but cannot be connected.");
+            });
+        }
+        /**
+         * Called by the VerifierController when the verification request has timed out.
+         */
+        public void onTimeout() {
+            mHandler.post(() -> {
+                mVerifierController.notifyVerificationTimeout(sessionId);
+                onSessionVerificationFailure(INSTALL_FAILED_VERIFICATION_FAILURE,
+                        "Verification timed out; missing a response from the verifier within the"
+                                + " time limit");
+            });
+        }
+        /**
+         * Called by the VerifierController when the verification request has received a complete
+         * response.
+         */
+        public void onVerificationCompleteReceived(@NonNull VerificationStatus statusReceived,
+                @Nullable PersistableBundle extensionResponse) {
+            // TODO: handle extension response
+            mHandler.post(() -> {
+                if (statusReceived.isVerified()) {
+                    // Continue with the rest of the verification and installation.
+                    resumeVerify();
+                } else {
+                    StringBuilder sb = new StringBuilder("Verifier rejected the installation");
+                    if (!TextUtils.isEmpty(statusReceived.getFailureMessage())) {
+                        sb.append(" with message: ").append(statusReceived.getFailureMessage());
+                    }
+                    onSessionVerificationFailure(INSTALL_FAILED_VERIFICATION_FAILURE,
+                            sb.toString());
+                }
+            });
+        }
+        /**
+         * Called by the VerifierController when the verification request has received an incomplete
+         * response.
+         */
+        public void onVerificationIncompleteReceived(int incompleteReason) {
+            mHandler.post(() -> {
+                if (incompleteReason == VERIFICATION_INCOMPLETE_UNKNOWN) {
+                    // TODO: change this to a user confirmation and handle other incomplete reasons
+                    onSessionVerificationFailure(INSTALL_FAILED_INTERNAL_ERROR,
+                            "Verification cannot be completed for unknown reasons.");
+                }
+            });
+        }
+    }
+
     private IntentSender getRemoteStatusReceiver() {
         synchronized (mLock) {
             return mRemoteStatusReceiver;
@@ -5369,6 +5476,14 @@
             }
         } catch (InstallerException ignored) {
         }
+        if (Flags.verificationService()
+                && !TextUtils.isEmpty(params.appPackageName)
+                && !isCommitted()) {
+            // Only notify for the cancellation if the verification request has not
+            // been sent out, which happens right after commit() is called.
+            mVerifierController.notifyVerificationCancelled(
+                    params.appPackageName);
+        }
     }
 
     void dump(IndentingPrintWriter pw) {
@@ -5768,7 +5883,8 @@
             @NonNull PackageManagerService pm, Looper installerThread,
             @NonNull StagingManager stagingManager, @NonNull File sessionsDir,
             @NonNull PackageSessionProvider sessionProvider,
-            @NonNull SilentUpdatePolicy silentUpdatePolicy)
+            @NonNull SilentUpdatePolicy silentUpdatePolicy,
+            @NonNull VerifierController verifierController)
             throws IOException, XmlPullParserException {
         final int sessionId = in.getAttributeInt(null, ATTR_SESSION_ID);
         final int userId = in.getAttributeInt(null, ATTR_USER_ID);
@@ -5972,6 +6088,6 @@
                 installerUid, installSource, params, createdMillis, committedMillis, stageDir,
                 stageCid, fileArray, checksumsMap, prepared, committed, destroyed, sealed,
                 childSessionIdsArray, parentSessionId, isReady, isFailed, isApplied,
-                sessionErrorCode, sessionErrorMessage, preVerifiedDomains);
+                sessionErrorCode, sessionErrorMessage, preVerifiedDomains, verifierController);
     }
 }
diff --git a/services/core/java/com/android/server/pm/verify/pkg/VerificationStatusTracker.java b/services/core/java/com/android/server/pm/verify/pkg/VerificationStatusTracker.java
new file mode 100644
index 0000000..db747f9
--- /dev/null
+++ b/services/core/java/com/android/server/pm/verify/pkg/VerificationStatusTracker.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2024 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.pm.verify.pkg;
+
+import android.annotation.CurrentTimeMillisLong;
+import android.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * This class keeps record of the current timeout status of a verification request.
+ */
+public final class VerificationStatusTracker {
+    private final @CurrentTimeMillisLong long mStartTime;
+    private @CurrentTimeMillisLong long mTimeoutTime;
+    private final @CurrentTimeMillisLong long mMaxTimeoutTime;
+    @NonNull
+    private final VerifierController.Injector mInjector;
+    // Record the package name associated with the verification result
+    @NonNull
+    private final String mPackageName;
+
+    /**
+     * By default, the timeout time is the default timeout duration plus the current time (when
+     * the timer starts for a verification request). Both the default timeout time and the max
+     * timeout time cannot be changed after the timer has started, but the actual timeout time
+     * can be extended via {@link #extendTimeRemaining} to the maximum allowed.
+     */
+    @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED)
+    public VerificationStatusTracker(@NonNull String packageName,
+            long defaultTimeoutMillis, long maxExtendedTimeoutMillis,
+            @NonNull VerifierController.Injector injector) {
+        mPackageName = packageName;
+        mStartTime = injector.getCurrentTimeMillis();
+        mTimeoutTime = mStartTime + defaultTimeoutMillis;
+        mMaxTimeoutTime = mStartTime + maxExtendedTimeoutMillis;
+        mInjector = injector;
+    }
+
+    /**
+     * Used by the controller to inform the verifier agent about the timestamp when the verification
+     * request will timeout.
+     */
+    public @CurrentTimeMillisLong long getTimeoutTime() {
+        return mTimeoutTime;
+    }
+
+    /**
+     * Used by the controller to decide when to check for timeout again.
+     * @return 0 if the timeout time has been reached, otherwise the remaining time in milliseconds
+     * before the timeout is reached.
+     */
+    public @CurrentTimeMillisLong long getRemainingTime() {
+        final long remainingTime = mTimeoutTime - mInjector.getCurrentTimeMillis();
+        if (remainingTime < 0) {
+            return 0;
+        }
+        return remainingTime;
+    }
+
+    /**
+     * Used by the controller to extend the timeout duration of the verification request, upon
+     * receiving the callback from the verifier agent.
+     * @return the amount of time in millis that the timeout has been extended, subject to the max
+     * amount allowed.
+     */
+    public long extendTimeRemaining(@CurrentTimeMillisLong long additionalMs) {
+        if (mTimeoutTime + additionalMs > mMaxTimeoutTime) {
+            additionalMs = mMaxTimeoutTime - mTimeoutTime;
+        }
+        mTimeoutTime += additionalMs;
+        return additionalMs;
+    }
+
+    /**
+     * Used by the controller to get the timeout status of the request.
+     * @return False if the request still has some time left before timeout, otherwise return True.
+     */
+    public boolean isTimeout() {
+        return mInjector.getCurrentTimeMillis() >= mTimeoutTime;
+    }
+
+    @NonNull
+    public String getPackageName() {
+        return mPackageName;
+    }
+}
diff --git a/services/core/java/com/android/server/pm/verify/pkg/VerifierController.java b/services/core/java/com/android/server/pm/verify/pkg/VerifierController.java
new file mode 100644
index 0000000..7eac940
--- /dev/null
+++ b/services/core/java/com/android/server/pm/verify/pkg/VerifierController.java
@@ -0,0 +1,645 @@
+/*
+ * Copyright (C) 2024 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.pm.verify.pkg;
+
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE;
+import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE;
+import static android.os.Process.SYSTEM_UID;
+import static android.provider.DeviceConfig.NAMESPACE_PACKAGE_MANAGER_SERVICE;
+
+import android.Manifest;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.pm.SharedLibraryInfo;
+import android.content.pm.SigningInfo;
+import android.content.pm.verify.pkg.IVerificationSessionCallback;
+import android.content.pm.verify.pkg.IVerificationSessionInterface;
+import android.content.pm.verify.pkg.IVerifierService;
+import android.content.pm.verify.pkg.VerificationSession;
+import android.content.pm.verify.pkg.VerificationStatus;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.provider.DeviceConfig;
+import android.util.Pair;
+import android.util.Slog;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.infra.AndroidFuture;
+import com.android.internal.infra.ServiceConnector;
+import com.android.server.pm.Computer;
+import com.android.server.pm.PackageInstallerSession;
+import com.android.server.pm.pkg.PackageStateInternal;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * This class manages the bind to the verifier agent installed on the device that implements
+ * {@link android.content.pm.verify.pkg.VerifierService} and handles all its interactions.
+ */
+public class VerifierController {
+    private static final String TAG = "VerifierController";
+    private static final boolean DEBUG = false;
+
+    /**
+     * Configurable maximum amount of time in milliseconds to wait for a verifier to respond to
+     * a verification request.
+     * Flag type: {@code long}
+     * Namespace: NAMESPACE_PACKAGE_MANAGER_SERVICE
+     */
+    private static final String PROPERTY_VERIFICATION_REQUEST_TIMEOUT_MILLIS =
+            "verification_request_timeout_millis";
+    // Default duration to wait for a verifier to respond to a verification request.
+    private static final long DEFAULT_VERIFICATION_REQUEST_TIMEOUT_MILLIS =
+            TimeUnit.MINUTES.toMillis(1);
+    /**
+     * Configurable maximum amount of time in milliseconds that the verifier can request to extend
+     * the verification request timeout duration to. This is the maximum amount of time the system
+     * can wait for a request before it times out.
+     * Flag type: {@code long}
+     * Namespace: NAMESPACE_PACKAGE_MANAGER_SERVICE
+     */
+    private static final String PROPERTY_MAX_VERIFICATION_REQUEST_EXTENDED_TIMEOUT_MILLIS =
+            "max_verification_request_extended_timeout_millis";
+    // Max duration allowed to wait for a verifier to respond to a verification request.
+    private static final long DEFAULT_MAX_VERIFICATION_REQUEST_EXTENDED_TIMEOUT_MILLIS =
+            TimeUnit.MINUTES.toMillis(10);
+    // The maximum amount of time to wait from the moment when the session requires a verification,
+    // till when the request is delivered to the verifier, pending the connection to be established.
+    private static final long CONNECTION_TIMEOUT_SECONDS = 10;
+    // The maximum amount of time to wait before the system unbinds from the verifier.
+    private static final long UNBIND_TIMEOUT_MILLIS = TimeUnit.HOURS.toMillis(6);
+
+    private final Context mContext;
+    private final Handler mHandler;
+    @Nullable
+    private ServiceConnector<IVerifierService> mRemoteService;
+    @Nullable
+    private ComponentName mRemoteServiceComponentName;
+    @NonNull
+    private Injector mInjector;
+
+    // Repository of active verification sessions and their status, mapping from id to status.
+    @NonNull
+    @GuardedBy("mVerificationStatus")
+    private final SparseArray<VerificationStatusTracker> mVerificationStatus = new SparseArray<>();
+
+    public VerifierController(@NonNull Context context, @NonNull Handler handler) {
+        this(context, handler, new Injector());
+    }
+
+    @VisibleForTesting
+    public VerifierController(@NonNull Context context, @NonNull Handler handler,
+            @NonNull Injector injector) {
+        mContext = context;
+        mHandler = handler;
+        mInjector = injector;
+    }
+
+    /**
+     * Used by the installation session to check if a verifier is installed.
+     */
+    public boolean isVerifierInstalled(Supplier<Computer> snapshotSupplier, int userId) {
+        if (isVerifierConnected()) {
+            // Verifier is connected or is being connected, so it must be installed.
+            return true;
+        }
+        // Verifier has been disconnected, or it hasn't been connected. Check if it's installed.
+        return mInjector.isVerifierInstalled(snapshotSupplier.get(), userId);
+    }
+
+    /**
+     * Called to start querying and binding to a qualified verifier agent.
+     *
+     * @return False if a qualified verifier agent doesn't exist on device, so that the system can
+     * handle this situation immediately after the call.
+     * <p>
+     * Notice that since this is an async call, even if this method returns true, it doesn't
+     * necessarily mean that the binding connection was successful. However, the system will only
+     * try to bind once per installation session, so that it doesn't waste resource by repeatedly
+     * trying to bind if the verifier agent isn't available during a short amount of time.
+     * <p>
+     * If the verifier agent exists but cannot be started for some reason, all the notify* methods
+     * in this class will fail asynchronously and quietly. The system will learn about the failure
+     * after receiving the failure from
+     * {@link PackageInstallerSession.VerifierCallback#onConnectionFailed}.
+     */
+    public boolean bindToVerifierServiceIfNeeded(Supplier<Computer> snapshotSupplier, int userId) {
+        if (DEBUG) {
+            Slog.i(TAG, "Requesting to bind to the verifier service.");
+        }
+        if (mRemoteService != null) {
+            // Already connected
+            if (DEBUG) {
+                Slog.i(TAG, "Verifier service is already connected.");
+            }
+            return true;
+        }
+        Pair<ServiceConnector<IVerifierService>, ComponentName> result =
+                mInjector.getRemoteService(snapshotSupplier.get(), mContext, userId, mHandler);
+        if (result == null || result.first == null) {
+            if (DEBUG) {
+                Slog.i(TAG, "Unable to find a qualified verifier.");
+            }
+            return false;
+        }
+        mRemoteService = result.first;
+        mRemoteServiceComponentName = result.second;
+        if (DEBUG) {
+            Slog.i(TAG, "Connecting to a qualified verifier: " + mRemoteServiceComponentName);
+        }
+        mRemoteService.setServiceLifecycleCallbacks(
+                new ServiceConnector.ServiceLifecycleCallbacks<>() {
+                    @Override
+                    public void onConnected(@NonNull IVerifierService service) {
+                        Slog.i(TAG, "Verifier " + mRemoteServiceComponentName + " is connected");
+                    }
+
+                    @Override
+                    public void onDisconnected(@NonNull IVerifierService service) {
+                        Slog.w(TAG,
+                                "Verifier " + mRemoteServiceComponentName + " is disconnected");
+                        destroy();
+                    }
+
+                    @Override
+                    public void onBinderDied() {
+                        Slog.w(TAG, "Verifier " + mRemoteServiceComponentName + " has died");
+                        destroy();
+                    }
+
+                    private void destroy() {
+                        if (isVerifierConnected()) {
+                            mRemoteService.unbind();
+                            mRemoteService = null;
+                            mRemoteServiceComponentName = null;
+                        }
+                    }
+                });
+        AndroidFuture<IVerifierService> unusedFuture = mRemoteService.connect();
+        return true;
+    }
+
+    private boolean isVerifierConnected() {
+        return mRemoteService != null && mRemoteServiceComponentName != null;
+    }
+
+    /**
+     * Called to notify the bound verifier agent that a package name is available and will soon be
+     * requested for verification.
+     */
+    public void notifyPackageNameAvailable(@NonNull String packageName) {
+        if (!isVerifierConnected()) {
+            if (DEBUG) {
+                Slog.i(TAG, "Verifier is not connected. Not notifying package name available");
+            }
+            return;
+        }
+        // Best effort. We don't check for the result.
+        mRemoteService.run(service -> {
+            if (DEBUG) {
+                Slog.i(TAG, "Notifying package name available for " + packageName);
+            }
+            service.onPackageNameAvailable(packageName);
+        });
+    }
+
+    /**
+     * Called to notify the bound verifier agent that a package previously notified via
+     * {@link android.content.pm.verify.pkg.VerifierService#onPackageNameAvailable(String)}
+     * will no longer be requested for verification, possibly because the installation is canceled.
+     */
+    public void notifyVerificationCancelled(@NonNull String packageName) {
+        if (!isVerifierConnected()) {
+            if (DEBUG) {
+                Slog.i(TAG, "Verifier is not connected. Not notifying verification cancelled");
+            }
+            return;
+        }
+        // Best effort. We don't check for the result.
+        mRemoteService.run(service -> {
+            if (DEBUG) {
+                Slog.i(TAG, "Notifying verification cancelled for " + packageName);
+            }
+            service.onVerificationCancelled(packageName);
+        });
+    }
+
+    /**
+     * Called to notify the bound verifier agent that a package that's pending installation needs
+     * to be verified right now.
+     * <p>The verification request must be sent to the verifier as soon as the verifier is
+     * connected. If the connection cannot be made within {@link #CONNECTION_TIMEOUT_SECONDS}</p>
+     * of when the request is sent out, we consider the verification to be failed and notify the
+     * installation session.</p>
+     * <p>If a response is not returned from the verifier agent within a timeout duration from the
+     * time the request is sent to the verifier, the verification will be considered a failure.</p>
+     *
+     * @param retry whether this request is for retrying a previously incomplete verification.
+     */
+    public boolean startVerificationSession(Supplier<Computer> snapshotSupplier, int userId,
+            int installationSessionId, String packageName,
+            Uri stagedPackageUri, SigningInfo signingInfo,
+            List<SharedLibraryInfo> declaredLibraries,
+            PersistableBundle extensionParams, PackageInstallerSession.VerifierCallback callback,
+            boolean retry) {
+        // Try connecting to the verifier if not already connected
+        if (!bindToVerifierServiceIfNeeded(snapshotSupplier, userId)) {
+            return false;
+        }
+        if (!isVerifierConnected()) {
+            if (DEBUG) {
+                Slog.i(TAG, "Verifier is not connected. Not notifying verification required");
+            }
+            // Normally this should not happen because we just tried to bind. But if the verifier
+            // just crashed or just became unavailable, we should notify the installation session so
+            // it can finish with a verification failure.
+            return false;
+        }
+        // For now, the verification id is the same as the installation session id.
+        final int verificationId = installationSessionId;
+        final VerificationSession session = new VerificationSession(
+                /* id= */ verificationId,
+                /* installSessionId= */ installationSessionId,
+                packageName, stagedPackageUri, signingInfo, declaredLibraries, extensionParams,
+                new VerificationSessionInterface(),
+                new VerificationSessionCallback(callback));
+        AndroidFuture<Void> unusedFuture = mRemoteService.post(service -> {
+            if (!retry) {
+                if (DEBUG) {
+                    Slog.i(TAG, "Notifying verification required for session " + verificationId);
+                }
+                service.onVerificationRequired(session);
+            } else {
+                if (DEBUG) {
+                    Slog.i(TAG, "Notifying verification retry for session " + verificationId);
+                }
+                service.onVerificationRetry(session);
+            }
+        }).orTimeout(CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS).whenComplete((res, err) -> {
+            if (err != null) {
+                Slog.e(TAG, "Error notifying verification request for session " + verificationId,
+                        err);
+                // Notify the installation session so it can finish with verification failure.
+                callback.onConnectionFailed();
+            }
+        });
+        // Keep track of the session status with the ID. Start counting down the session timeout.
+        final long defaultTimeoutMillis = mInjector.getVerificationRequestTimeoutMillis();
+        final long maxExtendedTimeoutMillis = mInjector.getMaxVerificationExtendedTimeoutMillis();
+        final VerificationStatusTracker tracker = new VerificationStatusTracker(
+                packageName, defaultTimeoutMillis, maxExtendedTimeoutMillis, mInjector);
+        synchronized (mVerificationStatus) {
+            mVerificationStatus.put(verificationId, tracker);
+        }
+        startTimeoutCountdown(verificationId, tracker, callback, defaultTimeoutMillis);
+        return true;
+    }
+
+    private void startTimeoutCountdown(int verificationId, VerificationStatusTracker tracker,
+            PackageInstallerSession.VerifierCallback callback, long delayMillis) {
+        mHandler.postDelayed(() -> {
+            if (DEBUG) {
+                Slog.i(TAG, "Checking request timeout for " + verificationId);
+            }
+            if (!tracker.isTimeout()) {
+                if (DEBUG) {
+                    Slog.i(TAG, "Timeout is not met for " + verificationId + "; check later.");
+                }
+                // If the current session is not timed out yet, check again later.
+                startTimeoutCountdown(verificationId, tracker, callback,
+                        /* delayMillis= */ tracker.getRemainingTime());
+            } else {
+                if (DEBUG) {
+                    Slog.i(TAG, "Request " + verificationId + " has timed out.");
+                }
+                // The request has timed out. Notify the installation session.
+                callback.onTimeout();
+                // Remove status tracking and stop the timeout countdown
+                removeStatusTracker(verificationId);
+            }
+        }, /* token= */ tracker, delayMillis);
+    }
+
+    /**
+     * Called to notify the bound verifier agent that a verification request has timed out.
+     */
+    public void notifyVerificationTimeout(int verificationId) {
+        if (!isVerifierConnected()) {
+            if (DEBUG) {
+                Slog.i(TAG,
+                        "Verifier is not connected. Not notifying timeout for " + verificationId);
+            }
+            return;
+        }
+        AndroidFuture<Void> unusedFuture = mRemoteService.post(service -> {
+            if (DEBUG) {
+                Slog.i(TAG, "Notifying timeout for " + verificationId);
+            }
+            service.onVerificationTimeout(verificationId);
+        }).whenComplete((res, err) -> {
+            if (err != null) {
+                Slog.e(TAG, "Error notifying VerificationTimeout for session "
+                        + verificationId, (Throwable) err);
+            }
+        });
+    }
+
+    /**
+     * Remove a status tracker after it's no longer needed.
+     */
+    private void removeStatusTracker(int verificationId) {
+        if (DEBUG) {
+            Slog.i(TAG, "Removing status tracking for verification " + verificationId);
+        }
+        synchronized (mVerificationStatus) {
+            VerificationStatusTracker tracker = mVerificationStatus.removeReturnOld(verificationId);
+            // Cancel the timeout counters if there's any
+            if (tracker != null) {
+                mInjector.stopTimeoutCountdown(mHandler, tracker);
+            }
+        }
+    }
+
+    @RequiresPermission(Manifest.permission.VERIFICATION_AGENT)
+    private void checkCallerPermission() {
+        // TODO: think of a better way to test it on non-eng builds
+        if (Build.IS_ENG) {
+            return;
+        }
+        if (mContext.checkCallingOrSelfPermission(Manifest.permission.VERIFICATION_AGENT)
+                != PackageManager.PERMISSION_GRANTED) {
+            throw new SecurityException("You need the"
+                    + " com.android.permission.VERIFICATION_AGENT permission"
+                    + " to use VerificationSession APIs.");
+        }
+    }
+
+    // This class handles requests from the remote verifier
+    private class VerificationSessionInterface extends IVerificationSessionInterface.Stub {
+        @Override
+        public long getTimeoutTime(int verificationId) {
+            checkCallerPermission();
+            synchronized (mVerificationStatus) {
+                final VerificationStatusTracker tracker = mVerificationStatus.get(verificationId);
+                if (tracker == null) {
+                    throw new IllegalStateException("Verification session " + verificationId
+                            + " doesn't exist or has finished");
+                }
+                return tracker.getTimeoutTime();
+            }
+        }
+
+        @Override
+        public long extendTimeRemaining(int verificationId, long additionalMs) {
+            checkCallerPermission();
+            synchronized (mVerificationStatus) {
+                final VerificationStatusTracker tracker = mVerificationStatus.get(verificationId);
+                if (tracker == null) {
+                    throw new IllegalStateException("Verification session " + verificationId
+                            + " doesn't exist or has finished");
+                }
+                return tracker.extendTimeRemaining(additionalMs);
+            }
+        }
+    }
+
+    private class VerificationSessionCallback extends IVerificationSessionCallback.Stub {
+        private final PackageInstallerSession.VerifierCallback mCallback;
+
+        VerificationSessionCallback(PackageInstallerSession.VerifierCallback callback) {
+            mCallback = callback;
+        }
+
+        @Override
+        public void reportVerificationIncomplete(int id, int reason) throws RemoteException {
+            checkCallerPermission();
+            final VerificationStatusTracker tracker;
+            synchronized (mVerificationStatus) {
+                tracker = mVerificationStatus.get(id);
+                if (tracker == null) {
+                    throw new IllegalStateException("Verification session " + id
+                            + " doesn't exist or has finished");
+                }
+                mCallback.onVerificationIncompleteReceived(reason);
+            }
+            // Remove status tracking and stop the timeout countdown
+            removeStatusTracker(id);
+        }
+
+        @Override
+        public void reportVerificationComplete(int id, VerificationStatus verificationStatus)
+                throws RemoteException {
+            reportVerificationCompleteWithExtensionResponse(id, verificationStatus,
+                    /* extensionResponse= */ null);
+        }
+
+        @Override
+        public void reportVerificationCompleteWithExtensionResponse(int id,
+                VerificationStatus verificationStatus, PersistableBundle extensionResponse)
+                throws RemoteException {
+            checkCallerPermission();
+            final VerificationStatusTracker tracker;
+            synchronized (mVerificationStatus) {
+                tracker = mVerificationStatus.get(id);
+                if (tracker == null) {
+                    throw new IllegalStateException("Verification session " + id
+                            + " doesn't exist or has finished");
+                }
+            }
+            mCallback.onVerificationCompleteReceived(verificationStatus, extensionResponse);
+            // Remove status tracking and stop the timeout countdown
+            removeStatusTracker(id);
+        }
+    }
+
+    @VisibleForTesting
+    public static class Injector {
+        /**
+         * Mock this method to inject the remote service to enable unit testing.
+         */
+        @Nullable
+        public Pair<ServiceConnector<IVerifierService>, ComponentName> getRemoteService(
+                @NonNull Computer snapshot, @NonNull Context context, int userId,
+                @NonNull Handler handler) {
+            final ComponentName verifierComponent = resolveVerifierComponentName(snapshot, userId);
+            if (verifierComponent == null) {
+                return null;
+            }
+            final Intent intent = new Intent(PackageManager.ACTION_VERIFY_PACKAGE);
+            intent.setComponent(verifierComponent);
+            return new Pair<>(new ServiceConnector.Impl<IVerifierService>(
+                    context, intent, Context.BIND_AUTO_CREATE, userId,
+                    IVerifierService.Stub::asInterface) {
+                @Override
+                protected Handler getJobHandler() {
+                    return handler;
+                }
+
+                @Override
+                protected long getRequestTimeoutMs() {
+                    return getVerificationRequestTimeoutMillis();
+                }
+
+                @Override
+                protected long getAutoDisconnectTimeoutMs() {
+                    return UNBIND_TIMEOUT_MILLIS;
+                }
+            }, verifierComponent);
+        }
+
+        /**
+         * Check if a verifier is installed on this device.
+         */
+        public boolean isVerifierInstalled(Computer snapshot, int userId) {
+            return resolveVerifierComponentName(snapshot, userId) != null;
+        }
+
+        /**
+         * Find the ComponentName of the verifier service agent, using the intent action.
+         * If multiple qualified verifier services are present, the one with the highest intent
+         * filter priority will be chosen.
+         */
+        private static @Nullable ComponentName resolveVerifierComponentName(Computer snapshot,
+                int userId) {
+            final Intent intent = new Intent(PackageManager.ACTION_VERIFY_PACKAGE);
+            final int resolveFlags = MATCH_DIRECT_BOOT_AWARE | MATCH_DIRECT_BOOT_UNAWARE;
+            final List<ResolveInfo> matchedServices = snapshot.queryIntentServicesInternal(
+                    intent, null,
+                    resolveFlags, userId, SYSTEM_UID, Process.INVALID_PID,
+                    /*includeInstantApps*/ false, /*resolveForStart*/ false);
+            if (matchedServices.isEmpty()) {
+                Slog.w(TAG,
+                        "Failed to find any matching verifier service agent");
+                return null;
+            }
+            ResolveInfo best = null;
+            int numMatchedServices = matchedServices.size();
+            for (int i = 0; i < numMatchedServices; i++) {
+                ResolveInfo cur = matchedServices.get(i);
+                if (!isQualifiedVerifier(snapshot, cur, userId)) {
+                    continue;
+                }
+                if (best == null || cur.priority > best.priority) {
+                    best = cur;
+                }
+            }
+            if (best != null) {
+                Slog.i(TAG, "Found verifier service agent: "
+                        + best.getComponentInfo().getComponentName().toShortString());
+                return best.getComponentInfo().getComponentName();
+            }
+            Slog.w(TAG, "Didn't find any qualified verifier service agent.");
+            return null;
+        }
+
+        @SuppressLint("AndroidFrameworkRequiresPermission")
+        private static boolean isQualifiedVerifier(Computer snapshot, ResolveInfo ri, int userId) {
+            // Basic null checks
+            if (ri.getComponentInfo() == null) {
+                return false;
+            }
+            final ApplicationInfo applicationInfo = ri.getComponentInfo().applicationInfo;
+            if (applicationInfo == null) {
+                return false;
+            }
+            // Check for installed state
+            PackageStateInternal ps = snapshot.getPackageStateInternal(
+                    ri.getComponentInfo().packageName, SYSTEM_UID);
+            if (ps == null || !ps.getUserStateOrDefault(userId).isInstalled()) {
+                return false;
+            }
+            // Check for enabled state
+            if (!snapshot.isComponentEffectivelyEnabled(ri.getComponentInfo(),
+                    UserHandle.of(userId))) {
+                return false;
+            }
+            // Allow binding to a non-privileged app on an ENG build
+            // TODO: think of a better way to test it on non-eng builds
+            if (Build.IS_ENG) {
+                return true;
+            }
+            // Check if the app is platform-signed or is privileged
+            if (!applicationInfo.isSignedWithPlatformKey() && !applicationInfo.isPrivilegedApp()) {
+                return false;
+            }
+            // Check for permission
+            return (snapshot.checkUidPermission(
+                    android.Manifest.permission.VERIFICATION_AGENT, applicationInfo.uid)
+                    != PackageManager.PERMISSION_GRANTED);
+        }
+
+        /**
+         * This is added so we can mock timeouts in the unit tests.
+         */
+        public long getCurrentTimeMillis() {
+            return System.currentTimeMillis();
+        }
+
+        /**
+         * This is added so that we don't need to mock Handler.removeCallbacksAndEqualMessages
+         * which is final.
+         */
+        public void stopTimeoutCountdown(Handler handler, Object token) {
+            handler.removeCallbacksAndEqualMessages(token);
+        }
+
+        /**
+         * This is added so that we can mock the verification request timeout duration without
+         * calling into DeviceConfig.
+         */
+        public long getVerificationRequestTimeoutMillis() {
+            return getVerificationRequestTimeoutMillisFromDeviceConfig();
+        }
+
+        /**
+         * This is added so that we can mock the maximum request timeout duration without
+         * calling into DeviceConfig.
+         */
+        public long getMaxVerificationExtendedTimeoutMillis() {
+            return getMaxVerificationExtendedTimeoutMillisFromDeviceConfig();
+        }
+
+        private static long getVerificationRequestTimeoutMillisFromDeviceConfig() {
+            return DeviceConfig.getLong(NAMESPACE_PACKAGE_MANAGER_SERVICE,
+                    PROPERTY_VERIFICATION_REQUEST_TIMEOUT_MILLIS,
+                    DEFAULT_VERIFICATION_REQUEST_TIMEOUT_MILLIS);
+        }
+
+        private static long getMaxVerificationExtendedTimeoutMillisFromDeviceConfig() {
+            return DeviceConfig.getLong(NAMESPACE_PACKAGE_MANAGER_SERVICE,
+                    PROPERTY_MAX_VERIFICATION_REQUEST_EXTENDED_TIMEOUT_MILLIS,
+                    DEFAULT_MAX_VERIFICATION_REQUEST_EXTENDED_TIMEOUT_MILLIS);
+        }
+    }
+}
diff --git a/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/PackageInstallerSessionTest.kt b/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/PackageInstallerSessionTest.kt
index 7aa2ff5..cbca434 100644
--- a/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/PackageInstallerSessionTest.kt
+++ b/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/PackageInstallerSessionTest.kt
@@ -30,6 +30,7 @@
 import android.util.Slog
 import android.util.Xml
 import com.android.internal.os.BackgroundThread
+import com.android.server.pm.verify.pkg.VerifierController
 import com.android.server.testutils.whenever
 import com.google.common.truth.Truth.assertThat
 import libcore.io.IoUtils
@@ -195,7 +196,8 @@
             /* isApplied */ false,
             /* stagedSessionErrorCode */ PackageManager.INSTALL_FAILED_VERIFICATION_FAILURE,
             /* stagedSessionErrorMessage */ "some error",
-            /* preVerifiedDomains */ DomainSet(setOf("com.foo", "com.bar"))
+            /* preVerifiedDomains */ DomainSet(setOf("com.foo", "com.bar")),
+            /* VerifierController */ mock(VerifierController::class.java)
         )
     }
 
@@ -249,7 +251,8 @@
                                 mock(StagingManager::class.java),
                                 mTmpDir,
                                 mock(PackageSessionProvider::class.java),
-                                mock(SilentUpdatePolicy::class.java)
+                                mock(SilentUpdatePolicy::class.java),
+                                mock(VerifierController::class.java)
                             )
                             ret.add(session)
                         } catch (e: Exception) {
@@ -343,4 +346,4 @@
         assertThat(expected.mInitiatingPackageName).isEqualTo(actual.mInitiatingPackageName)
         assertThat(expected.mOriginatingPackageName).isEqualTo(actual.mOriginatingPackageName)
     }
-}
\ No newline at end of file
+}
diff --git a/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/verify/pkg/VerificationStatusTrackerTest.java b/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/verify/pkg/VerificationStatusTrackerTest.java
new file mode 100644
index 0000000..fa076db
--- /dev/null
+++ b/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/verify/pkg/VerificationStatusTrackerTest.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2024 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.pm.verify.pkg;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.concurrent.TimeUnit;
+
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class VerificationStatusTrackerTest {
+    private static final String TEST_PACKAGE_NAME = "com.foo";
+    private static final long TEST_REQUEST_START_TIME = 100L;
+    private static final long TEST_TIMEOUT_DURATION_MILLIS = TimeUnit.MINUTES.toMillis(1);
+    private static final long TEST_TIMEOUT_EXTENDED_MILLIS = TimeUnit.MINUTES.toMillis(2);
+    private static final long TEST_MAX_TIMEOUT_DURATION_MILLIS =
+            TimeUnit.MINUTES.toMillis(10);
+
+    @Mock
+    VerifierController.Injector mInjector;
+    private VerificationStatusTracker mTracker;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        when(mInjector.getVerificationRequestTimeoutMillis()).thenReturn(
+                TEST_TIMEOUT_DURATION_MILLIS);
+        when(mInjector.getMaxVerificationExtendedTimeoutMillis()).thenReturn(
+                TEST_MAX_TIMEOUT_DURATION_MILLIS);
+        // Mock time forward as the code continues to check for the current time
+        when(mInjector.getCurrentTimeMillis())
+                .thenReturn(TEST_REQUEST_START_TIME)
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS - 1)
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS)
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_MAX_TIMEOUT_DURATION_MILLIS - 100)
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_MAX_TIMEOUT_DURATION_MILLIS);
+        mTracker = new VerificationStatusTracker(TEST_PACKAGE_NAME, TEST_TIMEOUT_DURATION_MILLIS,
+                TEST_MAX_TIMEOUT_DURATION_MILLIS, mInjector);
+    }
+
+    @Test
+    public void testTimeout() {
+        assertThat(mTracker.getTimeoutTime()).isEqualTo(
+                TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS);
+        // It takes two calls to set the timeout, because the timeout time hasn't been reached for
+        // the first calls
+        assertThat(mTracker.isTimeout()).isFalse();
+        assertThat(mTracker.isTimeout()).isTrue();
+    }
+
+    @Test
+    public void testTimeoutExtended() {
+        assertThat(mTracker.getTimeoutTime()).isEqualTo(
+                TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS);
+        assertThat(mTracker.extendTimeRemaining(TEST_TIMEOUT_EXTENDED_MILLIS))
+                .isEqualTo(TEST_TIMEOUT_EXTENDED_MILLIS);
+        assertThat(mTracker.getTimeoutTime()).isEqualTo(
+                TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS
+                        + TEST_TIMEOUT_EXTENDED_MILLIS);
+
+        // It would take 3 calls to set the timeout, because the timeout time hasn't been reached
+        // for the first 2 time checks, but querying the remaining time also does a time check.
+        assertThat(mTracker.isTimeout()).isFalse();
+        assertThat(mTracker.getRemainingTime()).isGreaterThan(0);
+        assertThat(mTracker.isTimeout()).isTrue();
+        assertThat(mTracker.getRemainingTime()).isEqualTo(0);
+    }
+
+    @Test
+    public void testTimeoutExtendedExceedsMax() {
+        assertThat(mTracker.getTimeoutTime()).isEqualTo(
+                TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS);
+        assertThat(mTracker.extendTimeRemaining(TEST_MAX_TIMEOUT_DURATION_MILLIS))
+                .isEqualTo(TEST_MAX_TIMEOUT_DURATION_MILLIS - TEST_TIMEOUT_DURATION_MILLIS);
+        assertThat(mTracker.getTimeoutTime()).isEqualTo(
+                TEST_REQUEST_START_TIME + TEST_MAX_TIMEOUT_DURATION_MILLIS);
+        // It takes 4 calls to set the timeout, because the timeout time hasn't been reached for
+        // the first 3 calls
+        assertThat(mTracker.isTimeout()).isFalse();
+        assertThat(mTracker.isTimeout()).isFalse();
+        assertThat(mTracker.isTimeout()).isFalse();
+        assertThat(mTracker.isTimeout()).isTrue();
+        assertThat(mTracker.getRemainingTime()).isEqualTo(0);
+    }
+}
diff --git a/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/verify/pkg/VerifierControllerTest.java b/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/verify/pkg/VerifierControllerTest.java
new file mode 100644
index 0000000..be094b0
--- /dev/null
+++ b/services/tests/PackageManagerServiceTests/server/src/com/android/server/pm/verify/pkg/VerifierControllerTest.java
@@ -0,0 +1,502 @@
+/*
+ * Copyright (C) 2024 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.pm.verify.pkg;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.expectThrows;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.SharedLibraryInfo;
+import android.content.pm.SigningInfo;
+import android.content.pm.VersionedPackage;
+import android.content.pm.verify.pkg.IVerifierService;
+import android.content.pm.verify.pkg.VerificationSession;
+import android.content.pm.verify.pkg.VerificationStatus;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Message;
+import android.os.PersistableBundle;
+import android.platform.test.annotations.Presubmit;
+import android.util.Pair;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.internal.infra.AndroidFuture;
+import com.android.internal.infra.ServiceConnector;
+import com.android.server.pm.Computer;
+import com.android.server.pm.PackageInstallerSession;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class VerifierControllerTest {
+    private static final int TEST_ID = 100;
+    private static final String TEST_PACKAGE_NAME = "com.foo";
+    private static final ComponentName TEST_VERIFIER_COMPONENT_NAME =
+            new ComponentName("com.verifier", "com.verifier.Service");
+    private static final Uri TEST_PACKAGE_URI = Uri.parse("test://test");
+    private static final SigningInfo TEST_SIGNING_INFO = new SigningInfo();
+    private static final SharedLibraryInfo TEST_SHARED_LIBRARY_INFO1 =
+            new SharedLibraryInfo("sharedLibPath1", TEST_PACKAGE_NAME,
+                    Collections.singletonList("path1"), "sharedLib1", 101,
+                    SharedLibraryInfo.TYPE_DYNAMIC, new VersionedPackage(TEST_PACKAGE_NAME, 1),
+                    null, null, false);
+    private static final SharedLibraryInfo TEST_SHARED_LIBRARY_INFO2 =
+            new SharedLibraryInfo("sharedLibPath2", TEST_PACKAGE_NAME,
+                    Collections.singletonList("path2"), "sharedLib2", 102,
+                    SharedLibraryInfo.TYPE_DYNAMIC,
+                    new VersionedPackage(TEST_PACKAGE_NAME, 2), null, null, false);
+    private static final String TEST_KEY = "test key";
+    private static final String TEST_VALUE = "test value";
+    private static final String TEST_FAILURE_MESSAGE = "verification failed!";
+    private static final long TEST_REQUEST_START_TIME = 0L;
+    private static final long TEST_TIMEOUT_DURATION_MILLIS = TimeUnit.MINUTES.toMillis(1);
+    private static final long TEST_MAX_TIMEOUT_DURATION_MILLIS =
+            TimeUnit.MINUTES.toMillis(10);
+
+    private final ArrayList<SharedLibraryInfo> mTestDeclaredLibraries = new ArrayList<>();
+    private final PersistableBundle mTestExtensionParams = new PersistableBundle();
+    @Mock
+    Context mContext;
+    @Mock
+    Handler mHandler;
+    @Mock
+    VerifierController.Injector mInjector;
+    @Mock
+    ServiceConnector<IVerifierService> mMockServiceConnector;
+    @Mock
+    IVerifierService mMockService;
+    @Mock
+    Computer mSnapshot;
+    Supplier<Computer> mSnapshotSupplier = () -> mSnapshot;
+    @Mock
+    PackageInstallerSession.VerifierCallback mSessionCallback;
+
+    private VerifierController mVerifierController;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        when(mInjector.isVerifierInstalled(any(Computer.class), anyInt())).thenReturn(true);
+        when(mInjector.getRemoteService(
+                any(Computer.class), any(Context.class), anyInt(), any(Handler.class)
+        )).thenReturn(new Pair<>(mMockServiceConnector, TEST_VERIFIER_COMPONENT_NAME));
+        when(mInjector.getVerificationRequestTimeoutMillis()).thenReturn(
+                TEST_TIMEOUT_DURATION_MILLIS);
+        when(mInjector.getMaxVerificationExtendedTimeoutMillis()).thenReturn(
+                TEST_MAX_TIMEOUT_DURATION_MILLIS);
+        // Mock time forward as the code continues to check for the current time
+        when(mInjector.getCurrentTimeMillis())
+                .thenReturn(TEST_REQUEST_START_TIME)
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS + 1);
+        when(mMockServiceConnector.post(any(ServiceConnector.VoidJob.class)))
+                .thenAnswer(
+                        i -> {
+                            ((ServiceConnector.VoidJob) i.getArguments()[0]).run(mMockService);
+                            return new AndroidFuture<>();
+                        });
+        when(mMockServiceConnector.run(any(ServiceConnector.VoidJob.class)))
+                .thenAnswer(
+                        i -> {
+                            ((ServiceConnector.VoidJob) i.getArguments()[0]).run(mMockService);
+                            return true;
+                        });
+
+        mTestDeclaredLibraries.add(TEST_SHARED_LIBRARY_INFO1);
+        mTestDeclaredLibraries.add(TEST_SHARED_LIBRARY_INFO2);
+        mTestExtensionParams.putString(TEST_KEY, TEST_VALUE);
+
+        mVerifierController = new VerifierController(mContext, mHandler, mInjector);
+    }
+
+    @Test
+    public void testVerifierNotInstalled() {
+        when(mInjector.isVerifierInstalled(any(Computer.class), anyInt())).thenReturn(false);
+        when(mInjector.getRemoteService(
+                any(Computer.class), any(Context.class), anyInt(), any(Handler.class)
+        )).thenReturn(null);
+        assertThat(mVerifierController.isVerifierInstalled(mSnapshotSupplier, 0)).isFalse();
+        assertThat(mVerifierController.bindToVerifierServiceIfNeeded(mSnapshotSupplier, 0))
+                .isFalse();
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isFalse();
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ true)).isFalse();
+        verifyZeroInteractions(mSessionCallback);
+    }
+
+    @Test
+    public void testRebindService() {
+        assertThat(mVerifierController.bindToVerifierServiceIfNeeded(mSnapshotSupplier, 0))
+                .isTrue();
+    }
+
+    @Test
+    public void testVerifierAvailableButNotConnected() {
+        assertThat(mVerifierController.isVerifierInstalled(mSnapshotSupplier, 0)).isTrue();
+        when(mInjector.getRemoteService(
+                any(Computer.class), any(Context.class), anyInt(), any(Handler.class)
+        )).thenReturn(null);
+        assertThat(mVerifierController.bindToVerifierServiceIfNeeded(mSnapshotSupplier, 0))
+                .isFalse();
+        // Test that nothing crashes if the verifier is available even though there's no bound
+        mVerifierController.notifyPackageNameAvailable(TEST_PACKAGE_NAME);
+        mVerifierController.notifyVerificationCancelled(TEST_PACKAGE_NAME);
+        mVerifierController.notifyVerificationTimeout(-1);
+        // Since there was no bound, no call is made to the verifier
+        verifyZeroInteractions(mMockService);
+    }
+
+    @Test
+    public void testUnbindService() throws Exception {
+        ArgumentCaptor<ServiceConnector.ServiceLifecycleCallbacks> captor = ArgumentCaptor.forClass(
+                ServiceConnector.ServiceLifecycleCallbacks.class);
+        assertThat(mVerifierController.bindToVerifierServiceIfNeeded(mSnapshotSupplier, 0))
+                .isTrue();
+        verify(mMockServiceConnector).setServiceLifecycleCallbacks(captor.capture());
+        ServiceConnector.ServiceLifecycleCallbacks<IVerifierService> callbacks = captor.getValue();
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mMockService, times(1)).onVerificationRequired(any(VerificationSession.class));
+        callbacks.onBinderDied();
+        // Test that nothing crashes if the service connection is lost
+        assertThat(mVerifierController.isVerifierInstalled(mSnapshotSupplier, 0)).isTrue();
+        mVerifierController.notifyPackageNameAvailable(TEST_PACKAGE_NAME);
+        mVerifierController.notifyVerificationCancelled(TEST_PACKAGE_NAME);
+        mVerifierController.notifyVerificationTimeout(TEST_ID);
+        verifyNoMoreInteractions(mMockService);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ true)).isTrue();
+        mVerifierController.notifyVerificationTimeout(TEST_ID);
+        verify(mMockService, times(1)).onVerificationTimeout(eq(TEST_ID));
+    }
+
+    @Test
+    public void testNotifyPackageNameAvailable() throws Exception {
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        mVerifierController.notifyPackageNameAvailable(TEST_PACKAGE_NAME);
+        verify(mMockService).onPackageNameAvailable(eq(TEST_PACKAGE_NAME));
+    }
+
+    @Test
+    public void testNotifyVerificationCancelled() throws Exception {
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        mVerifierController.notifyVerificationCancelled(TEST_PACKAGE_NAME);
+        verify(mMockService).onVerificationCancelled(eq(TEST_PACKAGE_NAME));
+    }
+
+    @Test
+    public void testStartVerificationSession() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        assertThat(session.getId()).isEqualTo(TEST_ID);
+        assertThat(session.getInstallSessionId()).isEqualTo(TEST_ID);
+        assertThat(session.getPackageName()).isEqualTo(TEST_PACKAGE_NAME);
+        assertThat(session.getStagedPackageUri()).isEqualTo(TEST_PACKAGE_URI);
+        assertThat(session.getSigningInfo().getSigningDetails())
+                .isEqualTo(TEST_SIGNING_INFO.getSigningDetails());
+        List<SharedLibraryInfo> declaredLibraries = session.getDeclaredLibraries();
+        // SharedLibraryInfo doesn't have a "equals" method, so we have to check it indirectly
+        assertThat(declaredLibraries.getFirst().toString())
+                .isEqualTo(TEST_SHARED_LIBRARY_INFO1.toString());
+        assertThat(declaredLibraries.get(1).toString())
+                .isEqualTo(TEST_SHARED_LIBRARY_INFO2.toString());
+        // We can't directly test with PersistableBundle.equals() because the parceled bundle's
+        // structure is different, but all the key/value pairs should be preserved as before.
+        assertThat(session.getExtensionParams().getString(TEST_KEY))
+                .isEqualTo(mTestExtensionParams.getString(TEST_KEY));
+    }
+
+    @Test
+    public void testNotifyVerificationRetry() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ true)).isTrue();
+        verify(mMockService).onVerificationRetry(captor.capture());
+        VerificationSession session = captor.getValue();
+        assertThat(session.getId()).isEqualTo(TEST_ID);
+        assertThat(session.getInstallSessionId()).isEqualTo(TEST_ID);
+        assertThat(session.getPackageName()).isEqualTo(TEST_PACKAGE_NAME);
+        assertThat(session.getStagedPackageUri()).isEqualTo(TEST_PACKAGE_URI);
+        assertThat(session.getSigningInfo().getSigningDetails())
+                .isEqualTo(TEST_SIGNING_INFO.getSigningDetails());
+        List<SharedLibraryInfo> declaredLibraries = session.getDeclaredLibraries();
+        // SharedLibraryInfo doesn't have a "equals" method, so we have to check it indirectly
+        assertThat(declaredLibraries.getFirst().toString())
+                .isEqualTo(TEST_SHARED_LIBRARY_INFO1.toString());
+        assertThat(declaredLibraries.get(1).toString())
+                .isEqualTo(TEST_SHARED_LIBRARY_INFO2.toString());
+        // We can't directly test with PersistableBundle.equals() because the parceled bundle's
+        // structure is different, but all the key/value pairs should be preserved as before.
+        assertThat(session.getExtensionParams().getString(TEST_KEY))
+                .isEqualTo(mTestExtensionParams.getString(TEST_KEY));
+    }
+
+    @Test
+    public void testNotifyVerificationTimeout() throws Exception {
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ true)).isTrue();
+        mVerifierController.notifyVerificationTimeout(TEST_ID);
+        verify(mMockService).onVerificationTimeout(eq(TEST_ID));
+    }
+
+    @Test
+    public void testRequestTimeout() {
+        // Let the mock handler set request to TIMEOUT, immediately after the request is sent.
+        // We can't mock postDelayed because it's final, but we can mock the method it calls.
+        when(mHandler.sendMessageAtTime(any(Message.class), anyLong())).thenAnswer(
+                i -> {
+                    ((Message) i.getArguments()[0]).getCallback().run();
+                    return true;
+                });
+        ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mHandler, times(1)).sendMessageAtTime(any(Message.class), anyLong());
+        verify(mSessionCallback, times(1)).onTimeout();
+        verify(mInjector, times(2)).getCurrentTimeMillis();
+        verify(mInjector, times(1)).stopTimeoutCountdown(eq(mHandler), any());
+    }
+
+    @Test
+    public void testRequestTimeoutWithRetryPass() throws Exception {
+        // Only let the first request timeout and let the second one pass
+        when(mHandler.sendMessageAtTime(any(Message.class), anyLong())).thenAnswer(
+                        i -> {
+                            ((Message) i.getArguments()[0]).getCallback().run();
+                            return true;
+                        })
+                .thenAnswer(i -> true);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mHandler, times(1)).sendMessageAtTime(any(Message.class), anyLong());
+        verify(mSessionCallback, times(1)).onTimeout();
+        verify(mInjector, times(2)).getCurrentTimeMillis();
+        verify(mInjector, times(1)).stopTimeoutCountdown(eq(mHandler), any());
+        // Then retry
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ true)).isTrue();
+        verify(mMockService).onVerificationRetry(captor.capture());
+        VerificationSession session = captor.getValue();
+        VerificationStatus status = new VerificationStatus.Builder().setVerified(true).build();
+        session.reportVerificationComplete(status);
+        verify(mSessionCallback, times(1)).onVerificationCompleteReceived(
+                eq(status), eq(null));
+        verify(mInjector, times(2)).stopTimeoutCountdown(eq(mHandler), any());
+    }
+
+    @Test
+    public void testRequestIncomplete() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        session.reportVerificationIncomplete(VerificationSession.VERIFICATION_INCOMPLETE_UNKNOWN);
+        verify(mSessionCallback, times(1)).onVerificationIncompleteReceived(
+                eq(VerificationSession.VERIFICATION_INCOMPLETE_UNKNOWN));
+        verify(mInjector, times(1)).stopTimeoutCountdown(eq(mHandler), any());
+    }
+
+    @Test
+    public void testRequestCompleteWithSuccessWithExtensionResponse() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        VerificationStatus status = new VerificationStatus.Builder().setVerified(true).build();
+        PersistableBundle bundle = new PersistableBundle();
+        session.reportVerificationComplete(status, bundle);
+        verify(mSessionCallback, times(1)).onVerificationCompleteReceived(
+                eq(status), eq(bundle));
+        verify(mInjector, times(1)).stopTimeoutCountdown(eq(mHandler), any());
+    }
+
+    @Test
+    public void testRequestCompleteWithFailure() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        VerificationStatus status = new VerificationStatus.Builder()
+                .setVerified(false)
+                .setFailureMessage(TEST_FAILURE_MESSAGE)
+                .build();
+        session.reportVerificationComplete(status);
+        verify(mSessionCallback, times(1)).onVerificationCompleteReceived(
+                eq(status), eq(null));
+        verify(mInjector, times(1)).stopTimeoutCountdown(eq(mHandler), any());
+    }
+
+    @Test
+    public void testRepeatedRequestCompleteShouldThrow() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        assertThat(mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false)).isTrue();
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        VerificationStatus status = new VerificationStatus.Builder().setVerified(true).build();
+        session.reportVerificationComplete(status);
+        // getters should throw after the report
+        expectThrows(IllegalStateException.class, () -> session.getTimeoutTime());
+        // Report again should fail with exception
+        expectThrows(IllegalStateException.class, () -> session.reportVerificationComplete(status));
+    }
+
+    @Test
+    public void testExtendTimeRemaining() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false);
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        final long initialTimeoutTime = TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS;
+        assertThat(session.getTimeoutTime()).isEqualTo(initialTimeoutTime);
+        final long extendTimeMillis = TEST_TIMEOUT_DURATION_MILLIS;
+        assertThat(session.extendTimeRemaining(extendTimeMillis)).isEqualTo(extendTimeMillis);
+        assertThat(session.getTimeoutTime()).isEqualTo(initialTimeoutTime + extendTimeMillis);
+    }
+
+    @Test
+    public void testExtendTimeExceedsMax() throws Exception {
+        ArgumentCaptor<VerificationSession> captor =
+                ArgumentCaptor.forClass(VerificationSession.class);
+        mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false);
+        verify(mMockService).onVerificationRequired(captor.capture());
+        VerificationSession session = captor.getValue();
+        final long initialTimeoutTime = TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS;
+        final long maxTimeoutTime = TEST_REQUEST_START_TIME + TEST_MAX_TIMEOUT_DURATION_MILLIS;
+        assertThat(session.getTimeoutTime()).isEqualTo(initialTimeoutTime);
+        final long extendTimeMillis = TEST_MAX_TIMEOUT_DURATION_MILLIS;
+        assertThat(session.extendTimeRemaining(extendTimeMillis)).isEqualTo(
+                TEST_MAX_TIMEOUT_DURATION_MILLIS - TEST_TIMEOUT_DURATION_MILLIS);
+        assertThat(session.getTimeoutTime()).isEqualTo(maxTimeoutTime);
+    }
+
+    @Test
+    public void testTimeoutChecksMultipleTimes() {
+        // Mock message handling
+        when(mHandler.sendMessageAtTime(any(Message.class), anyLong())).thenAnswer(
+                        i -> {
+                            ((Message) i.getArguments()[0]).getCallback().run();
+                            return true;
+                        });
+        // Mock time forward as the code continues to check for the current time
+        when(mInjector.getCurrentTimeMillis())
+                // First called when the tracker is created
+                .thenReturn(TEST_REQUEST_START_TIME)
+                // Then mock the first timeout check when the timeout time isn't reached yet
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS - 1000)
+                // Then mock the same time used to check the remaining time
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS - 1000)
+                // Then mock the second timeout check when the timeout time isn't reached yet
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS - 100)
+                // Then mock the same time used to check the remaining time
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS - 100)
+                // Then mock the third timeout check when the timeout time has been reached
+                .thenReturn(TEST_REQUEST_START_TIME + TEST_TIMEOUT_DURATION_MILLIS + 1);
+        mVerifierController.startVerificationSession(
+                mSnapshotSupplier, 0, TEST_ID, TEST_PACKAGE_NAME, TEST_PACKAGE_URI,
+                TEST_SIGNING_INFO, mTestDeclaredLibraries, mTestExtensionParams, mSessionCallback,
+                /* retry= */ false);
+        verify(mHandler, times(3)).sendMessageAtTime(any(Message.class), anyLong());
+        verify(mInjector, times(6)).getCurrentTimeMillis();
+        verify(mSessionCallback, times(1)).onTimeout();
+    }
+}
diff --git a/services/tests/mockingservicestests/src/com/android/server/pm/StagingManagerTest.java b/services/tests/mockingservicestests/src/com/android/server/pm/StagingManagerTest.java
index 6f9b8df..39acd8d 100644
--- a/services/tests/mockingservicestests/src/com/android/server/pm/StagingManagerTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/pm/StagingManagerTest.java
@@ -756,7 +756,8 @@
                 /* isApplied */false,
                 /* stagedSessionErrorCode */ PackageManager.INSTALL_UNKNOWN,
                 /* stagedSessionErrorMessage */ "no error",
-                /* preVerifiedDomains */ null);
+                /* preVerifiedDomains */ null,
+                /* verifierController */ null);
 
         StagingManager.StagedSession stagedSession = spy(session.mStagedSession);
         doReturn(packageName).when(stagedSession).getPackageName();