Merge "APF optionality for TV devices meeting specific power requirements" into main
diff --git a/framework-t/src/android/net/nsd/OffloadServiceInfo.java b/framework-t/src/android/net/nsd/OffloadServiceInfo.java
index e4b2f43..fd824f3 100644
--- a/framework-t/src/android/net/nsd/OffloadServiceInfo.java
+++ b/framework-t/src/android/net/nsd/OffloadServiceInfo.java
@@ -282,7 +282,7 @@
         }
 
         /**
-         * Get the service type. (e.g. "_http._tcp.local" )
+         * Get the service type. (e.g. "_http._tcp" )
          */
         @NonNull
         public String getServiceType() {
diff --git a/framework/api/current.txt b/framework/api/current.txt
index 797c107..a9d1569 100644
--- a/framework/api/current.txt
+++ b/framework/api/current.txt
@@ -233,6 +233,32 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.net.IpPrefix> CREATOR;
   }
 
+  @FlaggedApi("com.android.net.flags.ipv6_over_ble") public final class L2capNetworkSpecifier extends android.net.NetworkSpecifier implements android.os.Parcelable {
+    method public int describeContents();
+    method public int getHeaderCompression();
+    method public int getPsm();
+    method @Nullable public android.net.MacAddress getRemoteAddress();
+    method public int getRole();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.L2capNetworkSpecifier> CREATOR;
+    field public static final int HEADER_COMPRESSION_6LOWPAN = 2; // 0x2
+    field public static final int HEADER_COMPRESSION_ANY = 0; // 0x0
+    field public static final int HEADER_COMPRESSION_NONE = 1; // 0x1
+    field public static final int PSM_ANY = -1; // 0xffffffff
+    field public static final int ROLE_ANY = 0; // 0x0
+    field public static final int ROLE_CLIENT = 1; // 0x1
+    field public static final int ROLE_SERVER = 2; // 0x2
+  }
+
+  public static final class L2capNetworkSpecifier.Builder {
+    ctor public L2capNetworkSpecifier.Builder();
+    method @NonNull public android.net.L2capNetworkSpecifier build();
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setHeaderCompression(int);
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setPsm(int);
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setRemoteAddress(@NonNull android.net.MacAddress);
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setRole(int);
+  }
+
   public class LinkAddress implements android.os.Parcelable {
     method public int describeContents();
     method public java.net.InetAddress getAddress();
diff --git a/framework/src/android/net/L2capNetworkSpecifier.java b/framework/src/android/net/L2capNetworkSpecifier.java
new file mode 100644
index 0000000..c7067f6
--- /dev/null
+++ b/framework/src/android/net/L2capNetworkSpecifier.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.net.flags.Flags;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+
+/**
+ * A {@link NetworkSpecifier} used to identify an L2CAP network.
+ *
+ * An L2CAP network is not symmetrical, meaning there exists both a server (Bluetooth peripheral)
+ * and a client (Bluetooth central) node. This specifier contains information required to request or
+ * reserve an L2CAP network.
+ *
+ * An L2CAP server network allocates a PSM to be advertised to the client. Therefore, the server
+ * network must always be reserved using {@link ConnectivityManager#reserveNetwork}. The subsequent
+ * {@link ConnectivityManager.NetworkCallback#onReserved(NetworkCapabilities)} includes information
+ * (i.e. the PSM) for the server to advertise to the client.
+ * Under the hood, an L2CAP server network is represented by a {@link
+ * android.bluetooth.BluetoothServerSocket} which can, in theory, accept many connections. However,
+ * before Android 15 Bluetooth APIs do not expose the channel ID, so these connections are
+ * indistinguishable. In practice, this means that network matching semantics in {@link
+ * ConnectivityService} will tear down all but the first connection.
+ *
+ * The L2cap client network can be connected using {@link ConnectivityManager#requestNetwork}
+ * including passing in the relevant information (i.e. PSM and destination MAC address) using the
+ * {@link L2capNetworkSpecifier}.
+ *
+ */
+@FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
+public final class L2capNetworkSpecifier extends NetworkSpecifier implements Parcelable {
+    /** Accept any role. */
+    public static final int ROLE_ANY = 0;
+    /** Specifier describes a client network. */
+    public static final int ROLE_CLIENT = 1;
+    /** Specifier describes a server network. */
+    public static final int ROLE_SERVER = 2;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = false, prefix = "ROLE_", value = {
+        ROLE_ANY,
+        ROLE_CLIENT,
+        ROLE_SERVER
+    })
+    public @interface Role {}
+    /** Role used to distinguish client from server networks. */
+    @Role
+    private final int mRole;
+
+    /** Accept any form of header compression. */
+    public static final int HEADER_COMPRESSION_ANY = 0;
+    /** Do not compress packets on this network. */
+    public static final int HEADER_COMPRESSION_NONE = 1;
+    /** Use 6lowpan header compression as specified in rfc6282. */
+    public static final int HEADER_COMPRESSION_6LOWPAN = 2;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = false, prefix = "HEADER_COMPRESSION_", value = {
+        HEADER_COMPRESSION_ANY,
+        HEADER_COMPRESSION_NONE,
+        HEADER_COMPRESSION_6LOWPAN
+    })
+    public @interface HeaderCompression {}
+    /** Header compression mechanism used on this network. */
+    @HeaderCompression
+    private final int mHeaderCompression;
+
+    /**
+     *  The MAC address of the remote.
+     */
+    @Nullable
+    private final MacAddress mRemoteAddress;
+
+    /** Match any PSM. */
+    public static final int PSM_ANY = -1;
+
+    /** The Bluetooth L2CAP Protocol Service Multiplexer (PSM). */
+    private final int mPsm;
+
+    private L2capNetworkSpecifier(Parcel in) {
+        mRole = in.readInt();
+        mHeaderCompression = in.readInt();
+        mRemoteAddress = in.readParcelable(getClass().getClassLoader());
+        mPsm = in.readInt();
+    }
+
+    /** @hide */
+    public L2capNetworkSpecifier(@Role int role, @HeaderCompression int headerCompression,
+            MacAddress remoteAddress, int psm) {
+        mRole = role;
+        mHeaderCompression = headerCompression;
+        mRemoteAddress = remoteAddress;
+        mPsm = psm;
+    }
+
+    /** Returns the role to be used for this network. */
+    @Role
+    public int getRole() {
+        return mRole;
+    }
+
+    /** Returns the compression mechanism for this network. */
+    @HeaderCompression
+    public int getHeaderCompression() {
+        return mHeaderCompression;
+    }
+
+    /** Returns the remote MAC address for this network to connect to. */
+    public @Nullable MacAddress getRemoteAddress() {
+        return mRemoteAddress;
+    }
+
+    /** Returns the PSM for this network to connect to. */
+    public int getPsm() {
+        return mPsm;
+    }
+
+    /** A builder class for L2capNetworkSpecifier. */
+    public static final class Builder {
+        @Role
+        private int mRole;
+        @HeaderCompression
+        private int mHeaderCompression;
+        @Nullable
+        private MacAddress mRemoteAddress;
+        private int mPsm = PSM_ANY;
+
+        /**
+         * Set the role to use for this network.
+         *
+         * @param role the role to use.
+         */
+        @NonNull
+        public Builder setRole(@Role int role) {
+            mRole = role;
+            return this;
+        }
+
+        /**
+         * Set the header compression mechanism to use for this network.
+         *
+         * @param headerCompression the header compression mechanism to use.
+         */
+        @NonNull
+        public Builder setHeaderCompression(@HeaderCompression int headerCompression) {
+            mHeaderCompression = headerCompression;
+            return this;
+        }
+
+        /**
+         * Set the remote address for the client to connect to.
+         *
+         * Only valid for client networks. A null MacAddress matches *any* MacAddress.
+         *
+         * @param remoteAddress the MAC address to connect to.
+         */
+        @NonNull
+        public Builder setRemoteAddress(@NonNull MacAddress remoteAddress) {
+            Objects.requireNonNull(remoteAddress);
+            mRemoteAddress = remoteAddress;
+            return this;
+        }
+
+        /**
+         * Set the PSM for the client to connect to.
+         *
+         * Can only be configured on client networks.
+         *
+         * @param psm the Protocol Service Multiplexer (PSM) to connect to.
+         */
+        @NonNull
+        public Builder setPsm(int psm) {
+            mPsm = psm;
+            return this;
+        }
+
+        /** Create the L2capNetworkSpecifier object. */
+        @NonNull
+        public L2capNetworkSpecifier build() {
+            // TODO: throw an exception for combinations that cannot be supported.
+            return new L2capNetworkSpecifier(mRole, mHeaderCompression, mRemoteAddress, mPsm);
+        }
+    }
+
+    /** @hide */
+    @Override
+    public boolean canBeSatisfiedBy(NetworkSpecifier other) {
+        // TODO: implement matching semantics.
+        return false;
+    }
+
+    /** @hide */
+    @Override
+    @Nullable
+    public NetworkSpecifier redact() {
+        // Redact the remote MAC address and the PSM (for non-server roles).
+        final NetworkSpecifier redactedSpecifier = new Builder()
+                .setRole(mRole)
+                .setHeaderCompression(mHeaderCompression)
+                // TODO: consider not redacting the specifier in onReserved, so the redaction can be
+                // more strict (i.e. the PSM could always be redacted).
+                .setPsm(mRole == ROLE_SERVER ? mPsm : PSM_ANY)
+                .build();
+        return redactedSpecifier;
+    }
+
+    /** @hide */
+    @Override
+    public int hashCode() {
+        return Objects.hash(mRole, mHeaderCompression, mRemoteAddress, mPsm);
+    }
+
+    /** @hide */
+    public boolean equals(Object obj) {
+        if (this == obj) return true;
+        if (!(obj instanceof L2capNetworkSpecifier)) return false;
+
+        final L2capNetworkSpecifier rhs = (L2capNetworkSpecifier) obj;
+        return mRole == rhs.mRole
+                && mHeaderCompression == rhs.mHeaderCompression
+                && Objects.equals(mRemoteAddress, rhs.mRemoteAddress)
+                && mPsm == rhs.mPsm;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mRole);
+        dest.writeInt(mHeaderCompression);
+        dest.writeParcelable(mRemoteAddress, flags);
+        dest.writeInt(mPsm);
+    }
+
+    public static final @NonNull Creator<L2capNetworkSpecifier> CREATOR = new Creator<>() {
+        @Override
+        public L2capNetworkSpecifier createFromParcel(Parcel in) {
+            return new L2capNetworkSpecifier(in);
+        }
+
+        @Override
+        public L2capNetworkSpecifier[] newArray(int size) {
+            return new L2capNetworkSpecifier[size];
+        }
+    };
+}
diff --git a/framework/src/android/net/NetworkRequest.java b/framework/src/android/net/NetworkRequest.java
index b95363a..5a08d44 100644
--- a/framework/src/android/net/NetworkRequest.java
+++ b/framework/src/android/net/NetworkRequest.java
@@ -282,6 +282,13 @@
         this.type = that.type;
     }
 
+    private NetworkRequest(Parcel in) {
+        networkCapabilities = NetworkCapabilities.CREATOR.createFromParcel(in);
+        legacyType = in.readInt();
+        requestId = in.readInt();
+        type = Type.valueOf(in.readString());  // IllegalArgumentException if invalid.
+    }
+
     /**
      * Builder used to create {@link NetworkRequest} objects.  Specify the Network features
      * needed in terms of {@link NetworkCapabilities} features
@@ -678,12 +685,7 @@
     public static final @android.annotation.NonNull Creator<NetworkRequest> CREATOR =
         new Creator<NetworkRequest>() {
             public NetworkRequest createFromParcel(Parcel in) {
-                NetworkCapabilities nc = NetworkCapabilities.CREATOR.createFromParcel(in);
-                int legacyType = in.readInt();
-                int requestId = in.readInt();
-                Type type = Type.valueOf(in.readString());  // IllegalArgumentException if invalid.
-                NetworkRequest result = new NetworkRequest(nc, legacyType, requestId, type);
-                return result;
+                return new NetworkRequest(in);
             }
             public NetworkRequest[] newArray(int size) {
                 return new NetworkRequest[size];
diff --git a/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
index 66ae79c..ac381b8 100644
--- a/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
+++ b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
@@ -89,6 +89,9 @@
                             break;
                         case BroadcastRequest.PRESENCE_VERSION_V1:
                             if (adapter.isLeExtendedAdvertisingSupported()) {
+                                if (mAdvertisingSetCallback == null) {
+                                    mAdvertisingSetCallback = getAdvertisingSetCallback();
+                                }
                                 bluetoothLeAdvertiser.startAdvertisingSet(
                                         getAdvertisingSetParameters(),
                                         advertiseData,
@@ -133,6 +136,11 @@
             }
             mBroadcastListener = null;
             mIsAdvertising = false;
+            // If called startAdvertisingSet() but onAdvertisingSetStopped() is not invoked yet,
+            // using the same mAdvertisingSetCallback will cause new advertising cann't be stopped.
+            // Therefore, release the old mAdvertisingSetCallback and
+            // create a new mAdvertisingSetCallback when calling startAdvertisingSet.
+            mAdvertisingSetCallback = null;
         }
     }
 
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
index 002ad9a..764300c 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -16,18 +16,10 @@
 
 package com.android.server.net.ct;
 
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DEVICE_OFFLINE;
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DOWNLOAD_CANNOT_RESUME;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION;
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_TOO_MANY_REDIRECTS;
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_UNKNOWN;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS;
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__PENDING_WAITING_FOR_WIFI;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
 
 import android.annotation.RequiresApi;
 import android.app.DownloadManager;
@@ -43,13 +35,12 @@
 
 import com.android.server.net.ct.DownloadHelper.DownloadStatus;
 
-import org.json.JSONException;
-import org.json.JSONObject;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
 import java.security.InvalidKeyException;
+import java.util.ArrayList;
+import java.util.List;
 
 /** Helper class to download certificate transparency log files. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@@ -61,9 +52,10 @@
     private final DataStore mDataStore;
     private final DownloadHelper mDownloadHelper;
     private final SignatureVerifier mSignatureVerifier;
-    private final CertificateTransparencyInstaller mInstaller;
     private final CertificateTransparencyLogger mLogger;
 
+    private final List<CompatibilityVersion> mCompatVersions = new ArrayList<>();
+
     private boolean started = false;
 
     CertificateTransparencyDownloader(
@@ -71,25 +63,27 @@
             DataStore dataStore,
             DownloadHelper downloadHelper,
             SignatureVerifier signatureVerifier,
-            CertificateTransparencyInstaller installer,
             CertificateTransparencyLogger logger) {
         mContext = context;
         mSignatureVerifier = signatureVerifier;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
-        mInstaller = installer;
         mLogger = logger;
     }
 
+    void addCompatibilityVersion(CompatibilityVersion compatVersion) {
+        mCompatVersions.add(compatVersion);
+    }
+
     void start() {
         if (started) {
             return;
         }
-        mInstaller.addCompatibilityVersion(Config.COMPATIBILITY_VERSION);
         mContext.registerReceiver(
                 this,
                 new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
                 Context.RECEIVER_EXPORTED);
+        mDataStore.load();
         started = true;
 
         if (Config.DEBUG) {
@@ -102,7 +96,7 @@
             return;
         }
         mContext.unregisterReceiver(this);
-        mInstaller.removeCompatibilityVersion(Config.COMPATIBILITY_VERSION);
+        mDataStore.delete();
         started = false;
 
         if (Config.DEBUG) {
@@ -111,7 +105,7 @@
     }
 
     long startPublicKeyDownload() {
-        long downloadId = download(mDataStore.getProperty(Config.PUBLIC_KEY_URL));
+        long downloadId = download(Config.URL_PUBLIC_KEY);
         if (downloadId != -1) {
             mDataStore.setPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, downloadId);
             mDataStore.store();
@@ -119,19 +113,31 @@
         return downloadId;
     }
 
-    long startMetadataDownload() {
-        long downloadId = download(mDataStore.getProperty(Config.METADATA_URL));
+    private long startMetadataDownload(CompatibilityVersion compatVersion) {
+        long downloadId = download(compatVersion.getMetadataUrl());
         if (downloadId != -1) {
-            mDataStore.setPropertyLong(Config.METADATA_DOWNLOAD_ID, downloadId);
+            mDataStore.setPropertyLong(compatVersion.getMetadataPropertyName(), downloadId);
             mDataStore.store();
         }
         return downloadId;
     }
 
-    long startContentDownload() {
-        long downloadId = download(mDataStore.getProperty(Config.CONTENT_URL));
+    @VisibleForTesting
+    void startMetadataDownload() {
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            if (startMetadataDownload(compatVersion) == -1) {
+                Log.e(TAG, "Metadata download not started for " + compatVersion.getCompatVersion());
+            } else if (Config.DEBUG) {
+                Log.d(TAG, "Metadata download started for " + compatVersion.getCompatVersion());
+            }
+        }
+    }
+
+    @VisibleForTesting
+    long startContentDownload(CompatibilityVersion compatVersion) {
+        long downloadId = download(compatVersion.getContentUrl());
         if (downloadId != -1) {
-            mDataStore.setPropertyLong(Config.CONTENT_DOWNLOAD_ID, downloadId);
+            mDataStore.setPropertyLong(compatVersion.getContentPropertyName(), downloadId);
             mDataStore.store();
         }
         return downloadId;
@@ -145,25 +151,28 @@
             return;
         }
 
-        long completedId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
+        long completedId =
+                intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, /* defaultValue= */ -1);
         if (completedId == -1) {
             Log.e(TAG, "Invalid completed download Id");
             return;
         }
 
-        if (isPublicKeyDownloadId(completedId)) {
+        if (getPublicKeyDownloadId() == completedId) {
             handlePublicKeyDownloadCompleted(completedId);
             return;
         }
 
-        if (isMetadataDownloadId(completedId)) {
-            handleMetadataDownloadCompleted(completedId);
-            return;
-        }
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            if (getMetadataDownloadId(compatVersion) == completedId) {
+                handleMetadataDownloadCompleted(compatVersion, completedId);
+                return;
+            }
 
-        if (isContentDownloadId(completedId)) {
-            handleContentDownloadCompleted(completedId);
-            return;
+            if (getContentDownloadId(compatVersion) == completedId) {
+                handleContentDownloadCompleted(compatVersion, completedId);
+                return;
+            }
         }
 
         Log.i(TAG, "Download id " + completedId + " is not recognized.");
@@ -189,35 +198,33 @@
             return;
         }
 
-        if (startMetadataDownload() == -1) {
-            Log.e(TAG, "Metadata download not started.");
-        } else if (Config.DEBUG) {
-            Log.d(TAG, "Metadata download started successfully.");
-        }
+        startMetadataDownload();
     }
 
-    private void handleMetadataDownloadCompleted(long downloadId) {
+    private void handleMetadataDownloadCompleted(
+            CompatibilityVersion compatVersion, long downloadId) {
         DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
         if (!status.isSuccessful()) {
             handleDownloadFailed(status);
             return;
         }
-        if (startContentDownload() == -1) {
-            Log.e(TAG, "Content download not started.");
+        if (startContentDownload(compatVersion) == -1) {
+            Log.e(TAG, "Content download failed for" + compatVersion.getCompatVersion());
         } else if (Config.DEBUG) {
-            Log.d(TAG, "Content download started successfully.");
+            Log.d(TAG, "Content download started for" + compatVersion.getCompatVersion());
         }
     }
 
-    private void handleContentDownloadCompleted(long downloadId) {
+    private void handleContentDownloadCompleted(
+            CompatibilityVersion compatVersion, long downloadId) {
         DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
         if (!status.isSuccessful()) {
             handleDownloadFailed(status);
             return;
         }
 
-        Uri contentUri = getContentDownloadUri();
-        Uri metadataUri = getMetadataDownloadUri();
+        Uri contentUri = getContentDownloadUri(compatVersion);
+        Uri metadataUri = getMetadataDownloadUri(compatVersion);
         if (contentUri == null || metadataUri == null) {
             Log.e(TAG, "Invalid URIs");
             return;
@@ -234,8 +241,7 @@
                 mLogger.logCTLogListUpdateFailedEvent(
                         CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND,
                         mDataStore.getPropertyInt(
-                            Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0)
-                );
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0));
             }
         } catch (InvalidKeyException e) {
             if (updateFailureCount()) {
@@ -243,8 +249,7 @@
                 mLogger.logCTLogListUpdateFailedEvent(
                         CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
                         mDataStore.getPropertyInt(
-                            Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0)
-                );
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0));
             }
         } catch (IOException | GeneralSecurityException e) {
             Log.e(TAG, "Could not verify new log list", e);
@@ -258,32 +263,19 @@
                 mLogger.logCTLogListUpdateFailedEvent(
                         CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
                         mDataStore.getPropertyInt(
-                            Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0));
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0));
             }
             return;
         }
 
-        String version = null;
         try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
-            version =
-                    new JSONObject(new String(inputStream.readAllBytes(), UTF_8))
-                            .getString("version");
-        } catch (JSONException | IOException e) {
-            Log.e(TAG, "Could not extract version from log list", e);
-            return;
-        }
-
-        try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
-            success = mInstaller.install(Config.COMPATIBILITY_VERSION, inputStream, version);
+            success = compatVersion.install(inputStream);
         } catch (IOException e) {
             Log.e(TAG, "Could not install new content", e);
             return;
         }
 
         if (success) {
-            // Update information about the stored version on successful install.
-            mDataStore.setProperty(Config.VERSION, version);
-
             // Reset the number of consecutive log list failure updates back to zero.
             mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* value= */ 0);
             mDataStore.store();
@@ -305,43 +297,19 @@
                     mDataStore.getPropertyInt(
                             Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0);
 
-            // HTTP Error
-            if (400 <= status.reason() && status.reason() <= 600) {
+            if (status.isHttpError()) {
                 mLogger.logCTLogListUpdateFailedEvent(
                         CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR,
                         failureCount,
                         status.reason());
             } else {
                 // TODO(b/384935059): handle blocked domain logging
-                // TODO(b/384936292): add additionalchecks for pending wifi status
-                mLogger.logCTLogListUpdateFailedEvent(
-                        downloadStatusToFailureReason(status.reason()), failureCount);
+                mLogger.logCTLogListUpdateFailedEventWithDownloadStatus(
+                        status.reason(), failureCount);
             }
         }
     }
 
-    /** Converts DownloadStatus reason into failure reason to log. */
-    private int downloadStatusToFailureReason(int downloadStatusReason) {
-        switch (downloadStatusReason) {
-            case DownloadManager.PAUSED_WAITING_TO_RETRY:
-            case DownloadManager.PAUSED_WAITING_FOR_NETWORK:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DEVICE_OFFLINE;
-            case DownloadManager.ERROR_UNHANDLED_HTTP_CODE:
-            case DownloadManager.ERROR_HTTP_DATA_ERROR:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
-            case DownloadManager.ERROR_TOO_MANY_REDIRECTS:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_TOO_MANY_REDIRECTS;
-            case DownloadManager.ERROR_CANNOT_RESUME:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DOWNLOAD_CANNOT_RESUME;
-            case DownloadManager.ERROR_INSUFFICIENT_SPACE:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
-            case DownloadManager.PAUSED_QUEUED_FOR_WIFI:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__PENDING_WAITING_FOR_WIFI;
-            default:
-                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_UNKNOWN;
-        }
-    }
-
     /**
      * Updates the data store with the current number of consecutive log list update failures.
      *
@@ -374,17 +342,19 @@
 
     @VisibleForTesting
     long getPublicKeyDownloadId() {
-        return mDataStore.getPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, -1);
+        return mDataStore.getPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, /* defaultValue= */ -1);
     }
 
     @VisibleForTesting
-    long getMetadataDownloadId() {
-        return mDataStore.getPropertyLong(Config.METADATA_DOWNLOAD_ID, -1);
+    long getMetadataDownloadId(CompatibilityVersion compatVersion) {
+        return mDataStore.getPropertyLong(
+                compatVersion.getMetadataPropertyName(), /* defaultValue */ -1);
     }
 
     @VisibleForTesting
-    long getContentDownloadId() {
-        return mDataStore.getPropertyLong(Config.CONTENT_DOWNLOAD_ID, -1);
+    long getContentDownloadId(CompatibilityVersion compatVersion) {
+        return mDataStore.getPropertyLong(
+                compatVersion.getContentPropertyName(), /* defaultValue= */ -1);
     }
 
     @VisibleForTesting
@@ -394,38 +364,27 @@
 
     @VisibleForTesting
     boolean hasMetadataDownloadId() {
-        return getMetadataDownloadId() != -1;
+        return mCompatVersions.stream()
+                .map(this::getMetadataDownloadId)
+                .anyMatch(downloadId -> downloadId != -1);
     }
 
     @VisibleForTesting
     boolean hasContentDownloadId() {
-        return getContentDownloadId() != -1;
-    }
-
-    @VisibleForTesting
-    boolean isPublicKeyDownloadId(long downloadId) {
-        return getPublicKeyDownloadId() == downloadId;
-    }
-
-    @VisibleForTesting
-    boolean isMetadataDownloadId(long downloadId) {
-        return getMetadataDownloadId() == downloadId;
-    }
-
-    @VisibleForTesting
-    boolean isContentDownloadId(long downloadId) {
-        return getContentDownloadId() == downloadId;
+        return mCompatVersions.stream()
+                .map(this::getContentDownloadId)
+                .anyMatch(downloadId -> downloadId != -1);
     }
 
     private Uri getPublicKeyDownloadUri() {
         return mDownloadHelper.getUri(getPublicKeyDownloadId());
     }
 
-    private Uri getMetadataDownloadUri() {
-        return mDownloadHelper.getUri(getMetadataDownloadId());
+    private Uri getMetadataDownloadUri(CompatibilityVersion compatVersion) {
+        return mDownloadHelper.getUri(getMetadataDownloadId(compatVersion));
     }
 
-    private Uri getContentDownloadUri() {
-        return mDownloadHelper.getUri(getContentDownloadId());
+    private Uri getContentDownloadUri(CompatibilityVersion compatVersion) {
+        return mDownloadHelper.getUri(getContentDownloadId(compatVersion));
     }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
deleted file mode 100644
index 9970667..0000000
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * 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.net.ct;
-
-import android.util.Log;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Installer of CT log lists. */
-public class CertificateTransparencyInstaller {
-
-    private static final String TAG = "CertificateTransparencyInstaller";
-
-    private final Map<String, CompatibilityVersion> mCompatVersions = new HashMap<>();
-
-    // The CT root directory.
-    private final File mRootDirectory;
-
-    public CertificateTransparencyInstaller(File rootDirectory) {
-        mRootDirectory = rootDirectory;
-    }
-
-    public CertificateTransparencyInstaller(String rootDirectoryPath) {
-        this(new File(rootDirectoryPath));
-    }
-
-    public CertificateTransparencyInstaller() {
-        this(Config.CT_ROOT_DIRECTORY_PATH);
-    }
-
-    void addCompatibilityVersion(String versionName) {
-        removeCompatibilityVersion(versionName);
-        CompatibilityVersion newCompatVersion =
-                new CompatibilityVersion(new File(mRootDirectory, versionName));
-        mCompatVersions.put(versionName, newCompatVersion);
-    }
-
-    void removeCompatibilityVersion(String versionName) {
-        CompatibilityVersion compatVersion = mCompatVersions.remove(versionName);
-        if (compatVersion != null && !compatVersion.delete()) {
-            Log.w(TAG, "Could not delete compatibility version directory.");
-        }
-    }
-
-    CompatibilityVersion getCompatibilityVersion(String versionName) {
-        return mCompatVersions.get(versionName);
-    }
-
-    /**
-     * Install a new log list to use during SCT verification.
-     *
-     * @param compatibilityVersion the compatibility version of the new log list
-     * @param newContent an input stream providing the log list
-     * @param version the minor version of the new log list
-     * @return true if the log list was installed successfully, false otherwise.
-     * @throws IOException if the list cannot be saved in the CT directory.
-     */
-    public boolean install(String compatibilityVersion, InputStream newContent, String version)
-            throws IOException {
-        CompatibilityVersion compatVersion = mCompatVersions.get(compatibilityVersion);
-        if (compatVersion == null) {
-            Log.e(TAG, "No compatibility version for " + compatibilityVersion);
-            return false;
-        }
-        // Ensure root directory exists and is readable.
-        DirectoryUtils.makeDir(mRootDirectory);
-
-        if (!compatVersion.install(newContent, version)) {
-            Log.e(TAG, "Failed to install logs version " + version);
-            return false;
-        }
-        Log.i(TAG, "New logs installed at " + compatVersion.getLogsDir());
-        return true;
-    }
-}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
index baca2e3..a8acc60 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
@@ -22,6 +22,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.content.pm.PackageManager;
 import android.os.Build;
 import android.os.ConfigUpdate;
 import android.os.SystemClock;
@@ -32,9 +33,10 @@
 public class CertificateTransparencyJob extends BroadcastReceiver {
 
     private static final String TAG = "CertificateTransparencyJob";
+    private static final String UPDATE_CONFIG_PERMISSION = "android.permission.UPDATE_CONFIG";
 
     private final Context mContext;
-    private final DataStore mDataStore;
+    private final CompatibilityVersion mCompatVersion;
     private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
     private final AlarmManager mAlarmManager;
     private final PendingIntent mPendingIntent;
@@ -43,12 +45,16 @@
 
     /** Creates a new {@link CertificateTransparencyJob} object. */
     public CertificateTransparencyJob(
-            Context context,
-            DataStore dataStore,
-            CertificateTransparencyDownloader certificateTransparencyDownloader) {
+            Context context, CertificateTransparencyDownloader certificateTransparencyDownloader) {
         mContext = context;
-        mDataStore = dataStore;
+        mCompatVersion =
+                new CompatibilityVersion(
+                        Config.COMPATIBILITY_VERSION,
+                        Config.URL_SIGNATURE,
+                        Config.URL_LOG_LIST,
+                        Config.CT_ROOT_DIRECTORY_PATH);
         mCertificateTransparencyDownloader = certificateTransparencyDownloader;
+        mCertificateTransparencyDownloader.addCompatibilityVersion(mCompatVersion);
         mAlarmManager = context.getSystemService(AlarmManager.class);
         mPendingIntent =
                 PendingIntent.getBroadcast(
@@ -78,6 +84,7 @@
         mContext.unregisterReceiver(this);
         mAlarmManager.cancel(mPendingIntent);
         mCertificateTransparencyDownloader.stop();
+        mCompatVersion.delete();
         mDependenciesReady = false;
 
         if (Config.DEBUG) {
@@ -91,20 +98,19 @@
             Log.w(TAG, "Received unexpected broadcast with action " + intent);
             return;
         }
+        if (context.checkCallingOrSelfPermission(UPDATE_CONFIG_PERMISSION)
+                != PackageManager.PERMISSION_GRANTED) {
+            Log.e(TAG, "Caller does not have UPDATE_CONFIG permission.");
+            return;
+        }
         if (Config.DEBUG) {
             Log.d(TAG, "Starting CT daily job.");
         }
         if (!mDependenciesReady) {
-            mDataStore.load();
             mCertificateTransparencyDownloader.start();
             mDependenciesReady = true;
         }
 
-        mDataStore.setProperty(Config.CONTENT_URL, Config.URL_LOG_LIST);
-        mDataStore.setProperty(Config.METADATA_URL, Config.URL_SIGNATURE);
-        mDataStore.setProperty(Config.PUBLIC_KEY_URL, Config.URL_PUBLIC_KEY);
-        mDataStore.store();
-
         if (mCertificateTransparencyDownloader.startPublicKeyDownload() == -1) {
             Log.e(TAG, "Public key download not started.");
         } else if (Config.DEBUG) {
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java
index 93493c2..913c472 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java
@@ -16,22 +16,24 @@
 
 package com.android.server.net.ct;
 
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED;
+/** Interface with logging to statsd for Certificate Transparency. */
+public interface CertificateTransparencyLogger {
 
-/** Helper class to interface with logging to statsd. */
-public class CertificateTransparencyLogger {
-
-    public CertificateTransparencyLogger() {}
+    /**
+     * Logs a CTLogListUpdateFailed event to statsd, when failure is provided by DownloadManager.
+     *
+     * @param downloadStatus DownloadManager failure status why the log list wasn't updated
+     * @param failureCount number of consecutive log list update failures
+     */
+    void logCTLogListUpdateFailedEventWithDownloadStatus(int downloadStatus, int failureCount);
 
     /**
      * Logs a CTLogListUpdateFailed event to statsd, when no HTTP error status code is present.
      *
-     * @param failureReason reason why the log list wasn't updated (e.g. DownloadManager failures)
+     * @param failureReason reason why the log list wasn't updated
      * @param failureCount number of consecutive log list update failures
      */
-    public void logCTLogListUpdateFailedEvent(int failureReason, int failureCount) {
-        logCTLogListUpdateFailedEvent(failureReason, failureCount, /* httpErrorStatusCode= */ 0);
-    }
+    void logCTLogListUpdateFailedEvent(int failureReason, int failureCount);
 
     /**
      * Logs a CTLogListUpdateFailed event to statsd, when an HTTP error status code is provided.
@@ -40,13 +42,7 @@
      * @param failureCount number of consecutive log list update failures
      * @param httpErrorStatusCode if relevant, the HTTP error status code from DownloadManager
      */
-    public void logCTLogListUpdateFailedEvent(
-            int failureReason, int failureCount, int httpErrorStatusCode) {
-        CertificateTransparencyStatsLog.write(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED,
-                failureReason,
-                failureCount,
-                httpErrorStatusCode
-        );
-    }
-}
+    void logCTLogListUpdateFailedEvent(
+            int failureReason, int failureCount, int httpErrorStatusCode);
+
+}
\ No newline at end of file
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java
new file mode 100644
index 0000000..b97a885
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java
@@ -0,0 +1,77 @@
+/*
+ * 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.net.ct;
+
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DEVICE_OFFLINE;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DOWNLOAD_CANNOT_RESUME;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_TOO_MANY_REDIRECTS;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_UNKNOWN;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__PENDING_WAITING_FOR_WIFI;
+
+import android.app.DownloadManager;
+
+/** Implementation for logging to statsd for Certificate Transparency. */
+class CertificateTransparencyLoggerImpl implements CertificateTransparencyLogger {
+
+    @Override
+    public void logCTLogListUpdateFailedEventWithDownloadStatus(
+            int downloadStatus, int failureCount) {
+        logCTLogListUpdateFailedEvent(downloadStatusToFailureReason(downloadStatus), failureCount);
+    }
+
+    @Override
+    public void logCTLogListUpdateFailedEvent(int failureReason, int failureCount) {
+        logCTLogListUpdateFailedEvent(failureReason, failureCount, /* httpErrorStatusCode= */ 0);
+    }
+
+    @Override
+    public void logCTLogListUpdateFailedEvent(
+            int failureReason, int failureCount, int httpErrorStatusCode) {
+        CertificateTransparencyStatsLog.write(
+                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED,
+                failureReason,
+                failureCount,
+                httpErrorStatusCode
+        );
+    }
+
+    /** Converts DownloadStatus reason into failure reason to log. */
+    private int downloadStatusToFailureReason(int downloadStatusReason) {
+        switch (downloadStatusReason) {
+            case DownloadManager.PAUSED_WAITING_TO_RETRY:
+            case DownloadManager.PAUSED_WAITING_FOR_NETWORK:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DEVICE_OFFLINE;
+            case DownloadManager.ERROR_UNHANDLED_HTTP_CODE:
+            case DownloadManager.ERROR_HTTP_DATA_ERROR:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
+            case DownloadManager.ERROR_TOO_MANY_REDIRECTS:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_TOO_MANY_REDIRECTS;
+            case DownloadManager.ERROR_CANNOT_RESUME:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DOWNLOAD_CANNOT_RESUME;
+            case DownloadManager.ERROR_INSUFFICIENT_SPACE:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
+            case DownloadManager.PAUSED_QUEUED_FOR_WIFI:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__PENDING_WAITING_FOR_WIFI;
+            default:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_UNKNOWN;
+        }
+    }
+
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
index 4569628..ed98056 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -18,18 +18,18 @@
 
 import static android.security.Flags.certificateTransparencyConfiguration;
 
-import static com.android.net.ct.flags.Flags.certificateTransparencyJob;
 import static com.android.net.ct.flags.Flags.certificateTransparencyService;
 
 import android.annotation.RequiresApi;
 import android.content.Context;
 import android.net.ct.ICertificateTransparencyManager;
 import android.os.Build;
-import android.util.Log;
 import android.provider.DeviceConfig;
 import android.provider.DeviceConfig.Properties;
+import android.util.Log;
 
 import com.android.server.SystemService;
+
 import java.util.concurrent.Executors;
 
 /** Implementation of the Certificate Transparency service. */
@@ -61,10 +61,8 @@
                         dataStore,
                         downloadHelper,
                         signatureVerifier,
-                        new CertificateTransparencyInstaller(),
-                        new CertificateTransparencyLogger());
-        mCertificateTransparencyJob =
-                new CertificateTransparencyJob(context, dataStore, downloader);
+                        new CertificateTransparencyLoggerImpl());
+        mCertificateTransparencyJob = new CertificateTransparencyJob(context, downloader);
     }
 
     /**
diff --git a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
index 27488b5..fdeb746 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
@@ -15,58 +15,92 @@
  */
 package com.android.server.net.ct;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.annotation.RequiresApi;
+import android.os.Build;
 import android.system.ErrnoException;
 import android.system.Os;
+import android.util.Log;
 
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 
 /** Represents a compatibility version directory. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 class CompatibilityVersion {
 
+    private static final String TAG = "CompatibilityVersion";
+
     static final String LOGS_DIR_PREFIX = "logs-";
     static final String LOGS_LIST_FILE_NAME = "log_list.json";
+    static final String CURRENT_LOGS_DIR_SYMLINK_NAME = "current";
 
-    private static final String CURRENT_LOGS_DIR_SYMLINK_NAME = "current";
+    private final String mCompatVersion;
 
-    private final File mRootDirectory;
+    private final String mMetadataUrl;
+    private final String mContentUrl;
+    private final File mVersionDirectory;
     private final File mCurrentLogsDirSymlink;
 
-    private File mCurrentLogsDir = null;
+    CompatibilityVersion(
+            String compatVersion, String metadataUrl, String contentUrl, File rootDirectory) {
+        mCompatVersion = compatVersion;
+        mMetadataUrl = metadataUrl;
+        mContentUrl = contentUrl;
+        mVersionDirectory = new File(rootDirectory, compatVersion);
+        mCurrentLogsDirSymlink = new File(mVersionDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
+    }
 
-    CompatibilityVersion(File rootDirectory) {
-        mRootDirectory = rootDirectory;
-        mCurrentLogsDirSymlink = new File(mRootDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
+    CompatibilityVersion(
+            String compatVersion, String metadataUrl, String contentUrl, String rootDirectoryPath) {
+        this(compatVersion, metadataUrl, contentUrl, new File(rootDirectoryPath));
     }
 
     /**
      * Installs a log list within this compatibility version directory.
      *
      * @param newContent an input stream providing the log list
-     * @param version the version number of the log list
      * @return true if the log list was installed successfully, false otherwise.
      * @throws IOException if the list cannot be saved in the CT directory.
      */
-    boolean install(InputStream newContent, String version) throws IOException {
-        // To support atomically replacing the old configuration directory with the new there's a
-        // bunch of steps. We create a new directory with the logs and then do an atomic update of
-        // the current symlink to point to the new directory.
-        // 1. Ensure that the root directory exists and is readable.
-        DirectoryUtils.makeDir(mRootDirectory);
+    boolean install(InputStream newContent) throws IOException {
+        String content = new String(newContent.readAllBytes(), UTF_8);
+        try {
+            return install(
+                    new ByteArrayInputStream(content.getBytes()),
+                    new JSONObject(content).getString("version"));
+        } catch (JSONException e) {
+            Log.e(TAG, "invalid log list format", e);
+            return false;
+        }
+    }
 
-        File newLogsDir = new File(mRootDirectory, LOGS_DIR_PREFIX + version);
+    private boolean install(InputStream newContent, String version) throws IOException {
+        // To support atomically replacing the old configuration directory with the new
+        // there's a bunch of steps. We create a new directory with the logs and then do
+        // an atomic update of the current symlink to point to the new directory.
+        // 1. Ensure the path to the root directory exists and is readable.
+        DirectoryUtils.makeDir(mVersionDirectory);
+
+        File newLogsDir = new File(mVersionDirectory, LOGS_DIR_PREFIX + version);
         // 2. Handle the corner case where the new directory already exists.
         if (newLogsDir.exists()) {
-            // If the symlink has already been updated then the update died between steps 6 and 7
-            // and so we cannot delete the directory since it is in use.
+            // If the symlink has already been updated then the update died between steps 6
+            // and 7 and so we cannot delete the directory since it is in use.
             if (newLogsDir.getCanonicalPath().equals(mCurrentLogsDirSymlink.getCanonicalPath())) {
+                Log.i(TAG, newLogsDir + " already exists, skipping install.");
                 deleteOldLogDirectories();
                 return false;
             }
-            // If the symlink has not been updated then the previous installation failed and this is
-            // a re-attempt. Clean-up leftover files and try again.
+            // If the symlink has not been updated then the previous installation failed and
+            // this is a re-attempt. Clean-up leftover files and try again.
             DirectoryUtils.removeDir(newLogsDir);
         }
         try {
@@ -80,8 +114,8 @@
             }
             DirectoryUtils.setWorldReadable(logListFile);
 
-            // 5. Create temp symlink. We rename this to the target symlink to get an atomic update.
-            File tempSymlink = new File(mRootDirectory, "new_symlink");
+            // 5. Create temp symlink. We rename to the target symlink for an atomic update.
+            File tempSymlink = new File(mVersionDirectory, "new_symlink");
             try {
                 Os.symlink(newLogsDir.getCanonicalPath(), tempSymlink.getCanonicalPath());
             } catch (ErrnoException e) {
@@ -95,17 +129,33 @@
             throw e;
         }
         // 7. Cleanup
-        mCurrentLogsDir = newLogsDir;
+        Log.i(TAG, "New logs installed at " + newLogsDir);
         deleteOldLogDirectories();
         return true;
     }
 
-    File getRootDir() {
-        return mRootDirectory;
+    String getCompatVersion() {
+        return mCompatVersion;
     }
 
-    File getLogsDir() {
-        return mCurrentLogsDir;
+    String getMetadataUrl() {
+        return mMetadataUrl;
+    }
+
+    String getMetadataPropertyName() {
+        return mCompatVersion + "_" + Config.METADATA_DOWNLOAD_ID;
+    }
+
+    String getContentUrl() {
+        return mContentUrl;
+    }
+
+    String getContentPropertyName() {
+        return mCompatVersion + "_" + Config.CONTENT_DOWNLOAD_ID;
+    }
+
+    File getVersionDir() {
+        return mVersionDirectory;
     }
 
     File getLogsDirSymlink() {
@@ -113,19 +163,21 @@
     }
 
     File getLogsFile() {
-        return new File(mCurrentLogsDir, LOGS_LIST_FILE_NAME);
+        return new File(mCurrentLogsDirSymlink, LOGS_LIST_FILE_NAME);
     }
 
-    boolean delete() {
-        return DirectoryUtils.removeDir(mRootDirectory);
+    void delete() {
+        if (!DirectoryUtils.removeDir(mVersionDirectory)) {
+            Log.w(TAG, "Could not delete compatibility version directory " + mVersionDirectory);
+        }
     }
 
     private void deleteOldLogDirectories() throws IOException {
-        if (!mRootDirectory.exists()) {
+        if (!mVersionDirectory.exists()) {
             return;
         }
         File currentTarget = mCurrentLogsDirSymlink.getCanonicalFile();
-        for (File file : mRootDirectory.listFiles()) {
+        for (File file : mVersionDirectory.listFiles()) {
             if (!currentTarget.equals(file.getCanonicalFile())
                     && file.getName().startsWith(LOGS_DIR_PREFIX)) {
                 DirectoryUtils.removeDir(file);
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
index aafee60..592bc4e 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -48,11 +48,8 @@
 
     // properties
     static final String VERSION = "version";
-    static final String CONTENT_URL = "content_url";
     static final String CONTENT_DOWNLOAD_ID = "content_download_id";
-    static final String METADATA_URL = "metadata_url";
     static final String METADATA_DOWNLOAD_ID = "metadata_download_id";
-    static final String PUBLIC_KEY_URL = "public_key_url";
     static final String PUBLIC_KEY_DOWNLOAD_ID = "public_key_download_id";
     static final String LOG_LIST_UPDATE_FAILURE_COUNT = "log_list_update_failure_count";
 
diff --git a/networksecurity/service/src/com/android/server/net/ct/DataStore.java b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
index 3779269..8180316 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DataStore.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
@@ -57,6 +57,11 @@
         }
     }
 
+    boolean delete() {
+        clear();
+        return mPropertyFile.delete();
+    }
+
     long getPropertyLong(String key, long defaultValue) {
         return Optional.ofNullable(getProperty(key)).map(Long::parseLong).orElse(defaultValue);
     }
diff --git a/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
index ba42a82..54e277a 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
@@ -25,7 +25,7 @@
 class DirectoryUtils {
 
     static void makeDir(File dir) throws IOException {
-        dir.mkdir();
+        dir.mkdirs();
         if (!dir.isDirectory()) {
             throw new IOException("Unable to make directory " + dir.getCanonicalPath());
         }
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index 25f0dc1..34f8dd1 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -13,9 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package com.android.server.net.ct;
 
-import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION;
 import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS;
@@ -24,7 +24,6 @@
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
@@ -73,15 +72,14 @@
 public class CertificateTransparencyDownloaderTest {
 
     @Mock private DownloadManager mDownloadManager;
-    @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
     @Mock private CertificateTransparencyLogger mLogger;
 
     private PrivateKey mPrivateKey;
     private PublicKey mPublicKey;
     private Context mContext;
-    private File mTempFile;
     private DataStore mDataStore;
     private SignatureVerifier mSignatureVerifier;
+    private CompatibilityVersion mCompatVersion;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
     private long mNextDownloadId = 666;
@@ -95,8 +93,7 @@
         mPublicKey = keyPair.getPublic();
 
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
-        mTempFile = File.createTempFile("datastore-test", ".properties");
-        mDataStore = new DataStore(mTempFile);
+        mDataStore = new DataStore(File.createTempFile("datastore-test", ".properties"));
         mSignatureVerifier = new SignatureVerifier(mContext);
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
@@ -104,56 +101,64 @@
                         mDataStore,
                         new DownloadHelper(mDownloadManager),
                         mSignatureVerifier,
-                        mCertificateTransparencyInstaller,
                         mLogger);
+        mCompatVersion =
+                new CompatibilityVersion(
+                        /* compatVersion= */ "v666",
+                        Config.URL_SIGNATURE,
+                        Config.URL_LOG_LIST,
+                        mContext.getFilesDir());
 
-        prepareDataStore();
         prepareDownloadManager();
+        mCertificateTransparencyDownloader.addCompatibilityVersion(mCompatVersion);
+        mCertificateTransparencyDownloader.start();
     }
 
     @After
     public void tearDown() {
-        mTempFile.delete();
         mSignatureVerifier.resetPublicKey();
+        mCertificateTransparencyDownloader.stop();
+        mCompatVersion.delete();
     }
 
     @Test
     public void testDownloader_startPublicKeyDownload() {
         assertThat(mCertificateTransparencyDownloader.hasPublicKeyDownloadId()).isFalse();
+
         long downloadId = mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasPublicKeyDownloadId()).isTrue();
-        assertThat(mCertificateTransparencyDownloader.isPublicKeyDownloadId(downloadId)).isTrue();
+        assertThat(mCertificateTransparencyDownloader.getPublicKeyDownloadId())
+                .isEqualTo(downloadId);
     }
 
     @Test
     public void testDownloader_startMetadataDownload() {
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
-        long downloadId = mCertificateTransparencyDownloader.startMetadataDownload();
+
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isTrue();
-        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isTrue();
     }
 
     @Test
     public void testDownloader_startContentDownload() {
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
-        long downloadId = mCertificateTransparencyDownloader.startContentDownload();
+
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isTrue();
-        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isTrue();
     }
 
     @Test
     public void testDownloader_publicKeyDownloadSuccess_updatePublicKey_startMetadataDownload()
             throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
-        setSuccessfulDownload(publicKeyId, writePublicKeyToFile(mPublicKey));
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(publicKeyId));
+                mContext, makePublicKeyDownloadCompleteIntent(writePublicKeyToFile(mPublicKey)));
 
         assertThat(mSignatureVerifier.getPublicKey()).hasValue(mPublicKey);
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isTrue();
@@ -163,14 +168,14 @@
     public void
             testDownloader_publicKeyDownloadSuccess_updatePublicKeyFail_doNotStartMetadataDownload()
                     throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
-        setSuccessfulDownload(
-                publicKeyId, writeToFile("i_am_not_a_base64_encoded_public_key".getBytes()));
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(publicKeyId));
+                mContext,
+                makePublicKeyDownloadCompleteIntent(
+                        writeToFile("i_am_not_a_base64_encoded_public_key".getBytes())));
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
@@ -178,17 +183,15 @@
 
     @Test
     public void testDownloader_publicKeyDownloadFail_doNotUpdatePublicKey() throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
-        setFailedDownload(
-                publicKeyId, // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(publicKeyId);
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_HTTP_DATA_ERROR));
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
@@ -196,225 +199,208 @@
 
     @Test
     public void testDownloader_publicKeyDownloadFail_failureThresholdExceeded_logsFailure()
-                throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
+            throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
-        setFailedDownload(
-                publicKeyId, // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(publicKeyId);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_INSUFFICIENT_SPACE));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEventWithDownloadStatus(
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
     public void testDownloader_publicKeyDownloadFail_failureThresholdNotMet_doesNotLog()
-                throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
+            throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
         // Set the failure count to well below the threshold
         mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
-        setFailedDownload(
-                publicKeyId, // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(publicKeyId);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_HTTP_DATA_ERROR));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(1);
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
         verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
     }
 
     @Test
     public void testDownloader_metadataDownloadSuccess_startContentDownload() {
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, new File("log_list.sig"));
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+                mContext,
+                makeMetadataDownloadCompleteIntent(mCompatVersion, new File("log_list.sig")));
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isTrue();
     }
 
     @Test
     public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setFailedDownload(
-                metadataId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
     }
 
     @Test
     public void testDownloader_metadataDownloadFail_failureThresholdExceeded_logsFailure()
-                throws Exception {
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
+            throws Exception {
+        mCertificateTransparencyDownloader.startMetadataDownload();
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
-        setFailedDownload(
-                metadataId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEventWithDownloadStatus(
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
     public void testDownloader_metadataDownloadFail_failureThresholdNotMet_doesNotLog()
-                throws Exception {
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
+            throws Exception {
+        mCertificateTransparencyDownloader.startMetadataDownload();
         // Set the failure count to well below the threshold
         mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
-        setFailedDownload(
-                metadataId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(1);
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
         verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
     }
 
     @Test
-    public void testDownloader_contentDownloadSuccess_installSuccess_updateDataStore()
-            throws Exception {
+    public void testDownloader_contentDownloadSuccess_installSuccess() throws Exception {
         String newVersion = "456";
         File logListFile = makeLogListFile(newVersion);
         File metadataFile = sign(logListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(true);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
         assertInstallSuccessful(newVersion);
     }
 
     @Test
     public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setFailedDownload(
-                contentId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
 
-        verify(mCertificateTransparencyInstaller, never()).install(any(), any(), any());
         assertNoVersionIsInstalled();
     }
 
     @Test
     public void testDownloader_contentDownloadFail_failureThresholdExceeded_logsFailure()
-                throws Exception {
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
+            throws Exception {
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
-        setFailedDownload(
-                contentId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEventWithDownloadStatus(
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
     public void testDownloader_contentDownloadFail_failureThresholdNotMet_doesNotLog()
-                throws Exception {
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
+            throws Exception {
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
         // Set the failure count to well below the threshold
         mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
-        setFailedDownload(
-                contentId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(1);
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
         verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
     }
 
     @Test
-    public void testDownloader_contentDownloadSuccess_installFail_doNotUpdateDataStore()
+    public void testDownloader_contentDownloadSuccess_invalidLogList_installFails()
             throws Exception {
-        File logListFile = makeLogListFile("456");
-        File metadataFile = sign(logListFile);
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(false);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
 
         assertNoVersionIsInstalled();
     }
@@ -426,30 +412,31 @@
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
+        mCertificateTransparencyDownloader.startMetadataDownload();
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
         // Set the public key to be missing
         mSignatureVerifier.resetPublicKey();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
-        verify(mLogger, never()).logCTLogListUpdateFailedEvent(
-                eq(CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION),
-                anyInt()
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION),
+                        anyInt());
     }
 
     @Test
@@ -464,31 +451,31 @@
         KeyPairGenerator instance = KeyPairGenerator.getInstance("EC");
         mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
 
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
-
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
         // Act
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
         // Assert
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, never()).logCTLogListUpdateFailedEvent(
-                eq(CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
-                anyInt()
-        );
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
+                        anyInt());
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
@@ -503,31 +490,31 @@
         KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
         mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
 
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
-
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
         // Act
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
         // Assert
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, never()).logCTLogListUpdateFailedEvent(
-                eq(CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
-                anyInt()
-        );
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
+                        anyInt());
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
@@ -536,85 +523,84 @@
                     throws Exception {
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
-        // Set the public key wrong, so signature verification fails
-        mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
+        // Set the key to be deliberately wrong by using diff key pair
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
         // Set the failure count to well below the threshold
         mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
 
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(1);
-        verify(mLogger, never()).logCTLogListUpdateFailedEvent(
-                eq(CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
-                anyInt()
-        );
-        verify(mLogger, never()).logCTLogListUpdateFailedEvent(
-                eq(CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION),
-                anyInt()
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
+                        anyInt());
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION),
+                        anyInt());
     }
 
     @Test
     public void
             testDownloader_contentDownloadSuccess_installFail_failureThresholdExceeded_logsFailure()
                     throws Exception {
-        File logListFile = makeLogListFile("456");
-        File metadataFile = sign(logListFile);
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
         // Set the failure count to just below the threshold
-        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(false);
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT, Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
-        verify(mLogger, times(1)).logCTLogListUpdateFailedEvent(
-                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS,
-                Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD
-        );
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS,
+                        Config.LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
     public void
             testDownloader_contentDownloadSuccess_installFail_failureThresholdNotMet_doesNotLog()
                     throws Exception {
-        File logListFile = makeLogListFile("456");
-        File metadataFile = sign(logListFile);
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
         // Set the failure count to well below the threshold
         mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(false);
 
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
 
-        assertThat(mDataStore.getPropertyInt(
-                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
-                        .isEqualTo(1);
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
         verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
     }
 
     @Test
@@ -623,17 +609,14 @@
         File logListFile = makeLogListFile("456");
         File metadataFile = File.createTempFile("log_list-wrong_metadata", "sig");
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
 
         assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        verify(mCertificateTransparencyInstaller, never())
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), anyString());
         assertNoVersionIsInstalled();
     }
 
@@ -643,17 +626,14 @@
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
         mSignatureVerifier.resetPublicKey();
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
 
         assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        verify(mCertificateTransparencyInstaller, never())
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), anyString());
         assertNoVersionIsInstalled();
     }
 
@@ -667,52 +647,37 @@
         assertNoVersionIsInstalled();
 
         // 1. Start download of public key.
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
-        // 2. On successful public key download, set the key and start the metatadata download.
-        setSuccessfulDownload(publicKeyId, publicKeyFile);
-
+        // 2. On successful public key download, set the key and start the metatadata
+        // download.
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(publicKeyId));
+                mContext, makePublicKeyDownloadCompleteIntent(publicKeyFile));
 
         // 3. On successful metadata download, start the content download.
-        long metadataId = mCertificateTransparencyDownloader.getMetadataDownloadId();
-        setSuccessfulDownload(metadataId, metadataFile);
-
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
 
-        // 4. On successful content download, verify the signature and install the new version.
-        long contentId = mCertificateTransparencyDownloader.getContentDownloadId();
-        setSuccessfulDownload(contentId, logListFile);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(true);
-
+        // 4. On successful content download, verify the signature and install the new
+        // version.
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
         assertInstallSuccessful(newVersion);
     }
 
     private void assertNoVersionIsInstalled() {
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mCompatVersion.getVersionDir().exists()).isFalse();
     }
 
     private void assertInstallSuccessful(String version) {
-        assertThat(mDataStore.getProperty(Config.VERSION)).isEqualTo(version);
-    }
-
-    private Intent makeDownloadCompleteIntent(long downloadId) {
-        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
-                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
-    }
-
-    private void prepareDataStore() {
-        mDataStore.load();
-        mDataStore.setProperty(Config.CONTENT_URL, Config.URL_LOG_LIST);
-        mDataStore.setProperty(Config.METADATA_URL, Config.URL_SIGNATURE);
-        mDataStore.setProperty(Config.PUBLIC_KEY_URL, Config.URL_PUBLIC_KEY);
+        File logsDir =
+                new File(
+                        mCompatVersion.getVersionDir(),
+                        CompatibilityVersion.LOGS_DIR_PREFIX + version);
+        assertThat(logsDir.exists()).isTrue();
+        File logsFile = new File(logsDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
+        assertThat(logsFile.exists()).isTrue();
     }
 
     private void prepareDownloadManager() {
@@ -720,6 +685,32 @@
                 .thenAnswer(invocation -> mNextDownloadId++);
     }
 
+    private Intent makePublicKeyDownloadCompleteIntent(File publicKeyfile) {
+        return makeDownloadCompleteIntent(
+                mCertificateTransparencyDownloader.getPublicKeyDownloadId(), publicKeyfile);
+    }
+
+    private Intent makeMetadataDownloadCompleteIntent(
+            CompatibilityVersion compatVersion, File signatureFile) {
+        return makeDownloadCompleteIntent(
+                mCertificateTransparencyDownloader.getMetadataDownloadId(compatVersion),
+                signatureFile);
+    }
+
+    private Intent makeContentDownloadCompleteIntent(
+            CompatibilityVersion compatVersion, File logListFile) {
+        return makeDownloadCompleteIntent(
+                mCertificateTransparencyDownloader.getContentDownloadId(compatVersion),
+                logListFile);
+    }
+
+    private Intent makeDownloadCompleteIntent(long downloadId, File file) {
+        when(mDownloadManager.query(any(Query.class))).thenReturn(makeSuccessfulDownloadCursor());
+        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(Uri.fromFile(file));
+        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
+    }
+
     private Cursor makeSuccessfulDownloadCursor() {
         MatrixCursor cursor =
                 new MatrixCursor(
@@ -730,9 +721,26 @@
         return cursor;
     }
 
-    private void setSuccessfulDownload(long downloadId, File file) {
-        when(mDownloadManager.query(any(Query.class))).thenReturn(makeSuccessfulDownloadCursor());
-        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(Uri.fromFile(file));
+    private Intent makePublicKeyDownloadFailedIntent(int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getPublicKeyDownloadId(), error);
+    }
+
+    private Intent makeMetadataDownloadFailedIntent(CompatibilityVersion compatVersion, int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getMetadataDownloadId(compatVersion), error);
+    }
+
+    private Intent makeContentDownloadFailedIntent(CompatibilityVersion compatVersion, int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getContentDownloadId(compatVersion), error);
+    }
+
+    private Intent makeDownloadFailedIntent(long downloadId, int error) {
+        when(mDownloadManager.query(any(Query.class))).thenReturn(makeFailedDownloadCursor(error));
+        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(null);
+        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
 
     private Cursor makeFailedDownloadCursor(int error) {
@@ -745,16 +753,6 @@
         return cursor;
     }
 
-    private void setFailedDownload(long downloadId, int... downloadManagerErrors) {
-        Cursor first = makeFailedDownloadCursor(downloadManagerErrors[0]);
-        Cursor[] others = new Cursor[downloadManagerErrors.length - 1];
-        for (int i = 1; i < downloadManagerErrors.length; i++) {
-            others[i - 1] = makeFailedDownloadCursor(downloadManagerErrors[i]);
-        }
-        when(mDownloadManager.query(any())).thenReturn(first, others);
-        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(null);
-    }
-
     private File writePublicKeyToFile(PublicKey publicKey)
             throws IOException, GeneralSecurityException {
         return writeToFile(Base64.getEncoder().encode(publicKey.getEncoded()));
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
deleted file mode 100644
index 50d3f23..0000000
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
- * 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.net.ct;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import androidx.test.platform.app.InstrumentationRegistry;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-/** Tests for the {@link CertificateTransparencyInstaller}. */
-@RunWith(JUnit4.class)
-public class CertificateTransparencyInstallerTest {
-
-    private static final String TEST_VERSION = "test-v1";
-
-    private File mTestDir =
-            new File(
-                    InstrumentationRegistry.getInstrumentation().getContext().getFilesDir(),
-                    "test-dir");
-    private CertificateTransparencyInstaller mCertificateTransparencyInstaller =
-            new CertificateTransparencyInstaller(mTestDir);
-
-    @Before
-    public void setUp() {
-        mCertificateTransparencyInstaller.addCompatibilityVersion(TEST_VERSION);
-    }
-
-    @After
-    public void tearDown() {
-        mCertificateTransparencyInstaller.removeCompatibilityVersion(TEST_VERSION);
-        DirectoryUtils.removeDir(mTestDir);
-    }
-
-    @Test
-    public void testCompatibilityVersion_installSuccessful() throws IOException {
-        assertThat(mTestDir.mkdir()).isTrue();
-        String content = "i_am_compatible";
-        String version = "i_am_version";
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-
-        try (InputStream inputStream = asStream(content)) {
-            assertThat(compatVersion.install(inputStream, version)).isTrue();
-        }
-        File logsDir = compatVersion.getLogsDir();
-        assertThat(logsDir.exists()).isTrue();
-        assertThat(logsDir.isDirectory()).isTrue();
-        assertThat(logsDir.getAbsolutePath())
-                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION);
-        File logsListFile = compatVersion.getLogsFile();
-        assertThat(logsListFile.exists()).isTrue();
-        assertThat(logsListFile.getAbsolutePath()).startsWith(logsDir.getAbsolutePath());
-        assertThat(readAsString(logsListFile)).isEqualTo(content);
-        File logsSymlink = compatVersion.getLogsDirSymlink();
-        assertThat(logsSymlink.exists()).isTrue();
-        assertThat(logsSymlink.isDirectory()).isTrue();
-        assertThat(logsSymlink.getAbsolutePath())
-                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION + "/current");
-        assertThat(logsSymlink.getCanonicalPath()).isEqualTo(logsDir.getCanonicalPath());
-
-        assertThat(compatVersion.delete()).isTrue();
-        assertThat(logsDir.exists()).isFalse();
-        assertThat(logsSymlink.exists()).isFalse();
-        assertThat(logsListFile.exists()).isFalse();
-    }
-
-    @Test
-    public void testCompatibilityVersion_versionInstalledFailed() throws IOException {
-        assertThat(mTestDir.mkdir()).isTrue();
-
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-        File rootDir = compatVersion.getRootDir();
-        assertThat(rootDir.mkdir()).isTrue();
-
-        String existingVersion = "666";
-        File existingLogDir =
-                new File(rootDir, CompatibilityVersion.LOGS_DIR_PREFIX + existingVersion);
-        assertThat(existingLogDir.mkdir()).isTrue();
-
-        String existingContent = "somebody_tried_to_install_me_but_failed_halfway_through";
-        File logsListFile = new File(existingLogDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
-        assertThat(logsListFile.createNewFile()).isTrue();
-        writeToFile(logsListFile, existingContent);
-
-        String newContent = "i_am_the_real_content";
-        try (InputStream inputStream = asStream(newContent)) {
-            assertThat(compatVersion.install(inputStream, existingVersion)).isTrue();
-        }
-
-        assertThat(readAsString(logsListFile)).isEqualTo(newContent);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_installSuccessfully() throws IOException {
-        String content = "i_am_a_certificate_and_i_am_transparent";
-        String version = "666";
-
-        try (InputStream inputStream = asStream(content)) {
-            assertThat(
-                            mCertificateTransparencyInstaller.install(
-                                    TEST_VERSION, inputStream, version))
-                    .isTrue();
-        }
-
-        assertThat(mTestDir.exists()).isTrue();
-        assertThat(mTestDir.isDirectory()).isTrue();
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-        File logsDir = compatVersion.getLogsDir();
-        assertThat(logsDir.exists()).isTrue();
-        assertThat(logsDir.isDirectory()).isTrue();
-        assertThat(logsDir.getAbsolutePath())
-                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION);
-        File logsListFile = compatVersion.getLogsFile();
-        assertThat(logsListFile.exists()).isTrue();
-        assertThat(logsListFile.getAbsolutePath()).startsWith(logsDir.getAbsolutePath());
-        assertThat(readAsString(logsListFile)).isEqualTo(content);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_versionIsAlreadyInstalled()
-            throws IOException {
-        String existingVersion = "666";
-        String existingContent = "i_was_already_installed_successfully";
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-
-        DirectoryUtils.makeDir(mTestDir);
-        try (InputStream inputStream = asStream(existingContent)) {
-            assertThat(compatVersion.install(inputStream, existingVersion)).isTrue();
-        }
-
-        try (InputStream inputStream = asStream("i_will_be_ignored")) {
-            assertThat(
-                            mCertificateTransparencyInstaller.install(
-                                    TEST_VERSION, inputStream, existingVersion))
-                    .isFalse();
-        }
-
-        assertThat(readAsString(compatVersion.getLogsFile())).isEqualTo(existingContent);
-    }
-
-    private static InputStream asStream(String string) throws IOException {
-        return new ByteArrayInputStream(string.getBytes());
-    }
-
-    private static String readAsString(File file) throws IOException {
-        return new String(new FileInputStream(file).readAllBytes());
-    }
-
-    private static void writeToFile(File file, String string) throws IOException {
-        try (OutputStream out = new FileOutputStream(file)) {
-            out.write(string.getBytes());
-        }
-    }
-}
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
new file mode 100644
index 0000000..38fff48
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/** Tests for the {@link CompatibilityVersion}. */
+@RunWith(JUnit4.class)
+public class CompatibilityVersionTest {
+
+    private static final String TEST_VERSION = "v123";
+
+    private final File mTestDir =
+            InstrumentationRegistry.getInstrumentation().getContext().getFilesDir();
+    private final CompatibilityVersion mCompatVersion =
+            new CompatibilityVersion(
+                    TEST_VERSION, Config.URL_SIGNATURE, Config.URL_LOG_LIST, mTestDir);
+
+    @After
+    public void tearDown() {
+        mCompatVersion.delete();
+    }
+
+    @Test
+    public void testCompatibilityVersion_versionDirectory_setupSuccessful() {
+        File versionDir = mCompatVersion.getVersionDir();
+        assertThat(versionDir.exists()).isFalse();
+        assertThat(versionDir.getAbsolutePath()).startsWith(mTestDir.getAbsolutePath());
+        assertThat(versionDir.getAbsolutePath()).endsWith(TEST_VERSION);
+    }
+
+    @Test
+    public void testCompatibilityVersion_symlink_setupSuccessful() {
+        File dirSymlink = mCompatVersion.getLogsDirSymlink();
+        assertThat(dirSymlink.exists()).isFalse();
+        assertThat(dirSymlink.getAbsolutePath())
+                .startsWith(mCompatVersion.getVersionDir().getAbsolutePath());
+    }
+
+    @Test
+    public void testCompatibilityVersion_logsFile_setupSuccessful() {
+        File logsFile = mCompatVersion.getLogsFile();
+        assertThat(logsFile.exists()).isFalse();
+        assertThat(logsFile.getAbsolutePath())
+                .startsWith(mCompatVersion.getLogsDirSymlink().getAbsolutePath());
+    }
+
+    @Test
+    public void testCompatibilityVersion_installSuccessful() throws Exception {
+        String version = "i_am_version";
+        JSONObject logList = makeLogList(version, "i_am_content");
+
+        try (InputStream inputStream = asStream(logList)) {
+            assertThat(mCompatVersion.install(inputStream)).isTrue();
+        }
+
+        File logListFile = mCompatVersion.getLogsFile();
+        assertThat(logListFile.exists()).isTrue();
+        assertThat(logListFile.getCanonicalPath())
+                .isEqualTo(
+                        // <path-to-test-files>/v123/logs-i_am_version/log_list.json
+                        new File(
+                                        new File(
+                                                mCompatVersion.getVersionDir(),
+                                                CompatibilityVersion.LOGS_DIR_PREFIX + version),
+                                        CompatibilityVersion.LOGS_LIST_FILE_NAME)
+                                .getCanonicalPath());
+        assertThat(logListFile.getAbsolutePath())
+                .isEqualTo(
+                        // <path-to-test-files>/v123/current/log_list.json
+                        new File(
+                                        new File(
+                                                mCompatVersion.getVersionDir(),
+                                                CompatibilityVersion.CURRENT_LOGS_DIR_SYMLINK_NAME),
+                                        CompatibilityVersion.LOGS_LIST_FILE_NAME)
+                                .getAbsolutePath());
+    }
+
+    @Test
+    public void testCompatibilityVersion_deleteSuccessfully() throws Exception {
+        try (InputStream inputStream = asStream(makeLogList(/* version= */ "123"))) {
+            assertThat(mCompatVersion.install(inputStream)).isTrue();
+        }
+
+        mCompatVersion.delete();
+
+        assertThat(mCompatVersion.getLogsFile().exists()).isFalse();
+    }
+
+    @Test
+    public void testCompatibilityVersion_invalidLogList() throws Exception {
+        try (InputStream inputStream = new ByteArrayInputStream(("not_a_valid_list".getBytes()))) {
+            assertThat(mCompatVersion.install(inputStream)).isFalse();
+        }
+
+        assertThat(mCompatVersion.getLogsFile().exists()).isFalse();
+    }
+
+    @Test
+    public void testCompatibilityVersion_incompleteVersionExists_replacesOldVersion()
+            throws Exception {
+        String existingVersion = "666";
+        File existingLogDir =
+                new File(
+                        mCompatVersion.getVersionDir(),
+                        CompatibilityVersion.LOGS_DIR_PREFIX + existingVersion);
+        assertThat(existingLogDir.mkdirs()).isTrue();
+        File logsListFile = new File(existingLogDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
+        assertThat(logsListFile.createNewFile()).isTrue();
+
+        JSONObject newLogList = makeLogList(existingVersion, "i_am_the_real_content");
+        try (InputStream inputStream = asStream(newLogList)) {
+            assertThat(mCompatVersion.install(inputStream)).isTrue();
+        }
+
+        assertThat(readAsString(logsListFile)).isEqualTo(newLogList.toString());
+    }
+
+    @Test
+    public void testCompatibilityVersion_versionAlreadyExists_installFails() throws Exception {
+        String existingVersion = "666";
+        JSONObject existingLogList = makeLogList(existingVersion, "i_was_installed_successfully");
+        try (InputStream inputStream = asStream(existingLogList)) {
+            assertThat(mCompatVersion.install(inputStream)).isTrue();
+        }
+
+        try (InputStream inputStream = asStream(makeLogList(existingVersion, "i_am_ignored"))) {
+            assertThat(mCompatVersion.install(inputStream)).isFalse();
+        }
+
+        assertThat(readAsString(mCompatVersion.getLogsFile()))
+                .isEqualTo(existingLogList.toString());
+    }
+
+    private static InputStream asStream(JSONObject logList) throws IOException {
+        return new ByteArrayInputStream(logList.toString().getBytes());
+    }
+
+    private static JSONObject makeLogList(String version) throws JSONException {
+        return new JSONObject().put("version", version);
+    }
+
+    private static JSONObject makeLogList(String version, String content) throws JSONException {
+        return makeLogList(version).put("content", content);
+    }
+
+    private static String readAsString(File file) throws IOException {
+        try (InputStream in = new FileInputStream(file)) {
+            return new String(in.readAllBytes());
+        }
+    }
+}
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
index 4027038..3627157 100644
--- a/service/ServiceConnectivityResources/res/values/config_thread.xml
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -71,4 +71,12 @@
     -->
     <string-array name="config_thread_mdns_vendor_specific_txts">
     </string-array>
+
+    <!-- Whether to enable / start SRP server only when border routing is ready. SRP server and
+    border routing are mandatory features required by a Thread Border Router, and it takes 10 to
+    20 seconds to establish border routing. Starting SRP server earlier is useful for use cases
+    where the user needs to know what are the devices in the network before actually needs to reach
+    to the devices, or reaching to Thread end devices doesn't require border routing to work.
+    -->
+    <bool name="config_thread_srp_server_wait_for_border_routing_enabled">true</bool>
 </resources>
diff --git a/service/ServiceConnectivityResources/res/values/overlayable.xml b/service/ServiceConnectivityResources/res/values/overlayable.xml
index fbaae05..121cbc4 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -52,6 +52,7 @@
             <item type="string" name="config_thread_vendor_oui" />
             <item type="string" name="config_thread_model_name" />
             <item type="array" name="config_thread_mdns_vendor_specific_txts" />
+            <item type="bool" name="config_thread_srp_server_wait_for_border_routing_enabled" />
         </policy>
     </overlayable>
 </resources>
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index b4a3b8a..0eab6e7 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -350,7 +350,7 @@
     // TODO: remove "apex_available:platform".
     apex_available: [
         "//apex_available:platform",
-        "com.android.btservices",
+        "com.android.bt",
         "com.android.tethering",
         "com.android.wifi",
     ],
diff --git a/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
new file mode 100644
index 0000000..bc3b3a5
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
@@ -0,0 +1,324 @@
+/*
+ * 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.net.module.util;
+
+import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_ERROR;
+import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.ParcelFileDescriptor;
+import android.os.SystemClock;
+import android.util.CloseGuard;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.io.IOException;
+import java.util.PriorityQueue;
+
+/**
+ * Represents a realtime scheduler object used for scheduling tasks with precise delays.
+ * Compared to {@link Handler#postDelayed}, this class offers enhanced accuracy for delayed
+ * callbacks by accounting for periods when the device is in deep sleep.
+ *
+ *  <p> This class is designed for use exclusively from the handler thread.
+ *
+ * **Usage Examples:**
+ *
+ * ** Scheduling recurring tasks with the same RealtimeScheduler **
+ *
+ * ```java
+ * // Create a RealtimeScheduler
+ * final RealtimeScheduler scheduler = new RealtimeScheduler(handler);
+ *
+ * // Schedule a new task with a delay.
+ * scheduler.postDelayed(() -> taskToExecute(), delayTime);
+ *
+ * // Once the delay has elapsed, and the task is running, schedule another task.
+ * scheduler.postDelayed(() -> anotherTaskToExecute(), anotherDelayTime);
+ *
+ * // Remember to close the RealtimeScheduler after all tasks have finished running.
+ * scheduler.close();
+ * ```
+ */
+public class RealtimeScheduler {
+    private static final String TAG = RealtimeScheduler.class.getSimpleName();
+    // EVENT_ERROR may be generated even if not specified, as per its javadoc.
+    private static final int FD_EVENTS = EVENT_INPUT | EVENT_ERROR;
+    private final CloseGuard mGuard = new CloseGuard();
+    @NonNull
+    private final Handler mHandler;
+    @NonNull
+    private final MessageQueue mQueue;
+    @NonNull
+    private final ParcelFileDescriptor mParcelFileDescriptor;
+    private final int mFdInt;
+
+    private final PriorityQueue<Task> mTaskQueue;
+
+    /**
+     * An abstract class for defining tasks that can be executed using a {@link Handler}.
+     */
+    private abstract static class Task implements Comparable<Task> {
+        private final long mRunTimeMs;
+        private final long mCreatedTimeNs = SystemClock.elapsedRealtimeNanos();
+
+        /**
+         * create a task with a run time
+         */
+        Task(long runTimeMs) {
+            mRunTimeMs = runTimeMs;
+        }
+
+        /**
+         * Executes the task using the provided {@link Handler}.
+         *
+         * @param handler The {@link Handler} to use for executing the task.
+         */
+        abstract void post(Handler handler);
+
+        @Override
+        public int compareTo(@NonNull Task o) {
+            if (mRunTimeMs != o.mRunTimeMs) {
+                return Long.compare(mRunTimeMs, o.mRunTimeMs);
+            }
+            return Long.compare(mCreatedTimeNs, o.mCreatedTimeNs);
+        }
+
+        /**
+         * Returns the run time of the task.
+         */
+        public long getRunTimeMs() {
+            return mRunTimeMs;
+        }
+    }
+
+    /**
+     * A task that sends a {@link Message} using a {@link Handler}.
+     */
+    private static class MessageTask extends Task {
+        private final Message mMessage;
+
+        MessageTask(Message message, long runTimeMs) {
+            super(runTimeMs);
+            mMessage = message;
+        }
+
+        /**
+         * Sends the {@link Message} using the provided {@link Handler}.
+         *
+         * @param handler The {@link Handler} to use for sending the message.
+         */
+        @Override
+        public void post(Handler handler) {
+            handler.sendMessage(mMessage);
+        }
+    }
+
+    /**
+     * A task that posts a {@link Runnable} to a {@link Handler}.
+     */
+    private static class RunnableTask extends Task {
+        private final Runnable mRunnable;
+
+        RunnableTask(Runnable runnable, long runTimeMs) {
+            super(runTimeMs);
+            mRunnable = runnable;
+        }
+
+        /**
+         * Posts the {@link Runnable} to the provided {@link Handler}.
+         *
+         * @param handler The {@link Handler} to use for posting the runnable.
+         */
+        @Override
+        public void post(Handler handler) {
+            handler.post(mRunnable);
+        }
+    }
+
+    /**
+     * The RealtimeScheduler constructor
+     *
+     * Note: The constructor is currently safe to call on another thread because it only sets final
+     * members and registers the event to be called on the handler.
+     */
+    public RealtimeScheduler(@NonNull Handler handler) {
+        mFdInt = TimerFdUtils.createTimerFileDescriptor();
+        mParcelFileDescriptor = ParcelFileDescriptor.adoptFd(mFdInt);
+        mHandler = handler;
+        mQueue = handler.getLooper().getQueue();
+        mTaskQueue = new PriorityQueue<>();
+        registerFdEventListener();
+
+        mGuard.open("close");
+    }
+
+    private boolean enqueueTask(@NonNull Task task, long delayMs) {
+        ensureRunningOnCorrectThread();
+        if (delayMs <= 0L) {
+            task.post(mHandler);
+            return true;
+        }
+        if (mTaskQueue.isEmpty() || task.compareTo(mTaskQueue.peek()) < 0) {
+            if (!TimerFdUtils.setExpirationTime(mFdInt, delayMs)) {
+                return false;
+            }
+        }
+        mTaskQueue.add(task);
+        return true;
+    }
+
+    /**
+     * Set a runnable to be executed after a specified delay.
+     *
+     * If delayMs is less than or equal to 0, the runnable will be executed immediately.
+     *
+     * @param runnable the runnable to be executed
+     * @param delayMs the delay time in milliseconds
+     * @return true if the task is scheduled successfully, false otherwise.
+     */
+    public boolean postDelayed(@NonNull Runnable runnable, long delayMs) {
+        return enqueueTask(new RunnableTask(runnable, SystemClock.elapsedRealtime() + delayMs),
+                delayMs);
+    }
+
+    /**
+     * Remove a scheduled runnable.
+     *
+     * @param runnable the runnable to be removed
+     */
+    public void removeDelayedRunnable(@NonNull Runnable runnable) {
+        ensureRunningOnCorrectThread();
+        mTaskQueue.removeIf(task -> task instanceof RunnableTask
+                && ((RunnableTask) task).mRunnable == runnable);
+    }
+
+    /**
+     * Set a message to be sent after a specified delay.
+     *
+     * If delayMs is less than or equal to 0, the message will be sent immediately.
+     *
+     * @param msg the message to be sent
+     * @param delayMs the delay time in milliseconds
+     * @return true if the message is scheduled successfully, false otherwise.
+     */
+    public boolean sendDelayedMessage(Message msg, long delayMs) {
+
+        return enqueueTask(new MessageTask(msg, SystemClock.elapsedRealtime() + delayMs), delayMs);
+    }
+
+    /**
+     * Remove a scheduled message.
+     *
+     * @param what the message to be removed
+     */
+    public void removeDelayedMessage(int what) {
+        ensureRunningOnCorrectThread();
+        mTaskQueue.removeIf(task -> task instanceof MessageTask
+                && ((MessageTask) task).mMessage.what == what);
+    }
+
+    /**
+     * Close the RealtimeScheduler. This implementation closes the underlying
+     * OS resources allocated to represent this stream.
+     */
+    public void close() {
+        ensureRunningOnCorrectThread();
+        unregisterAndDestroyFd();
+    }
+
+    private void registerFdEventListener() {
+        mQueue.addOnFileDescriptorEventListener(
+                mParcelFileDescriptor.getFileDescriptor(),
+                FD_EVENTS,
+                (fd, events) -> {
+                    if (!isRunning()) {
+                        return 0;
+                    }
+                    if ((events & EVENT_INPUT) != 0) {
+                        handleExpiration();
+                    }
+                    return FD_EVENTS;
+                });
+    }
+
+    private boolean isRunning() {
+        return mParcelFileDescriptor.getFileDescriptor().valid();
+    }
+
+    private void handleExpiration() {
+        long currentTimeMs = SystemClock.elapsedRealtime();
+        while (!mTaskQueue.isEmpty()) {
+            final Task task = mTaskQueue.peek();
+            currentTimeMs = SystemClock.elapsedRealtime();
+            if (currentTimeMs < task.getRunTimeMs()) {
+                break;
+            }
+            task.post(mHandler);
+            mTaskQueue.poll();
+        }
+
+
+        if (!mTaskQueue.isEmpty()) {
+            // Using currentTimeMs ensures that the calculated expiration time
+            // is always positive.
+            if (!TimerFdUtils.setExpirationTime(mFdInt,
+                    mTaskQueue.peek().getRunTimeMs() - currentTimeMs)) {
+                // If setting the expiration time fails, clear the task queue.
+                Log.wtf(TAG, "Failed to set expiration time");
+                mTaskQueue.clear();
+            }
+        } else {
+            // We have to clean up the timer if no tasks are left. Otherwise, the timer will keep
+            // being triggered.
+            TimerFdUtils.setExpirationTime(mFdInt, 0);
+        }
+    }
+
+    private void unregisterAndDestroyFd() {
+        if (mGuard != null) {
+            mGuard.close();
+        }
+
+        mQueue.removeOnFileDescriptorEventListener(mParcelFileDescriptor.getFileDescriptor());
+        try {
+            mParcelFileDescriptor.close();
+        } catch (IOException exception) {
+            Log.e(TAG, "close ParcelFileDescriptor failed. ", exception);
+        }
+    }
+
+    private void ensureRunningOnCorrectThread() {
+        if (mHandler.getLooper() != Looper.myLooper()) {
+            throw new IllegalStateException(
+                    "Not running on Handler thread: " + Thread.currentThread().getName());
+        }
+    }
+
+    @SuppressWarnings("Finalize")
+    @Override
+    protected void finalize() throws Throwable {
+        if (mGuard != null) {
+            mGuard.warnIfOpen();
+        }
+        super.finalize();
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/TimerFileDescriptor.java b/staticlibs/device/com/android/net/module/util/TimerFileDescriptor.java
deleted file mode 100644
index dbbccc5..0000000
--- a/staticlibs/device/com/android/net/module/util/TimerFileDescriptor.java
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- * 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.net.module.util;
-
-import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_ERROR;
-import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT;
-
-import android.os.Handler;
-import android.os.Looper;
-import android.os.Message;
-import android.os.MessageQueue;
-import android.os.ParcelFileDescriptor;
-import android.util.CloseGuard;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.io.IOException;
-
-/**
- * Represents a Timer file descriptor object used for scheduling tasks with precise delays.
- * Compared to {@link Handler#postDelayed}, this class offers enhanced accuracy for delayed
- * callbacks by accounting for periods when the device is in deep sleep.
- *
- *  <p> This class is designed for use exclusively from the handler thread.
- *
- * **Usage Examples:**
- *
- * ** Scheduling recurring tasks with the same TimerFileDescriptor **
- *
- * ```java
- * // Create a TimerFileDescriptor
- * final TimerFileDescriptor timerFd = new TimerFileDescriptor(handler);
- *
- * // Schedule a new task with a delay.
- * timerFd.setDelayedTask(() -> taskToExecute(), delayTime);
- *
- * // Once the delay has elapsed, and the task is running, schedule another task.
- * timerFd.setDelayedTask(() -> anotherTaskToExecute(), anotherDelayTime);
- *
- * // Remember to close the TimerFileDescriptor after all tasks have finished running.
- * timerFd.close();
- * ```
- */
-public class TimerFileDescriptor {
-    private static final String TAG = TimerFileDescriptor.class.getSimpleName();
-    // EVENT_ERROR may be generated even if not specified, as per its javadoc.
-    private static final int FD_EVENTS = EVENT_INPUT | EVENT_ERROR;
-    private final CloseGuard mGuard = new CloseGuard();
-    @NonNull
-    private final Handler mHandler;
-    @NonNull
-    private final MessageQueue mQueue;
-    @NonNull
-    private final ParcelFileDescriptor mParcelFileDescriptor;
-    private final int mFdInt;
-    @Nullable
-    private ITask mTask;
-
-    /**
-     * An interface for defining tasks that can be executed using a {@link Handler}.
-     */
-    public interface ITask {
-        /**
-         * Executes the task using the provided {@link Handler}.
-         *
-         * @param handler The {@link Handler} to use for executing the task.
-         */
-        void post(Handler handler);
-    }
-
-    /**
-     * A task that sends a {@link Message} using a {@link Handler}.
-     */
-    public static class MessageTask implements ITask {
-        private final Message mMessage;
-
-        public MessageTask(Message message) {
-            mMessage = message;
-        }
-
-        /**
-         * Sends the {@link Message} using the provided {@link Handler}.
-         *
-         * @param handler The {@link Handler} to use for sending the message.
-         */
-        @Override
-        public void post(Handler handler) {
-            handler.sendMessage(mMessage);
-        }
-    }
-
-    /**
-     * A task that posts a {@link Runnable} to a {@link Handler}.
-     */
-    public static class RunnableTask implements ITask {
-        private final Runnable mRunnable;
-
-        public RunnableTask(Runnable runnable) {
-            mRunnable = runnable;
-        }
-
-        /**
-         * Posts the {@link Runnable} to the provided {@link Handler}.
-         *
-         * @param handler The {@link Handler} to use for posting the runnable.
-         */
-        @Override
-        public void post(Handler handler) {
-            handler.post(mRunnable);
-        }
-    }
-
-    /**
-     * TimerFileDescriptor constructor
-     *
-     * Note: The constructor is currently safe to call on another thread because it only sets final
-     * members and registers the event to be called on the handler.
-     */
-    public TimerFileDescriptor(@NonNull Handler handler) {
-        mFdInt = TimerFdUtils.createTimerFileDescriptor();
-        mParcelFileDescriptor = ParcelFileDescriptor.adoptFd(mFdInt);
-        mHandler = handler;
-        mQueue = handler.getLooper().getQueue();
-        registerFdEventListener();
-
-        mGuard.open("close");
-    }
-
-    /**
-     * Set a task to be executed after a specified delay.
-     *
-     * <p> A task can only be scheduled once at a time. Cancel previous scheduled task before the
-     *     new task is scheduled.
-     *
-     * @param task the task to be executed
-     * @param delayMs the delay time in milliseconds
-     * @throws IllegalArgumentException if try to replace the current scheduled task
-     * @throws IllegalArgumentException if the delay time is less than 0
-     */
-    public void setDelayedTask(@NonNull ITask task, long delayMs) {
-        ensureRunningOnCorrectThread();
-        if (mTask != null) {
-            throw new IllegalArgumentException("task is already scheduled");
-        }
-        if (delayMs <= 0L) {
-            task.post(mHandler);
-            return;
-        }
-
-        if (TimerFdUtils.setExpirationTime(mFdInt, delayMs)) {
-            mTask = task;
-        }
-    }
-
-    /**
-     * Cancel the scheduled task.
-     */
-    public void cancelTask() {
-        ensureRunningOnCorrectThread();
-        if (mTask == null) return;
-
-        TimerFdUtils.setExpirationTime(mFdInt, 0 /* delayMs */);
-        mTask = null;
-    }
-
-    /**
-     * Check if there is a scheduled task.
-     */
-    public boolean hasDelayedTask() {
-        ensureRunningOnCorrectThread();
-        return mTask != null;
-    }
-
-    /**
-     * Close the TimerFileDescriptor. This implementation closes the underlying
-     * OS resources allocated to represent this stream.
-     */
-    public void close() {
-        ensureRunningOnCorrectThread();
-        unregisterAndDestroyFd();
-    }
-
-    private void registerFdEventListener() {
-        mQueue.addOnFileDescriptorEventListener(
-                mParcelFileDescriptor.getFileDescriptor(),
-                FD_EVENTS,
-                (fd, events) -> {
-                    if (!isRunning()) {
-                        return 0;
-                    }
-                    if ((events & EVENT_INPUT) != 0) {
-                        handleExpiration();
-                    }
-                    return FD_EVENTS;
-                });
-    }
-
-    private boolean isRunning() {
-        return mParcelFileDescriptor.getFileDescriptor().valid();
-    }
-
-    private void handleExpiration() {
-        // Execute the task
-        if (mTask != null) {
-            mTask.post(mHandler);
-            mTask = null;
-        }
-    }
-
-    private void unregisterAndDestroyFd() {
-        if (mGuard != null) {
-            mGuard.close();
-        }
-
-        mQueue.removeOnFileDescriptorEventListener(mParcelFileDescriptor.getFileDescriptor());
-        try {
-            mParcelFileDescriptor.close();
-        } catch (IOException exception) {
-            Log.e(TAG, "close ParcelFileDescriptor failed. ", exception);
-        }
-    }
-
-    private void ensureRunningOnCorrectThread() {
-        if (mHandler.getLooper() != Looper.myLooper()) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
-    @SuppressWarnings("Finalize")
-    @Override
-    protected void finalize() throws Throwable {
-        if (mGuard != null) {
-            mGuard.warnIfOpen();
-        }
-        super.finalize();
-    }
-}
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 9d1d291..f4f1ea9 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -28,6 +28,7 @@
         "net-utils-device-common-struct-base",
         "net-utils-device-common-wear",
         "net-utils-service-connectivity",
+        "truth",
     ],
     libs: [
         "android.test.runner.stubs",
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/RealtimeSchedulerTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/RealtimeSchedulerTest.kt
new file mode 100644
index 0000000..30b530f
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/RealtimeSchedulerTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.net.module.util
+
+import android.os.Build
+import android.os.ConditionVariable
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Looper
+import android.os.Message
+import android.os.SystemClock
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.tryTest
+import com.android.testutils.visibleOnHandlerThread
+import com.google.common.collect.Range
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertEquals
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class RealtimeSchedulerTest {
+
+    private val TIMEOUT_MS = 1000L
+    private val TOLERANCE_MS = 50L
+    private class TestHandler(looper: Looper) : Handler(looper) {
+        override fun handleMessage(msg: Message) {
+            val pair = msg.obj as Pair<ConditionVariable, MutableList<Long>>
+            val cv = pair.first
+            cv.open()
+            val executionTimes = pair.second
+            executionTimes.add(SystemClock.elapsedRealtime())
+        }
+    }
+    private val thread = HandlerThread(RealtimeSchedulerTest::class.simpleName).apply { start() }
+    private val handler by lazy { TestHandler(thread.looper) }
+
+    @After
+    fun tearDown() {
+        thread.quitSafely()
+        thread.join()
+    }
+
+    @Test
+    fun testMultiplePostDelayedTasks() {
+        val scheduler = RealtimeScheduler(handler)
+        tryTest {
+            val initialTimeMs = SystemClock.elapsedRealtime()
+            val executionTimes = mutableListOf<Long>()
+            val cv = ConditionVariable()
+            handler.post {
+                scheduler.postDelayed(
+                    { executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs) }, 0)
+                scheduler.postDelayed(
+                    { executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs) }, 200)
+                val toBeRemoved = Runnable {
+                    executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs)
+                }
+                scheduler.postDelayed(toBeRemoved, 250)
+                scheduler.removeDelayedRunnable(toBeRemoved)
+                scheduler.postDelayed(
+                    { executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs) }, 100)
+                scheduler.postDelayed({
+                    executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs)
+                    cv.open() }, 300)
+            }
+            cv.block(TIMEOUT_MS)
+            assertEquals(4, executionTimes.size)
+            assertThat(executionTimes[0]).isIn(Range.closed(0L, TOLERANCE_MS))
+            assertThat(executionTimes[1]).isIn(Range.closed(100L, 100 + TOLERANCE_MS))
+            assertThat(executionTimes[2]).isIn(Range.closed(200L, 200 + TOLERANCE_MS))
+            assertThat(executionTimes[3]).isIn(Range.closed(300L, 300 + TOLERANCE_MS))
+        } cleanup {
+            visibleOnHandlerThread(handler) { scheduler.close() }
+        }
+    }
+
+    @Test
+    fun testMultipleSendDelayedMessages() {
+        val scheduler = RealtimeScheduler(handler)
+        tryTest {
+            val MSG_ID_0 = 0
+            val MSG_ID_1 = 1
+            val MSG_ID_2 = 2
+            val MSG_ID_3 = 3
+            val MSG_ID_4 = 4
+            val initialTimeMs = SystemClock.elapsedRealtime()
+            val executionTimes = mutableListOf<Long>()
+            val cv = ConditionVariable()
+            handler.post {
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_0, Pair(ConditionVariable(), executionTimes)), 0)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_1, Pair(ConditionVariable(), executionTimes)),
+                    200)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_4, Pair(ConditionVariable(), executionTimes)),
+                    250)
+                scheduler.removeDelayedMessage(MSG_ID_4)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_2, Pair(ConditionVariable(), executionTimes)),
+                    100)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_3, Pair(cv, executionTimes)),
+                    300)
+            }
+            cv.block(TIMEOUT_MS)
+            assertEquals(4, executionTimes.size)
+            assertThat(executionTimes[0] - initialTimeMs).isIn(Range.closed(0L, TOLERANCE_MS))
+            assertThat(executionTimes[1] - initialTimeMs)
+                .isIn(Range.closed(100L, 100 + TOLERANCE_MS))
+            assertThat(executionTimes[2] - initialTimeMs)
+                .isIn(Range.closed(200L, 200 + TOLERANCE_MS))
+            assertThat(executionTimes[3] - initialTimeMs)
+                .isIn(Range.closed(300L, 300 + TOLERANCE_MS))
+        } cleanup {
+            visibleOnHandlerThread(handler) { scheduler.close() }
+        }
+    }
+}
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/TimerFileDescriptorTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/TimerFileDescriptorTest.kt
deleted file mode 100644
index f5e47c9..0000000
--- a/staticlibs/tests/unit/src/com/android/net/module/util/TimerFileDescriptorTest.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * 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.net.module.util
-
-import android.os.Build
-import android.os.ConditionVariable
-import android.os.Handler
-import android.os.HandlerThread
-import android.os.Looper
-import android.os.Message
-import androidx.test.filters.SmallTest
-import com.android.net.module.util.TimerFileDescriptor.ITask
-import com.android.net.module.util.TimerFileDescriptor.MessageTask
-import com.android.net.module.util.TimerFileDescriptor.RunnableTask
-import com.android.testutils.DevSdkIgnoreRule
-import com.android.testutils.DevSdkIgnoreRunner
-import com.android.testutils.tryTest
-import com.android.testutils.visibleOnHandlerThread
-import org.junit.After
-import org.junit.Test
-import org.junit.runner.RunWith
-import java.time.Duration
-import java.time.Instant
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-private const val MSG_TEST = 1
-
-@DevSdkIgnoreRunner.MonitorThreadLeak
-@RunWith(DevSdkIgnoreRunner::class)
-@SmallTest
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
-class TimerFileDescriptorTest {
-    private class TestHandler(looper: Looper) : Handler(looper) {
-        override fun handleMessage(msg: Message) {
-            val cv = msg.obj as ConditionVariable
-            cv.open()
-        }
-    }
-    private val thread = HandlerThread(TimerFileDescriptorTest::class.simpleName).apply { start() }
-    private val handler by lazy { TestHandler(thread.looper) }
-
-    @After
-    fun tearDown() {
-        thread.quitSafely()
-        thread.join()
-    }
-
-    private fun assertDelayedTaskPost(
-            timerFd: TimerFileDescriptor,
-            task: ITask,
-            cv: ConditionVariable
-    ) {
-        val delayTime = 10L
-        val startTime1 = Instant.now()
-        handler.post { timerFd.setDelayedTask(task, delayTime) }
-        assertTrue(cv.block(100L /* timeoutMs*/))
-        assertTrue(Duration.between(startTime1, Instant.now()).toMillis() >= delayTime)
-    }
-
-    @Test
-    fun testSetDelayedTask() {
-        val timerFd = TimerFileDescriptor(handler)
-        tryTest {
-            // Verify the delayed task is executed with the self-implemented ITask
-            val cv1 = ConditionVariable()
-            assertDelayedTaskPost(timerFd, { cv1.open() }, cv1)
-
-            // Verify the delayed task is executed with the RunnableTask
-            val cv2 = ConditionVariable()
-            assertDelayedTaskPost(timerFd, RunnableTask{ cv2.open() }, cv2)
-
-            // Verify the delayed task is executed with the MessageTask
-            val cv3 = ConditionVariable()
-            assertDelayedTaskPost(timerFd, MessageTask(handler.obtainMessage(MSG_TEST, cv3)), cv3)
-        } cleanup {
-            visibleOnHandlerThread(handler) { timerFd.close() }
-        }
-    }
-
-    @Test
-    fun testCancelTask() {
-        // The task is posted and canceled within the same handler loop, so the short delay used
-        // here won't cause flakes.
-        val delayTime = 10L
-        val timerFd = TimerFileDescriptor(handler)
-        val cv = ConditionVariable()
-        tryTest {
-            handler.post {
-                timerFd.setDelayedTask({ cv.open() }, delayTime)
-                assertTrue(timerFd.hasDelayedTask())
-                timerFd.cancelTask()
-                assertFalse(timerFd.hasDelayedTask())
-            }
-            assertFalse(cv.block(20L /* timeoutMs*/))
-        } cleanup {
-            visibleOnHandlerThread(handler) { timerFd.close() }
-        }
-    }
-}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
index 8dc1bc4..bfbbc34 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
@@ -14,19 +14,34 @@
  * limitations under the License.
  */
 
-package com.android.testutils;
+package com.android.testutils
 
 import android.content.Context
+import android.net.InetAddresses.parseNumericAddress
 import android.net.KeepalivePacketData
+import android.net.LinkAddress
 import android.net.LinkProperties
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
+import android.net.NetworkCapabilities.TRANSPORT_TEST
 import android.net.NetworkProvider
+import android.net.NetworkRequest
 import android.net.QosFilter
 import android.net.Uri
 import android.os.Looper
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import android.system.OsConstants.EADDRNOTAVAIL
+import android.system.OsConstants.ENETUNREACH
+import android.system.OsConstants.ENONET
+import android.system.OsConstants.IPPROTO_UDP
+import android.system.OsConstants.SOCK_DGRAM
+import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import com.android.net.module.util.ArrayTrackRecord
+import com.android.testutils.CompatUtil.makeTestNetworkSpecifier
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAddKeepalivePacketFilter
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAutomaticReconnectDisabled
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnBandwidthUpdateRequested
@@ -42,6 +57,8 @@
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnStopSocketKeepalive
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnUnregisterQosCallback
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnValidationStatus
+import java.net.NetworkInterface
+import java.net.SocketException
 import java.time.Duration
 import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
@@ -65,6 +82,92 @@
     conf: NetworkAgentConfig
 ) : NetworkAgent(context, looper, TestableNetworkAgent::class.java.simpleName /* tag */,
         nc, lp, TEST_NETWORK_SCORE, conf, Provider(context, looper)) {
+    companion object {
+
+        /**
+         * Convenience method to create a [NetworkRequest] matching [TestableNetworkAgent]s from
+         * [createOnInterface].
+         */
+        fun makeNetworkRequestForInterface(ifaceName: String) = NetworkRequest.Builder()
+            .removeCapability(NET_CAPABILITY_TRUSTED)
+            .addTransportType(TRANSPORT_TEST)
+            .setNetworkSpecifier(makeTestNetworkSpecifier(ifaceName))
+            .build()
+
+        /**
+         * Convenience method to initialize a [TestableNetworkAgent] on a given interface.
+         *
+         * This waits for link-local addresses to be setup and ensures LinkProperties are updated
+         * with the addresses.
+         */
+        fun createOnInterface(
+            context: Context,
+            looper: Looper,
+            ifaceName: String,
+            timeoutMs: Long
+        ): TestableNetworkAgent {
+            val lp = LinkProperties().apply {
+                interfaceName = ifaceName
+            }
+            val agent = TestableNetworkAgent(
+                context,
+                looper,
+                NetworkCapabilities().apply {
+                    removeCapability(NET_CAPABILITY_TRUSTED)
+                    addTransportType(TRANSPORT_TEST)
+                    setNetworkSpecifier(makeTestNetworkSpecifier(ifaceName))
+                },
+                lp,
+                NetworkAgentConfig.Builder().build()
+            )
+            val network = agent.register()
+            agent.markConnected()
+            if (isAtLeastS()) {
+                // OnNetworkCreated was added in S
+                agent.eventuallyExpect<OnNetworkCreated>()
+            }
+
+            // Wait until the link-local address can be used. Address flags are not available
+            // without elevated permissions, so check that bindSocket works.
+            assertEventuallyTrue("No usable v6 address after $timeoutMs ms", timeoutMs) {
+                // To avoid race condition between socket connection succeeding and interface
+                // returning a non-empty address list. Verify that interface returns a non-empty
+                // list, before trying the socket connection.
+                if (NetworkInterface.getByName(ifaceName).interfaceAddresses.isEmpty()) {
+                    return@assertEventuallyTrue false
+                }
+
+                val sock = Os.socket(OsConstants.AF_INET6, SOCK_DGRAM, IPPROTO_UDP)
+                tryTest {
+                    network.bindSocket(sock)
+                    Os.connect(sock, parseNumericAddress("ff02::fb%$ifaceName"), 12345)
+                    true
+                }.catch<ErrnoException> {
+                    if (it.errno != ENETUNREACH && it.errno != EADDRNOTAVAIL) {
+                        throw it
+                    }
+                    false
+                }.catch<SocketException> {
+                    // OnNetworkCreated does not exist on R, so a SocketException caused by ENONET
+                    // may be seen before the network is created
+                    if (isAtLeastS()) throw it
+                    val cause = it.cause as? ErrnoException ?: throw it
+                    if (cause.errno != ENONET) {
+                        throw it
+                    }
+                    false
+                } cleanup {
+                    Os.close(sock)
+                }
+            }
+
+            agent.lp.setLinkAddresses(NetworkInterface.getByName(ifaceName).interfaceAddresses.map {
+                LinkAddress(it.address, it.networkPrefixLength.toInt())
+            })
+            agent.sendLinkProperties(agent.lp)
+            return agent
+        }
+    }
 
     val DEFAULT_TIMEOUT_MS = 5000L
 
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
index ad98a29..ac60b0f 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
@@ -25,6 +25,7 @@
 import android.content.Context;
 import android.net.TetheringInterface;
 import android.net.cts.util.CtsTetheringUtils;
+import android.net.cts.util.CtsTetheringUtils.TestTetheringEventCallback;
 import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiSsid;
 
@@ -37,6 +38,7 @@
 public class TetheringTest {
     private CtsTetheringUtils mCtsTetheringUtils;
     private TetheringHelperClient mTetheringHelperClient;
+    private TestTetheringEventCallback mTetheringEventCallback;
 
     @Before
     public void setUp() throws Exception {
@@ -44,11 +46,14 @@
         mCtsTetheringUtils = new CtsTetheringUtils(targetContext);
         mTetheringHelperClient = new TetheringHelperClient(targetContext);
         mTetheringHelperClient.bind();
+        mTetheringEventCallback = mCtsTetheringUtils.registerTetheringEventCallback();
     }
 
     @After
     public void tearDown() throws Exception {
         mTetheringHelperClient.unbind();
+        mCtsTetheringUtils.unregisterTetheringEventCallback(mTetheringEventCallback);
+        mCtsTetheringUtils.stopAllTethering();
     }
 
     /**
@@ -57,24 +62,20 @@
      */
     @Test
     public void testSoftApConfigurationRedactedForOtherUids() throws Exception {
-        final CtsTetheringUtils.TestTetheringEventCallback tetherEventCallback =
-                mCtsTetheringUtils.registerTetheringEventCallback();
+        mTetheringEventCallback.assumeWifiTetheringSupported(
+                getInstrumentation().getTargetContext());
         SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
                 .setWifiSsid(WifiSsid.fromBytes("This is an SSID!"
                         .getBytes(StandardCharsets.UTF_8))).build();
         final TetheringInterface tetheringInterface =
-                mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
+                mCtsTetheringUtils.startWifiTethering(mTetheringEventCallback, softApConfig);
         assertNotNull(tetheringInterface);
         assertEquals(softApConfig, tetheringInterface.getSoftApConfiguration());
-        try {
-            TetheringInterface tetheringInterfaceForApp2 =
-                    mTetheringHelperClient.getTetheredWifiInterface();
-            assertNotNull(tetheringInterfaceForApp2);
-            assertNull(tetheringInterfaceForApp2.getSoftApConfiguration());
-            assertEquals(
-                    tetheringInterface.getInterface(), tetheringInterfaceForApp2.getInterface());
-        } finally {
-            mCtsTetheringUtils.stopWifiTethering(tetherEventCallback);
-        }
+        TetheringInterface tetheringInterfaceForApp2 =
+                mTetheringHelperClient.getTetheredWifiInterface();
+        assertNotNull(tetheringInterfaceForApp2);
+        assertNull(tetheringInterfaceForApp2.getSoftApConfiguration());
+        assertEquals(
+                tetheringInterface.getInterface(), tetheringInterfaceForApp2.getInterface());
     }
 }
diff --git a/tests/cts/multidevices/apfv4_test.py b/tests/cts/multidevices/apfv4_test.py
index 7795be5..aa535fd 100644
--- a/tests/cts/multidevices/apfv4_test.py
+++ b/tests/cts/multidevices/apfv4_test.py
@@ -53,7 +53,7 @@
   )  # Declare inputs for state_str and expected_result.
   def test_apf_drop_ethertype_not_allowed(self, blocked_ether_type):
     # Ethernet header (14 bytes).
-    packet = ETHER_BROADCAST_ADDR  # Destination MAC (broadcast)
+    packet = self.client_mac_address.replace(":", "")  # Destination MAC
     packet += self.server_mac_address.replace(":", "")  # Source MAC
     packet += blocked_ether_type
 
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
index 76e2707..6c29aff 100644
--- a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -21,7 +21,6 @@
 
 import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
-import android.content.pm.PackageManager
 import android.content.pm.PackageManager.FEATURE_AUTOMOTIVE
 import android.content.pm.PackageManager.FEATURE_LEANBACK
 import android.content.pm.PackageManager.FEATURE_WIFI
@@ -172,8 +171,8 @@
         private fun isAutomotiveWithVisibleBackgroundUser(): Boolean {
             val packageManager = context.getPackageManager()
             val userManager = context.getSystemService(UserManager::class.java)!!
-            return (packageManager.hasSystemFeature(FEATURE_AUTOMOTIVE)
-                    && userManager.isVisibleBackgroundUsersSupported)
+            return (packageManager.hasSystemFeature(FEATURE_AUTOMOTIVE) &&
+                    userManager.isVisibleBackgroundUsersSupported)
         }
 
         @BeforeClass
diff --git a/tests/cts/net/src/android/net/cts/DnsResolverTapTest.kt b/tests/cts/net/src/android/net/cts/DnsResolverTapTest.kt
new file mode 100644
index 0000000..b1b6e0d
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/DnsResolverTapTest.kt
@@ -0,0 +1,183 @@
+/*
+ * 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 android.net.cts
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.Manifest.permission.READ_DEVICE_CONFIG
+import android.net.DnsResolver
+import android.net.InetAddresses.parseNumericAddress
+import android.net.IpPrefix
+import android.net.MacAddress
+import android.net.RouteInfo
+import android.os.CancellationSignal
+import android.os.HandlerThread
+import android.os.SystemClock
+import android.provider.DeviceConfig
+import android.provider.DeviceConfig.NAMESPACE_NETD_NATIVE
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.net.module.util.NetworkStackConstants.ETHER_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN
+import com.android.testutils.AutoReleaseNetworkCallbackRule
+import com.android.testutils.DeviceConfigRule
+import com.android.testutils.DnsResolverModuleTest
+import com.android.testutils.IPv6UdpFilter
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.RouterAdvertisementResponder
+import com.android.testutils.TapPacketReaderRule
+import com.android.testutils.TestableNetworkAgent
+import com.android.testutils.TestDnsPacket
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule
+import com.android.testutils.runAsShell
+import java.net.Inet6Address
+import java.net.InetAddress
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private val TEST_DNSSERVER_MAC = MacAddress.fromString("00:11:22:33:44:55")
+private val TAG = DnsResolverTapTest::class.java.simpleName
+private const val TEST_TIMEOUT_MS = 10_000L
+
+@DnsResolverModuleTest
+@RunWith(AndroidJUnit4::class)
+class DnsResolverTapTest {
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val handlerThread = HandlerThread(TAG)
+
+    @get:Rule(order = 1)
+    val deviceConfigRule = DeviceConfigRule()
+
+    @get:Rule(order = 2)
+    val featureFlagsRule = SetFeatureFlagsRule(
+        setFlagsMethod = { name, enabled ->
+            val value = when (enabled) {
+                null -> null
+                true -> "1"
+                false -> "0"
+            }
+            deviceConfigRule.setConfig(NAMESPACE_NETD_NATIVE, name, value)
+        },
+        getFlagsMethod = {
+            runAsShell(READ_DEVICE_CONFIG) {
+                DeviceConfig.getInt(NAMESPACE_NETD_NATIVE, it, 0) == 1
+            }
+        }
+    )
+
+    @get:Rule(order = 3)
+    val packetReaderRule = TapPacketReaderRule()
+
+    @get:Rule(order = 4)
+    val cbRule = AutoReleaseNetworkCallbackRule()
+
+    private val ndResponder by lazy { RouterAdvertisementResponder(packetReaderRule.reader) }
+    private val dnsServerAddr by lazy {
+        parseNumericAddress("fe80::124%${packetReaderRule.iface.interfaceName}") as Inet6Address
+    }
+    private lateinit var agent: TestableNetworkAgent
+
+    @Before
+    fun setUp() {
+        handlerThread.start()
+        val interfaceName = packetReaderRule.iface.interfaceName
+        val cb = cbRule.requestNetwork(TestableNetworkAgent.makeNetworkRequestForInterface(
+            interfaceName))
+        agent = runAsShell(MANAGE_TEST_NETWORKS) {
+            TestableNetworkAgent.createOnInterface(context, handlerThread.looper,
+                interfaceName, TEST_TIMEOUT_MS)
+        }
+        ndResponder.addNeighborEntry(TEST_DNSSERVER_MAC, dnsServerAddr)
+        ndResponder.start()
+        agent.lp.apply {
+            addDnsServer(dnsServerAddr)
+            // A default route is needed for DnsResolver.java to send queries over IPv6
+            // (see usage of DnsUtils.haveIpv6).
+            addRoute(RouteInfo(IpPrefix("::/0"), null, null))
+        }
+        agent.sendLinkProperties(agent.lp)
+        cb.eventuallyExpect<LinkPropertiesChanged> { it.lp.dnsServers.isNotEmpty() }
+    }
+
+    @After
+    fun tearDown() {
+        ndResponder.stop()
+        if (::agent.isInitialized) {
+            agent.unregister()
+        }
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    private class DnsCallback : DnsResolver.Callback<List<InetAddress>> {
+        override fun onAnswer(answer: List<InetAddress>, rcode: Int) = Unit
+        override fun onError(error: DnsResolver.DnsException) = Unit
+    }
+
+    /**
+     * Run a cancellation test.
+     *
+     * @param domain Domain name to query
+     * @param waitTimeForNoRetryAfterCancellationMs If positive, cancel the query and wait for that
+     *                                              delay to check no retry is sent.
+     * @return The duration it took to receive all expected replies.
+     */
+    fun doCancellationTest(domain: String, waitTimeForNoRetryAfterCancellationMs: Long): Long {
+        val cancellationSignal = CancellationSignal()
+        val dnsCb = DnsCallback()
+        val queryStart = SystemClock.elapsedRealtime()
+        DnsResolver.getInstance().query(
+            agent.network, domain, 0 /* flags */,
+            Runnable::run /* executor */, cancellationSignal, dnsCb
+        )
+
+        if (waitTimeForNoRetryAfterCancellationMs > 0) {
+            cancellationSignal.cancel()
+        }
+        // Filter for queries on UDP port 53 for the specified domain
+        val filter = IPv6UdpFilter(dstPort = 53).and {
+            TestDnsPacket(
+                it.copyOfRange(ETHER_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN, it.size),
+                dstAddr = dnsServerAddr
+            ).isQueryFor(domain, DnsResolver.TYPE_AAAA)
+        }
+
+        val reader = packetReaderRule.reader
+        assertNotNull(reader.poll(TEST_TIMEOUT_MS, filter), "Original query not found")
+        if (waitTimeForNoRetryAfterCancellationMs > 0) {
+            assertNull(reader.poll(waitTimeForNoRetryAfterCancellationMs, filter),
+                "Expected no retry query")
+        } else {
+            assertNotNull(reader.poll(TEST_TIMEOUT_MS, filter), "Retry query not found")
+        }
+        return SystemClock.elapsedRealtime() - queryStart
+    }
+
+    @SetFeatureFlagsRule.FeatureFlag("no_retry_after_cancel", true)
+    @Test
+    fun testCancellation() {
+        val timeWithRetryWhenNotCancelled = doCancellationTest("test1.example.com",
+            waitTimeForNoRetryAfterCancellationMs = 0L)
+        doCancellationTest("test2.example.com",
+            waitTimeForNoRetryAfterCancellationMs = timeWithRetryWhenNotCancelled + 50L)
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/net/src/android/net/cts/L2capNetworkSpecifierTest.kt b/tests/cts/net/src/android/net/cts/L2capNetworkSpecifierTest.kt
new file mode 100644
index 0000000..b593baf
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/L2capNetworkSpecifierTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.cts
+
+import android.net.L2capNetworkSpecifier
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_NONE
+import android.net.L2capNetworkSpecifier.ROLE_CLIENT
+import android.net.L2capNetworkSpecifier.ROLE_SERVER
+import android.net.MacAddress
+import android.os.Build
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.assertParcelingIsLossless
+import kotlin.test.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class L2capNetworkSpecifierTest {
+    @Test
+    fun testParcelUnparcel() {
+        val remoteMac = MacAddress.fromString("01:02:03:04:05:06")
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .setPsm(42)
+                .setRemoteAddress(remoteMac)
+                .build()
+        assertParcelingIsLossless(specifier)
+    }
+
+    @Test
+    fun testGetters() {
+        val remoteMac = MacAddress.fromString("11:22:33:44:55:66")
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setPsm(123)
+                .setRemoteAddress(remoteMac)
+                .build()
+        assertEquals(ROLE_SERVER, specifier.getRole())
+        assertEquals(HEADER_COMPRESSION_NONE, specifier.getHeaderCompression())
+        assertEquals(123, specifier.getPsm())
+        assertEquals(remoteMac, specifier.getRemoteAddress())
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
index 1ca5a77..2fb140a 100644
--- a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
@@ -33,6 +33,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
 import static com.google.common.truth.Truth.assertThat;
 
 import static junit.framework.Assert.fail;
@@ -103,6 +104,16 @@
         }
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    @Test
+    public void testParceling() {
+        NetworkCapabilities nc = new NetworkCapabilities.Builder().build();
+        NetworkRequest request = new NetworkRequest(nc, TYPE_NONE, 42 /* rId */,
+                NetworkRequest.Type.RESERVATION);
+
+        assertParcelingIsLossless(request);
+    }
+
     @Test
     public void testCapabilities() {
         assertTrue(new NetworkRequest.Builder().addCapability(NET_CAPABILITY_MMS).build()
diff --git a/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
new file mode 100644
index 0000000..0b10ef6
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.cts
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.Manifest.permission.NETWORK_SETTINGS
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
+import android.net.NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_TEST
+import android.net.NetworkProvider
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Reserved
+import com.android.testutils.RecorderCallback.CallbackEntry.Unavailable
+import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.TestableNetworkOfferCallback
+import com.android.testutils.runAsShell
+import kotlin.test.assertEquals
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TAG = "NetworkReservationTest"
+
+private val NETWORK_SCORE = NetworkScore.Builder().build()
+private val ETHERNET_CAPS = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_ETHERNET)
+        .addTransportType(TRANSPORT_TEST)
+        .addCapability(NET_CAPABILITY_INTERNET)
+        .addCapability(NET_CAPABILITY_NOT_CONGESTED)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .build()
+private val BLANKET_CAPS = NetworkCapabilities(ETHERNET_CAPS).apply {
+    reservationId = RES_ID_MATCH_ALL_RESERVATIONS
+}
+private val ETHERNET_REQUEST = NetworkRequest.Builder()
+        .addTransportType(TRANSPORT_ETHERNET)
+        .addTransportType(TRANSPORT_TEST)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .build()
+private const val TIMEOUT_MS = 5_000L
+private const val NO_CB_TIMEOUT_MS = 200L
+
+// TODO: integrate with CSNetworkReservationTest and move to common tests.
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class NetworkReservationTest {
+    private val context = InstrumentationRegistry.getInstrumentation().context
+    private val cm = context.getSystemService(ConnectivityManager::class.java)!!
+    private val handlerThread = HandlerThread("$TAG handler thread").apply { start() }
+    private val handler = Handler(handlerThread.looper)
+    private val provider = NetworkProvider(context, handlerThread.looper, TAG)
+
+    @Before
+    fun setUp() {
+        runAsShell(NETWORK_SETTINGS) {
+            cm.registerNetworkProvider(provider)
+        }
+    }
+
+    @After
+    fun tearDown() {
+        runAsShell(NETWORK_SETTINGS) {
+            // unregisterNetworkProvider unregisters all associated NetworkOffers.
+            cm.unregisterNetworkProvider(provider)
+        }
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    fun NetworkCapabilities.copyWithReservationId(resId: Int) = NetworkCapabilities(this).also {
+        it.reservationId = resId
+    }
+
+    @Test
+    fun testReserveNetwork() {
+        // register blanket offer
+        val blanketOffer = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        runAsShell(MANAGE_TEST_NETWORKS) {
+            provider.registerNetworkOffer(NETWORK_SCORE, BLANKET_CAPS, handler::post, blanketOffer)
+        }
+
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, handler, cb)
+
+        // validate the reservation matches the blanket offer.
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        // bring up reserved reservation offer
+        val reservedCaps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        val reservedOffer = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        runAsShell(MANAGE_TEST_NETWORKS) {
+            provider.registerNetworkOffer(NETWORK_SCORE, reservedCaps, handler::post, reservedOffer)
+        }
+
+        // validate onReserved was sent to the app
+        val appObservedCaps = cb.expect<Reserved>().caps
+        assertEquals(reservedCaps, appObservedCaps)
+
+        // validate the reservation matches the reserved offer.
+        reservedOffer.expectOnNetworkNeeded(reservedCaps)
+
+        // reserved offer goes away
+        provider.unregisterNetworkOffer(reservedOffer)
+        cb.expect<Unavailable>()
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index c981a1b..ee31f1a 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -22,14 +22,10 @@
 import android.net.ConnectivityManager.NetworkCallback
 import android.net.DnsResolver
 import android.net.InetAddresses.parseNumericAddress
-import android.net.LinkAddress
-import android.net.LinkProperties
 import android.net.LocalSocket
 import android.net.LocalSocketAddress
 import android.net.MacAddress
 import android.net.Network
-import android.net.NetworkAgentConfig
-import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED
 import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
 import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
@@ -53,16 +49,10 @@
 import android.os.HandlerThread
 import android.platform.test.annotations.AppModeFull
 import android.provider.DeviceConfig.NAMESPACE_TETHERING
-import android.system.ErrnoException
-import android.system.Os
-import android.system.OsConstants.AF_INET6
-import android.system.OsConstants.EADDRNOTAVAIL
-import android.system.OsConstants.ENETUNREACH
 import android.system.OsConstants.ETH_P_IPV6
 import android.system.OsConstants.IPPROTO_IPV6
 import android.system.OsConstants.IPPROTO_UDP
 import android.system.OsConstants.RT_SCOPE_LINK
-import android.system.OsConstants.SOCK_DGRAM
 import android.util.Log
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
@@ -106,7 +96,6 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.TestDnsPacket
 import com.android.testutils.TestableNetworkAgent
-import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.assertEmpty
 import com.android.testutils.filters.CtsNetTestCasesMaxTargetSdk30
@@ -247,16 +236,12 @@
         val tnm = context.getSystemService(TestNetworkManager::class.java)!!
         val iface = tnm.createTapInterface()
         val cb = TestableNetworkCallback()
-        val testNetworkSpecifier = TestNetworkSpecifier(iface.interfaceName)
         cm.requestNetwork(
-            NetworkRequest.Builder()
-                .removeCapability(NET_CAPABILITY_TRUSTED)
-                .addTransportType(TRANSPORT_TEST)
-                .setNetworkSpecifier(testNetworkSpecifier)
-                .build(),
+            TestableNetworkAgent.makeNetworkRequestForInterface(iface.interfaceName),
             cb
         )
-        val agent = registerTestNetworkAgent(iface.interfaceName)
+        val agent = TestableNetworkAgent.createOnInterface(context, handlerThread.looper,
+            iface.interfaceName, TIMEOUT_MS)
         val network = agent.network ?: fail("Registered agent should have a network")
 
         cb.eventuallyExpect<LinkPropertiesChanged>(TIMEOUT_MS) {
@@ -271,57 +256,6 @@
         return TestTapNetwork(iface, cb, agent, network)
     }
 
-    private fun registerTestNetworkAgent(ifaceName: String): TestableNetworkAgent {
-        val lp = LinkProperties().apply {
-            interfaceName = ifaceName
-        }
-        val agent = TestableNetworkAgent(
-            context,
-            handlerThread.looper,
-                NetworkCapabilities().apply {
-                    removeCapability(NET_CAPABILITY_TRUSTED)
-                    addTransportType(TRANSPORT_TEST)
-                    setNetworkSpecifier(TestNetworkSpecifier(ifaceName))
-                },
-            lp,
-            NetworkAgentConfig.Builder().build()
-        )
-        val network = agent.register()
-        agent.markConnected()
-        agent.expectCallback<OnNetworkCreated>()
-
-        // Wait until the link-local address can be used. Address flags are not available without
-        // elevated permissions, so check that bindSocket works.
-        PollingCheck.check("No usable v6 address on interface after $TIMEOUT_MS ms", TIMEOUT_MS) {
-            // To avoid race condition between socket connection succeeding and interface returning
-            // a non-empty address list. Verify that interface returns a non-empty list, before
-            // trying the socket connection.
-            if (NetworkInterface.getByName(ifaceName).interfaceAddresses.isEmpty()) {
-                return@check false
-            }
-
-            val sock = Os.socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP)
-            tryTest {
-                network.bindSocket(sock)
-                Os.connect(sock, parseNumericAddress("ff02::fb%$ifaceName"), 12345)
-                true
-            }.catch<ErrnoException> {
-                if (it.errno != ENETUNREACH && it.errno != EADDRNOTAVAIL) {
-                    throw it
-                }
-                false
-            } cleanup {
-                Os.close(sock)
-            }
-        }
-
-        lp.setLinkAddresses(NetworkInterface.getByName(ifaceName).interfaceAddresses.map {
-            LinkAddress(it.address, it.networkPrefixLength.toInt())
-        })
-        agent.sendLinkProperties(lp)
-        return agent
-    }
-
     private fun makeTestServiceInfo(network: Network? = null) = NsdServiceInfo().also {
         it.serviceType = serviceType
         it.serviceName = serviceName
@@ -576,7 +510,9 @@
             assertEquals(testNetwork1.network, serviceLost.serviceInfo.network)
 
             val newAgent = runAsShell(MANAGE_TEST_NETWORKS) {
-                registerTestNetworkAgent(testNetwork1.iface.interfaceName)
+                TestableNetworkAgent.createOnInterface(context, handlerThread.looper,
+                    testNetwork1.iface.interfaceName,
+                    TIMEOUT_MS)
             }
             val newNetwork = newAgent.network ?: fail("Registered agent should have a network")
             val serviceDiscovered3 = discoveryRecord.expectCallback<ServiceFound>()
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index c55096b..c2959f4 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -623,12 +623,14 @@
         mNat64CidrController.maybeUpdateNat64Cidr();
     }
 
-    private static OtDaemonConfiguration newOtDaemonConfig(
-            @NonNull ThreadConfiguration threadConfig) {
+    private OtDaemonConfiguration newOtDaemonConfig(ThreadConfiguration threadConfig) {
+        int srpServerConfig = R.bool.config_thread_srp_server_wait_for_border_routing_enabled;
+        boolean srpServerWaitEnabled = mResources.get().getBoolean(srpServerConfig);
         return new OtDaemonConfiguration.Builder()
                 .setBorderRouterEnabled(threadConfig.isBorderRouterEnabled())
                 .setNat64Enabled(threadConfig.isNat64Enabled())
                 .setDhcpv6PdEnabled(threadConfig.isDhcpv6PdEnabled())
+                .setSrpServerWaitForBorderRoutingEnabled(srpServerWaitEnabled)
                 .build();
     }
 
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index dcbb3f5..1d44ccb 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -231,6 +231,9 @@
 
         when(mConnectivityResources.get()).thenReturn(mResources);
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
+        when(mResources.getBoolean(
+                        eq(R.bool.config_thread_srp_server_wait_for_border_routing_enabled)))
+                .thenReturn(true);
         when(mResources.getString(eq(R.string.config_thread_vendor_name)))
                 .thenReturn(TEST_VENDOR_NAME);
         when(mResources.getString(eq(R.string.config_thread_vendor_oui)))
@@ -285,6 +288,9 @@
 
     @Test
     public void initialize_resourceOverlayValuesAreSetToOtDaemon() throws Exception {
+        when(mResources.getBoolean(
+                        eq(R.bool.config_thread_srp_server_wait_for_border_routing_enabled)))
+                .thenReturn(false);
         when(mResources.getString(eq(R.string.config_thread_vendor_name)))
                 .thenReturn(TEST_VENDOR_NAME);
         when(mResources.getString(eq(R.string.config_thread_vendor_oui)))
@@ -297,6 +303,7 @@
         mService.initialize();
         mTestLooper.dispatchAll();
 
+        assertThat(mFakeOtDaemon.getConfiguration().srpServerWaitForBorderRoutingEnabled).isFalse();
         MeshcopTxtAttributes meshcopTxts = mFakeOtDaemon.getOverriddenMeshcopTxtAttributes();
         assertThat(meshcopTxts.vendorName).isEqualTo(TEST_VENDOR_NAME);
         assertThat(meshcopTxts.vendorOui).isEqualTo(TEST_VENDOR_OUI_BYTES);