Merge "Add a new module-only API to preload HttpEngine" into main
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 2f3307a..e2498e4 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -33,6 +33,10 @@
 
         // Using for test only
         "//cts/tests/netlegacy22.api",
+
+        // TODO: b/374174952 Remove it when VCN CTS is moved to Connectivity/
+        "//cts/tests/tests/vcn",
+
         "//external/sl4a:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
diff --git a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
index cd57c8d..fb16226 100644
--- a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
+++ b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
@@ -167,6 +167,11 @@
                 } else {
                     mLog.e("Current user (" + currentUserId
                             + ") is not allowed to perform entitlement check.");
+                    // If the user is not allowed to perform an entitlement check
+                    // (e.g., a non-admin user), notify the receiver immediately.
+                    // This is necessary because the entitlement check app cannot
+                    // be launched to conduct the check and deliver the results.
+                    receiver.send(TETHER_ERROR_PROVISIONING_FAILED, null);
                     return null;
                 }
             } else {
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
index 8626b18..51c2d56 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
@@ -84,6 +84,7 @@
 
 import com.android.internal.util.test.BroadcastInterceptingContext;
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.ArrayTrackRecord;
 import com.android.net.module.util.SharedLog;
 import com.android.testutils.DevSdkIgnoreRule;
 
@@ -187,8 +188,9 @@
             if (intent != null) {
                 assertUiTetherProvisioningIntent(type, config, receiver, intent);
                 uiProvisionCount++;
+                // If the intent is null, the result is sent by the underlying method.
+                receiver.send(fakeEntitlementResult, null);
             }
-            receiver.send(fakeEntitlementResult, null);
             return intent;
         }
 
@@ -348,99 +350,43 @@
     public void testRequestLastEntitlementCacheValue() throws Exception {
         // 1. Entitlement check is not required.
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        ResultReceiver receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_NO_ERROR, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_NO_ERROR, true);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
 
         setupForRequiredProvisioning();
         // 2. No cache value and don't need to run entitlement check.
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_ENTITLEMENT_UNKNOWN, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_ENTITLEMENT_UNKNOWN, false);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
         // 3. No cache value and ui entitlement check is needed.
         mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_PROVISIONING_FAILED, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_PROVISIONING_FAILED, true);
         assertEquals(1, mDeps.uiProvisionCount);
         mDeps.reset();
         // 4. Cache value is TETHER_ERROR_PROVISIONING_FAILED and don't need to run entitlement
         // check.
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_PROVISIONING_FAILED, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_PROVISIONING_FAILED, false);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
         // 5. Cache value is TETHER_ERROR_PROVISIONING_FAILED and ui entitlement check is needed.
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_NO_ERROR, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_NO_ERROR, true);
         assertEquals(1, mDeps.uiProvisionCount);
         mDeps.reset();
         // 6. Cache value is TETHER_ERROR_NO_ERROR.
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_NO_ERROR, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_NO_ERROR, true);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
         // 7. Test get value for other downstream type.
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_ENTITLEMENT_UNKNOWN, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_USB, receiver, false);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_USB, TETHER_ERROR_ENTITLEMENT_UNKNOWN, false);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
         // 8. Test get value for invalid downstream type.
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_ENTITLEMENT_UNKNOWN, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI_P2P, receiver, true);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI_P2P, TETHER_ERROR_ENTITLEMENT_UNKNOWN, true);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
     }
@@ -660,6 +606,34 @@
         doTestUiProvisioningMultiUser(false, 1);
     }
 
+    private static class TestableResultReceiver extends ResultReceiver {
+        private static final long DEFAULT_TIMEOUT_MS = 200L;
+        private final ArrayTrackRecord<Integer>.ReadHead mHistory =
+                new ArrayTrackRecord<Integer>().newReadHead();
+
+        TestableResultReceiver(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        protected void onReceiveResult(int resultCode, Bundle resultData) {
+            mHistory.add(resultCode);
+        }
+
+        void expectResult(int resultCode) {
+            final int event = mHistory.poll(DEFAULT_TIMEOUT_MS, it -> true);
+            assertEquals(resultCode, event);
+        }
+    }
+
+    void assertLatestEntitlementResult(int downstreamType, int expectedCode,
+            boolean showEntitlementUi) {
+        final TestableResultReceiver receiver = new TestableResultReceiver(null);
+        mEnMgr.requestLatestTetheringEntitlementResult(downstreamType, receiver, showEntitlementUi);
+        mLooper.dispatchAll();
+        receiver.expectResult(expectedCode);
+    }
+
     private void doTestUiProvisioningMultiUser(boolean isAdminUser, int expectedUiProvisionCount) {
         setupForRequiredProvisioning();
         doReturn(isAdminUser).when(mUserManager).isAdminUser();
@@ -671,10 +645,19 @@
         mEnMgr.startProvisioningIfNeeded(TETHERING_USB, true);
         mLooper.dispatchAll();
         assertEquals(expectedUiProvisionCount, mDeps.uiProvisionCount);
+        if (expectedUiProvisionCount == 0) { // Failed to launch entitlement UI.
+            assertLatestEntitlementResult(TETHERING_USB, TETHER_ERROR_PROVISIONING_FAILED, false);
+            verify(mTetherProvisioningFailedListener).onTetherProvisioningFailed(TETHERING_USB,
+                    FAILED_TETHERING_REASON);
+        } else {
+            assertLatestEntitlementResult(TETHERING_USB, TETHER_ERROR_NO_ERROR, false);
+            verify(mTetherProvisioningFailedListener, never()).onTetherProvisioningFailed(anyInt(),
+                    anyString());
+        }
     }
 
     @Test
-    public void testsetExemptedDownstreamType() throws Exception {
+    public void testSetExemptedDownstreamType() {
         setupForRequiredProvisioning();
         // Cellular upstream is not permitted when no entitlement result.
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
@@ -737,14 +720,7 @@
         setupCarrierConfig(false);
         setupForRequiredProvisioning();
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        ResultReceiver receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_PROVISIONING_FAILED, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
-        mLooper.dispatchAll();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_PROVISIONING_FAILED, false);
         assertEquals(0, mDeps.uiProvisionCount);
         mDeps.reset();
     }
diff --git a/bpf/syscall_wrappers/include/BpfSyscallWrappers.h b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
index 73cef89..a31445a 100644
--- a/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
+++ b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
@@ -16,24 +16,20 @@
 
 #pragma once
 
+#include <android-base/unique_fd.h>
 #include <stdlib.h>
 #include <unistd.h>
 #include <linux/bpf.h>
 #include <linux/unistd.h>
 #include <sys/file.h>
 
-#ifdef BPF_FD_JUST_USE_INT
-  #define BPF_FD_TYPE int
-  #define BPF_FD_TO_U32(x) static_cast<__u32>(x)
-#else
-  #include <android-base/unique_fd.h>
-  #define BPF_FD_TYPE base::unique_fd&
-  #define BPF_FD_TO_U32(x) static_cast<__u32>((x).get())
-#endif
 
 namespace android {
 namespace bpf {
 
+using ::android::base::borrowed_fd;
+using ::android::base::unique_fd;
+
 inline uint64_t ptr_to_u64(const void * const x) {
     return (uint64_t)(uintptr_t)x;
 }
@@ -69,58 +65,59 @@
 //   'inner_map_fd' is basically a template specifying {map_type, key_size, value_size, max_entries, map_flags}
 //   of the inner map type (and possibly only key_size/value_size actually matter?).
 inline int createOuterMap(bpf_map_type map_type, uint32_t key_size, uint32_t value_size,
-                          uint32_t max_entries, uint32_t map_flags, const BPF_FD_TYPE inner_map_fd) {
+                          uint32_t max_entries, uint32_t map_flags,
+                          const borrowed_fd& inner_map_fd) {
     return bpf(BPF_MAP_CREATE, {
                                        .map_type = map_type,
                                        .key_size = key_size,
                                        .value_size = value_size,
                                        .max_entries = max_entries,
                                        .map_flags = map_flags,
-                                       .inner_map_fd = BPF_FD_TO_U32(inner_map_fd),
+                                       .inner_map_fd = static_cast<__u32>(inner_map_fd.get()),
                                });
 }
 
-inline int writeToMapEntry(const BPF_FD_TYPE map_fd, const void* key, const void* value,
+inline int writeToMapEntry(const borrowed_fd& map_fd, const void* key, const void* value,
                            uint64_t flags) {
     return bpf(BPF_MAP_UPDATE_ELEM, {
-                                            .map_fd = BPF_FD_TO_U32(map_fd),
+                                            .map_fd = static_cast<__u32>(map_fd.get()),
                                             .key = ptr_to_u64(key),
                                             .value = ptr_to_u64(value),
                                             .flags = flags,
                                     });
 }
 
-inline int findMapEntry(const BPF_FD_TYPE map_fd, const void* key, void* value) {
+inline int findMapEntry(const borrowed_fd& map_fd, const void* key, void* value) {
     return bpf(BPF_MAP_LOOKUP_ELEM, {
-                                            .map_fd = BPF_FD_TO_U32(map_fd),
+                                            .map_fd = static_cast<__u32>(map_fd.get()),
                                             .key = ptr_to_u64(key),
                                             .value = ptr_to_u64(value),
                                     });
 }
 
-inline int deleteMapEntry(const BPF_FD_TYPE map_fd, const void* key) {
+inline int deleteMapEntry(const borrowed_fd& map_fd, const void* key) {
     return bpf(BPF_MAP_DELETE_ELEM, {
-                                            .map_fd = BPF_FD_TO_U32(map_fd),
+                                            .map_fd = static_cast<__u32>(map_fd.get()),
                                             .key = ptr_to_u64(key),
                                     });
 }
 
-inline int getNextMapKey(const BPF_FD_TYPE map_fd, const void* key, void* next_key) {
+inline int getNextMapKey(const borrowed_fd& map_fd, const void* key, void* next_key) {
     return bpf(BPF_MAP_GET_NEXT_KEY, {
-                                             .map_fd = BPF_FD_TO_U32(map_fd),
+                                             .map_fd = static_cast<__u32>(map_fd.get()),
                                              .key = ptr_to_u64(key),
                                              .next_key = ptr_to_u64(next_key),
                                      });
 }
 
-inline int getFirstMapKey(const BPF_FD_TYPE map_fd, void* firstKey) {
+inline int getFirstMapKey(const borrowed_fd& map_fd, void* firstKey) {
     return getNextMapKey(map_fd, NULL, firstKey);
 }
 
-inline int bpfFdPin(const BPF_FD_TYPE map_fd, const char* pathname) {
+inline int bpfFdPin(const borrowed_fd& map_fd, const char* pathname) {
     return bpf(BPF_OBJ_PIN, {
                                     .pathname = ptr_to_u64(pathname),
-                                    .bpf_fd = BPF_FD_TO_U32(map_fd),
+                                    .bpf_fd = static_cast<__u32>(map_fd.get()),
                             });
 }
 
@@ -131,22 +128,15 @@
                             });
 }
 
-int bpfGetFdMapId(const BPF_FD_TYPE map_fd);
+int bpfGetFdMapId(const borrowed_fd& map_fd);
 
 inline int bpfLock(int fd, short type) {
     if (fd < 0) return fd;  // pass any errors straight through
 #ifdef BPF_MAP_LOCKLESS_FOR_TEST
     return fd;
 #endif
-#ifdef BPF_FD_JUST_USE_INT
     int mapId = bpfGetFdMapId(fd);
     int saved_errno = errno;
-#else
-    base::unique_fd ufd(fd);
-    int mapId = bpfGetFdMapId(ufd);
-    int saved_errno = errno;
-    (void)ufd.release();
-#endif
     // 4.14+ required to fetch map id, but we don't want to call isAtLeastKernelVersion
     if (mapId == -1 && saved_errno == EINVAL) return fd;
     if (mapId <= 0) abort();  // should not be possible
@@ -193,37 +183,35 @@
 }
 
 inline bool usableProgram(const char* pathname) {
-    int fd = retrieveProgram(pathname);
-    bool ok = (fd >= 0);
-    if (ok) close(fd);
-    return ok;
+    unique_fd fd(retrieveProgram(pathname));
+    return fd.ok();
 }
 
-inline int attachProgram(bpf_attach_type type, const BPF_FD_TYPE prog_fd,
-                         const BPF_FD_TYPE cg_fd, uint32_t flags = 0) {
+inline int attachProgram(bpf_attach_type type, const borrowed_fd& prog_fd,
+                         const borrowed_fd& cg_fd, uint32_t flags = 0) {
     return bpf(BPF_PROG_ATTACH, {
-                                        .target_fd = BPF_FD_TO_U32(cg_fd),
-                                        .attach_bpf_fd = BPF_FD_TO_U32(prog_fd),
+                                        .target_fd = static_cast<__u32>(cg_fd.get()),
+                                        .attach_bpf_fd = static_cast<__u32>(prog_fd.get()),
                                         .attach_type = type,
                                         .attach_flags = flags,
                                 });
 }
 
-inline int detachProgram(bpf_attach_type type, const BPF_FD_TYPE cg_fd) {
+inline int detachProgram(bpf_attach_type type, const borrowed_fd& cg_fd) {
     return bpf(BPF_PROG_DETACH, {
-                                        .target_fd = BPF_FD_TO_U32(cg_fd),
+                                        .target_fd = static_cast<__u32>(cg_fd.get()),
                                         .attach_type = type,
                                 });
 }
 
-inline int queryProgram(const BPF_FD_TYPE cg_fd,
+inline int queryProgram(const borrowed_fd& cg_fd,
                         enum bpf_attach_type attach_type,
                         __u32 query_flags = 0,
                         __u32 attach_flags = 0) {
     int prog_id = -1;  // equivalent to an array of one integer.
     bpf_attr arg = {
             .query = {
-                    .target_fd = BPF_FD_TO_U32(cg_fd),
+                    .target_fd = static_cast<__u32>(cg_fd.get()),
                     .attach_type = attach_type,
                     .query_flags = query_flags,
                     .attach_flags = attach_flags,
@@ -237,21 +225,21 @@
     return prog_id;  // return actual id
 }
 
-inline int detachSingleProgram(bpf_attach_type type, const BPF_FD_TYPE prog_fd,
-                               const BPF_FD_TYPE cg_fd) {
+inline int detachSingleProgram(bpf_attach_type type, const borrowed_fd& prog_fd,
+                               const borrowed_fd& cg_fd) {
     return bpf(BPF_PROG_DETACH, {
-                                        .target_fd = BPF_FD_TO_U32(cg_fd),
-                                        .attach_bpf_fd = BPF_FD_TO_U32(prog_fd),
+                                        .target_fd = static_cast<__u32>(cg_fd.get()),
+                                        .attach_bpf_fd = static_cast<__u32>(prog_fd.get()),
                                         .attach_type = type,
                                 });
 }
 
 // Available in 4.12 and later kernels.
-inline int runProgram(const BPF_FD_TYPE prog_fd, const void* data,
+inline int runProgram(const borrowed_fd& prog_fd, const void* data,
                       const uint32_t data_size) {
     return bpf(BPF_PROG_RUN, {
                                      .test = {
-                                             .prog_fd = BPF_FD_TO_U32(prog_fd),
+                                             .prog_fd = static_cast<__u32>(prog_fd.get()),
                                              .data_size_in = data_size,
                                              .data_in = ptr_to_u64(data),
                                      },
@@ -265,10 +253,10 @@
 // supported/returned by the running kernel.  We do this by checking it is fully
 // within the bounds of the struct size as reported by the kernel.
 #define DEFINE_BPF_GET_FD(TYPE, NAME, FIELD) \
-inline int bpfGetFd ## NAME(const BPF_FD_TYPE fd) { \
+inline int bpfGetFd ## NAME(const borrowed_fd& fd) { \
     struct bpf_ ## TYPE ## _info info = {}; \
     union bpf_attr attr = { .info = { \
-        .bpf_fd = BPF_FD_TO_U32(fd), \
+        .bpf_fd = static_cast<__u32>(fd.get()), \
         .info_len = sizeof(info), \
         .info = ptr_to_u64(&info), \
     }}; \
@@ -283,19 +271,16 @@
 
 // All 7 of these fields are already present in Linux v4.14 (even ACK 4.14-P)
 // while BPF_OBJ_GET_INFO_BY_FD is not implemented at all in v4.9 (even ACK 4.9-Q)
-DEFINE_BPF_GET_FD(map, MapType, type)            // int bpfGetFdMapType(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, MapId, id)                // int bpfGetFdMapId(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, KeySize, key_size)        // int bpfGetFdKeySize(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, ValueSize, value_size)    // int bpfGetFdValueSize(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, MaxEntries, max_entries)  // int bpfGetFdMaxEntries(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, MapFlags, map_flags)      // int bpfGetFdMapFlags(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(prog, ProgId, id)              // int bpfGetFdProgId(const BPF_FD_TYPE prog_fd)
+DEFINE_BPF_GET_FD(map, MapType, type)            // int bpfGetFdMapType(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(map, MapId, id)                // int bpfGetFdMapId(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(map, KeySize, key_size)        // int bpfGetFdKeySize(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(map, ValueSize, value_size)    // int bpfGetFdValueSize(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(map, MaxEntries, max_entries)  // int bpfGetFdMaxEntries(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(map, MapFlags, map_flags)      // int bpfGetFdMapFlags(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(prog, ProgId, id)              // int bpfGetFdProgId(const borrowed_fd& prog_fd)
 
 #undef DEFINE_BPF_GET_FD
 
 }  // namespace bpf
 }  // namespace android
 
-#undef BPF_FD_TO_U32
-#undef BPF_FD_TYPE
-#undef BPF_FD_JUST_USE_INT
diff --git a/common/networksecurity_flags.aconfig b/common/networksecurity_flags.aconfig
index 6438ba4..4a83af4 100644
--- a/common/networksecurity_flags.aconfig
+++ b/common/networksecurity_flags.aconfig
@@ -8,3 +8,12 @@
     bug: "319829948"
     is_fixed_read_only: true
 }
+
+flag {
+    name: "certificate_transparency_job"
+    is_exported: true
+    namespace: "network_security"
+    description: "Enable daily job service for certificate transparency instead of flags listener"
+    bug: "319829948"
+    is_fixed_read_only: true
+}
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 7551b92..26fc145 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -172,6 +172,10 @@
         // Tests using hidden APIs
         "//cts/tests/netlegacy22.api",
         "//cts/tests/tests/app.usage", // NetworkUsageStatsTest
+
+        // TODO: b/374174952 Remove it when VCN CTS is moved to Connectivity/
+        "//cts/tests/tests/vcn",
+
         "//external/sl4a:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
diff --git a/framework/Android.bp b/framework/Android.bp
index 4353213..a93a532 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -64,6 +64,7 @@
         ":net-utils-framework-common-srcs",
         ":framework-connectivity-api-shared-srcs",
         ":framework-networksecurity-sources",
+        ":statslog-framework-connectivity-java-gen",
     ],
     aidl: {
         generate_get_transaction_name: true,
@@ -104,6 +105,7 @@
         "androidx.annotation_annotation",
         "app-compat-annotations",
         "framework-connectivity-t.stubs.module_lib",
+        "framework-statsd.stubs.module_lib",
         "unsupportedappusage",
     ],
     apex_available: [
@@ -188,6 +190,10 @@
         // Tests using hidden APIs
         "//cts/tests/netlegacy22.api",
         "//cts/tests/tests/app.usage", // NetworkUsageStatsTest
+
+        // TODO: b/374174952 Remove it when VCN CTS is moved to Connectivity/
+        "//cts/tests/tests/vcn",
+
         "//external/sl4a:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java
index 574ab2f..cefa1ea 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -31,12 +31,14 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteException;
 import android.telephony.data.EpsBearerQosSessionAttributes;
 import android.telephony.data.NrQosSessionAttributes;
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.FrameworkConnectivityStatsLog;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -943,6 +945,19 @@
 
     private void queueOrSendMessage(@NonNull RegistryAction action) {
         synchronized (mPreConnectedQueue) {
+            if (mNetwork == null && !Process.isApplicationUid(Process.myUid())) {
+                // Theoretically, it should not be valid to queue messages here before
+                // registering the NetworkAgent. However, practically, with the way
+                // queueing works right now, it ends up working out just fine.
+                // Log a statistic so that we know if this is happening in the
+                // wild. The check for isApplicationUid is to prevent logging the
+                // metric from test code.
+
+                FrameworkConnectivityStatsLog.write(
+                        FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                        FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_MESSAGE_QUEUED_BEFORE_CONNECT
+                );
+            }
             if (mRegistry != null) {
                 try {
                     action.execute(mRegistry);
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 f86d127..bd8f7b9 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -15,11 +15,11 @@
  */
 package com.android.server.net.ct;
 
-import android.annotation.NonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import android.annotation.RequiresApi;
 import android.app.DownloadManager;
 import android.content.BroadcastReceiver;
-import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
@@ -31,16 +31,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.security.KeyFactory;
-import java.security.PublicKey;
-import java.security.Signature;
-import java.security.spec.X509EncodedKeySpec;
-import java.util.Base64;
-import java.util.Optional;
 
 /** Helper class to download certificate transparency log files. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@@ -51,30 +47,22 @@
     private final Context mContext;
     private final DataStore mDataStore;
     private final DownloadHelper mDownloadHelper;
+    private final SignatureVerifier mSignatureVerifier;
     private final CertificateTransparencyInstaller mInstaller;
 
-    @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
-
-    @VisibleForTesting
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
+            SignatureVerifier signatureVerifier,
             CertificateTransparencyInstaller installer) {
         mContext = context;
+        mSignatureVerifier = signatureVerifier;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
         mInstaller = installer;
     }
 
-    CertificateTransparencyDownloader(Context context, DataStore dataStore) {
-        this(
-                context,
-                dataStore,
-                new DownloadHelper(context),
-                new CertificateTransparencyInstaller());
-    }
-
     void initialize() {
         mInstaller.addCompatibilityVersion(Config.COMPATIBILITY_VERSION);
 
@@ -87,18 +75,14 @@
         }
     }
 
-    void setPublicKey(String publicKey) throws GeneralSecurityException {
-        mPublicKey =
-                Optional.of(
-                        KeyFactory.getInstance("RSA")
-                                .generatePublic(
-                                        new X509EncodedKeySpec(
-                                                Base64.getDecoder().decode(publicKey))));
-    }
-
-    @VisibleForTesting
-    void resetPublicKey() {
-        mPublicKey = Optional.empty();
+    void startPublicKeyDownload(String publicKeyUrl) {
+        long downloadId = download(publicKeyUrl);
+        if (downloadId == -1) {
+            Log.e(TAG, "Metadata download request failed for " + publicKeyUrl);
+            return;
+        }
+        mDataStore.setPropertyLong(Config.PUBLIC_KEY_URL_KEY, downloadId);
+        mDataStore.store();
     }
 
     void startMetadataDownload(String metadataUrl) {
@@ -135,6 +119,11 @@
             return;
         }
 
+        if (isPublicKeyDownloadId(completedId)) {
+            handlePublicKeyDownloadCompleted(completedId);
+            return;
+        }
+
         if (isMetadataDownloadId(completedId)) {
             handleMetadataDownloadCompleted(completedId);
             return;
@@ -145,7 +134,30 @@
             return;
         }
 
-        Log.e(TAG, "Download id " + completedId + " is neither metadata nor content.");
+        Log.i(TAG, "Download id " + completedId + " is not recognized.");
+    }
+
+    private void handlePublicKeyDownloadCompleted(long downloadId) {
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
+            return;
+        }
+
+        Uri publicKeyUri = getPublicKeyDownloadUri();
+        if (publicKeyUri == null) {
+            Log.e(TAG, "Invalid public key URI");
+            return;
+        }
+
+        try {
+            mSignatureVerifier.setPublicKeyFrom(publicKeyUri);
+        } catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
+            Log.e(TAG, "Error setting the public Key", e);
+            return;
+        }
+
+        startMetadataDownload(mDataStore.getProperty(Config.METADATA_URL_PENDING));
     }
 
     private void handleMetadataDownloadCompleted(long downloadId) {
@@ -173,7 +185,7 @@
 
         boolean success = false;
         try {
-            success = verify(contentUri, metadataUri);
+            success = mSignatureVerifier.verify(contentUri, metadataUri);
         } catch (IOException | GeneralSecurityException e) {
             Log.e(TAG, "Could not verify new log list", e);
         }
@@ -182,9 +194,16 @@
             return;
         }
 
-        // TODO: validate file content.
+        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;
+        }
 
-        String version = mDataStore.getProperty(Config.VERSION_PENDING);
         String contentUrl = mDataStore.getProperty(Config.CONTENT_URL_PENDING);
         String metadataUrl = mDataStore.getProperty(Config.METADATA_URL_PENDING);
         try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
@@ -204,25 +223,10 @@
     }
 
     private void handleDownloadFailed(DownloadStatus status) {
-        Log.e(TAG, "Content download failed with " + status);
+        Log.e(TAG, "Download failed with " + status);
         // TODO(378626065): Report failure via statsd.
     }
 
-    private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
-        if (!mPublicKey.isPresent()) {
-            throw new InvalidKeyException("Missing public key for signature verification");
-        }
-        Signature verifier = Signature.getInstance("SHA256withRSA");
-        verifier.initVerify(mPublicKey.get());
-        ContentResolver contentResolver = mContext.getContentResolver();
-
-        try (InputStream fileStream = contentResolver.openInputStream(file);
-                InputStream signatureStream = contentResolver.openInputStream(signature)) {
-            verifier.update(fileStream.readAllBytes());
-            return verifier.verify(signatureStream.readAllBytes());
-        }
-    }
-
     private long download(String url) {
         try {
             return mDownloadHelper.startDownload(url);
@@ -233,6 +237,11 @@
     }
 
     @VisibleForTesting
+    boolean isPublicKeyDownloadId(long downloadId) {
+        return mDataStore.getPropertyLong(Config.PUBLIC_KEY_URL_KEY, -1) == downloadId;
+    }
+
+    @VisibleForTesting
     boolean isMetadataDownloadId(long downloadId) {
         return mDataStore.getPropertyLong(Config.METADATA_URL_KEY, -1) == downloadId;
     }
@@ -242,6 +251,10 @@
         return mDataStore.getPropertyLong(Config.CONTENT_URL_KEY, -1) == downloadId;
     }
 
+    private Uri getPublicKeyDownloadUri() {
+        return mDownloadHelper.getUri(mDataStore.getPropertyLong(Config.PUBLIC_KEY_URL_KEY, -1));
+    }
+
     private Uri getMetadataDownloadUri() {
         return mDownloadHelper.getUri(mDataStore.getPropertyLong(Config.METADATA_URL_KEY, -1));
     }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
index 0ae982d..f359a2a 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -16,7 +16,6 @@
 package com.android.server.net.ct;
 
 import android.annotation.RequiresApi;
-import android.content.Context;
 import android.os.Build;
 import android.provider.DeviceConfig;
 import android.provider.DeviceConfig.Properties;
@@ -33,12 +32,16 @@
     private static final String TAG = "CertificateTransparencyFlagsListener";
 
     private final DataStore mDataStore;
+    private final SignatureVerifier mSignatureVerifier;
     private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
-    CertificateTransparencyFlagsListener(Context context) {
-        mDataStore = new DataStore(Config.PREFERENCES_FILE);
-        mCertificateTransparencyDownloader =
-                new CertificateTransparencyDownloader(context, mDataStore);
+    CertificateTransparencyFlagsListener(
+            DataStore dataStore,
+            SignatureVerifier signatureVerifier,
+            CertificateTransparencyDownloader certificateTransparencyDownloader) {
+        mDataStore = dataStore;
+        mSignatureVerifier = signatureVerifier;
+        mCertificateTransparencyDownloader = certificateTransparencyDownloader;
     }
 
     void initialize() {
@@ -104,8 +107,8 @@
         }
 
         try {
-            mCertificateTransparencyDownloader.setPublicKey(newPublicKey);
-        } catch (GeneralSecurityException e) {
+            mSignatureVerifier.setPublicKey(newPublicKey);
+        } catch (GeneralSecurityException | IllegalArgumentException e) {
             Log.e(TAG, "Error setting the public Key", e);
             return;
         }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
new file mode 100644
index 0000000..c5d0413
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
@@ -0,0 +1,88 @@
+/*
+ * 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.annotation.RequiresApi;
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.Log;
+
+/** Implementation of the Certificate Transparency job */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+public class CertificateTransparencyJob extends BroadcastReceiver {
+
+    private static final String TAG = "CertificateTransparencyJob";
+
+    private static final String ACTION_JOB_START = "com.android.server.net.ct.action.JOB_START";
+
+    private final Context mContext;
+    private final DataStore mDataStore;
+    private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
+    private final AlarmManager mAlarmManager;
+
+    /** Creates a new {@link CertificateTransparencyJob} object. */
+    public CertificateTransparencyJob(
+            Context context,
+            DataStore dataStore,
+            CertificateTransparencyDownloader certificateTransparencyDownloader) {
+        mContext = context;
+        mDataStore = dataStore;
+        mCertificateTransparencyDownloader = certificateTransparencyDownloader;
+        mAlarmManager = context.getSystemService(AlarmManager.class);
+    }
+
+    void initialize() {
+        mDataStore.load();
+        mCertificateTransparencyDownloader.initialize();
+
+        mContext.registerReceiver(
+                this, new IntentFilter(ACTION_JOB_START), Context.RECEIVER_EXPORTED);
+        mAlarmManager.setInexactRepeating(
+                AlarmManager.ELAPSED_REALTIME,
+                SystemClock.elapsedRealtime(), // schedule first job at earliest convenient time.
+                AlarmManager.INTERVAL_DAY,
+                PendingIntent.getBroadcast(
+                        mContext, 0, new Intent(ACTION_JOB_START), PendingIntent.FLAG_IMMUTABLE));
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob scheduled successfully.");
+        }
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (!ACTION_JOB_START.equals(intent.getAction())) {
+            Log.w(TAG, "Received unexpected broadcast with action " + intent);
+            return;
+        }
+        if (Config.DEBUG) {
+            Log.d(TAG, "Starting CT daily job.");
+        }
+
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, Config.URL_LOG_LIST);
+        mDataStore.setProperty(Config.METADATA_URL_PENDING, Config.URL_SIGNATURE);
+        mDataStore.setProperty(Config.PUBLIC_KEY_URL_PENDING, Config.URL_PUBLIC_KEY);
+        mDataStore.store();
+
+        mCertificateTransparencyDownloader.startPublicKeyDownload(Config.URL_PUBLIC_KEY);
+    }
+}
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 edf7c56..92b2b09 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -29,6 +29,7 @@
 public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub {
 
     private final CertificateTransparencyFlagsListener mFlagsListener;
+    private final CertificateTransparencyJob mCertificateTransparencyJob;
 
     /**
      * @return true if the CertificateTransparency service is enabled.
@@ -41,7 +42,21 @@
 
     /** Creates a new {@link CertificateTransparencyService} object. */
     public CertificateTransparencyService(Context context) {
-        mFlagsListener = new CertificateTransparencyFlagsListener(context);
+        DataStore dataStore = new DataStore(Config.PREFERENCES_FILE);
+        DownloadHelper downloadHelper = new DownloadHelper(context);
+        SignatureVerifier signatureVerifier = new SignatureVerifier(context);
+        CertificateTransparencyDownloader downloader =
+                new CertificateTransparencyDownloader(
+                        context,
+                        dataStore,
+                        downloadHelper,
+                        signatureVerifier,
+                        new CertificateTransparencyInstaller());
+
+        mFlagsListener =
+                new CertificateTransparencyFlagsListener(dataStore, signatureVerifier, downloader);
+        mCertificateTransparencyJob =
+                new CertificateTransparencyJob(context, dataStore, downloader);
     }
 
     /**
@@ -53,7 +68,11 @@
 
         switch (phase) {
             case SystemService.PHASE_BOOT_COMPLETED:
-                mFlagsListener.initialize();
+                if (Flags.certificateTransparencyJob()) {
+                    mCertificateTransparencyJob.initialize();
+                } else {
+                    mFlagsListener.initialize();
+                }
                 break;
             default:
         }
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 242f13a..ae30f3a 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -55,4 +55,13 @@
     static final String METADATA_URL_PENDING = "metadata_url_pending";
     static final String METADATA_URL = "metadata_url";
     static final String METADATA_URL_KEY = "metadata_url_key";
+    static final String PUBLIC_KEY_URL_PENDING = "public_key_url_pending";
+    static final String PUBLIC_KEY_URL = "public_key_url";
+    static final String PUBLIC_KEY_URL_KEY = "public_key_url_key";
+
+    // URLs
+    static final String URL_BASE = "https://www.gstatic.com/android/certificate_transparency/";
+    static final String URL_LOG_LIST = URL_BASE + "log_list.json";
+    static final String URL_SIGNATURE = URL_BASE + "log_list.sig";
+    static final String URL_PUBLIC_KEY = URL_BASE + "log_list.pub";
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
new file mode 100644
index 0000000..0b775ca
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
@@ -0,0 +1,91 @@
+/*
+ * 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.annotation.NonNull;
+import android.annotation.RequiresApi;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Optional;
+
+/** Verifier of the log list signature. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+public class SignatureVerifier {
+
+    private final Context mContext;
+
+    @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
+
+    public SignatureVerifier(Context context) {
+        mContext = context;
+    }
+
+    @VisibleForTesting
+    Optional<PublicKey> getPublicKey() {
+        return mPublicKey;
+    }
+
+    void resetPublicKey() {
+        mPublicKey = Optional.empty();
+    }
+
+    void setPublicKeyFrom(Uri file) throws GeneralSecurityException, IOException {
+        try (InputStream fileStream = mContext.getContentResolver().openInputStream(file)) {
+            setPublicKey(new String(fileStream.readAllBytes()));
+        }
+    }
+
+    void setPublicKey(String publicKey) throws GeneralSecurityException {
+        setPublicKey(
+                KeyFactory.getInstance("RSA")
+                        .generatePublic(
+                                new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey))));
+    }
+
+    @VisibleForTesting
+    void setPublicKey(PublicKey publicKey) {
+        mPublicKey = Optional.of(publicKey);
+    }
+
+    boolean verify(Uri file, Uri signature) throws GeneralSecurityException, IOException {
+        if (!mPublicKey.isPresent()) {
+            throw new InvalidKeyException("Missing public key for signature verification");
+        }
+        Signature verifier = Signature.getInstance("SHA256withRSA");
+        verifier.initVerify(mPublicKey.get());
+        ContentResolver contentResolver = mContext.getContentResolver();
+
+        try (InputStream fileStream = contentResolver.openInputStream(file);
+                InputStream signatureStream = contentResolver.openInputStream(signature)) {
+            verifier.update(fileStream.readAllBytes());
+            return verifier.verify(signatureStream.readAllBytes());
+        }
+    }
+}
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 fb55295..87d75e6 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
@@ -18,12 +18,14 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import android.app.DownloadManager;
 import android.content.Context;
 import android.content.Intent;
@@ -33,6 +35,8 @@
 
 import com.android.server.net.ct.DownloadHelper.DownloadStatus;
 
+import org.json.JSONException;
+import org.json.JSONObject;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -67,8 +71,11 @@
     private Context mContext;
     private File mTempFile;
     private DataStore mDataStore;
+    private SignatureVerifier mSignatureVerifier;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
+    private long mNextDownloadId = 666;
+
     @Before
     public void setUp() throws IOException, GeneralSecurityException {
         MockitoAnnotations.initMocks(this);
@@ -82,23 +89,37 @@
         mTempFile = File.createTempFile("datastore-test", ".properties");
         mDataStore = new DataStore(mTempFile);
         mDataStore.load();
+        mSignatureVerifier = new SignatureVerifier(mContext);
 
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
-                        mContext, mDataStore, mDownloadHelper, mCertificateTransparencyInstaller);
+                        mContext,
+                        mDataStore,
+                        mDownloadHelper,
+                        mSignatureVerifier,
+                        mCertificateTransparencyInstaller);
     }
 
     @After
     public void tearDown() {
         mTempFile.delete();
-        mCertificateTransparencyDownloader.resetPublicKey();
+        mSignatureVerifier.resetPublicKey();
+    }
+
+    @Test
+    public void testDownloader_startPublicKeyDownload() {
+        String publicKeyUrl = "http://test-public-key.org";
+        long downloadId = preparePublicKeyDownload(publicKeyUrl);
+
+        assertThat(mCertificateTransparencyDownloader.isPublicKeyDownloadId(downloadId)).isFalse();
+        mCertificateTransparencyDownloader.startPublicKeyDownload(publicKeyUrl);
+        assertThat(mCertificateTransparencyDownloader.isPublicKeyDownloadId(downloadId)).isTrue();
     }
 
     @Test
     public void testDownloader_startMetadataDownload() {
         String metadataUrl = "http://test-metadata.org";
-        long downloadId = 666;
-        when(mDownloadHelper.startDownload(metadataUrl)).thenReturn(downloadId);
+        long downloadId = prepareMetadataDownload(metadataUrl);
 
         assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isFalse();
         mCertificateTransparencyDownloader.startMetadataDownload(metadataUrl);
@@ -108,8 +129,7 @@
     @Test
     public void testDownloader_startContentDownload() {
         String contentUrl = "http://test-content.org";
-        long downloadId = 666;
-        when(mDownloadHelper.startDownload(contentUrl)).thenReturn(downloadId);
+        long downloadId = prepareContentDownload(contentUrl);
 
         assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isFalse();
         mCertificateTransparencyDownloader.startContentDownload(contentUrl);
@@ -117,16 +137,65 @@
     }
 
     @Test
-    public void testDownloader_metadataDownloadSuccess_startContentDownload() {
-        long metadataId = 123;
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.getDownloadStatus(metadataId))
-                .thenReturn(makeSuccessfulDownloadStatus(metadataId));
-        long contentId = 666;
-        String contentUrl = "http://test-content.org";
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
-        when(mDownloadHelper.startDownload(contentUrl)).thenReturn(contentId);
+    public void testDownloader_publicKeyDownloadSuccess_updatePublicKey_startMetadataDownload()
+            throws Exception {
+        long publicKeyId = prepareSuccessfulPublicKeyDownload(writePublicKeyToFile(mPublicKey));
+        long metadataId = prepareMetadataDownload("http://test-metadata.org");
 
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(metadataId)).isFalse();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(publicKeyId));
+
+        assertThat(mSignatureVerifier.getPublicKey()).hasValue(mPublicKey);
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(metadataId)).isTrue();
+    }
+
+    @Test
+    public void
+            testDownloader_publicKeyDownloadSuccess_updatePublicKeyFail_doNotStartMetadataDownload()
+                    throws Exception {
+        long publicKeyId =
+                prepareSuccessfulPublicKeyDownload(
+                        writeToFile("i_am_not_a_base64_encoded_public_key".getBytes()));
+        long metadataId = prepareMetadataDownload("http://test-metadata.org");
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(metadataId)).isFalse();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(publicKeyId));
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(metadataId)).isFalse();
+        verify(mDownloadHelper, never()).startDownload(anyString());
+    }
+
+    @Test
+    public void testDownloader_publicKeyDownloadFail_doNotUpdatePublicKey() throws Exception {
+        long publicKeyId =
+                prepareFailedPublicKeyDownload(
+                        // Failure cases where we give up on the download.
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        DownloadManager.ERROR_HTTP_DATA_ERROR);
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(publicKeyId);
+        long metadataId = prepareMetadataDownload("http://test-metadata.org");
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(metadataId)).isFalse();
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(metadataId)).isFalse();
+        verify(mDownloadHelper, never()).startDownload(anyString());
+    }
+
+    @Test
+    public void testDownloader_metadataDownloadSuccess_startContentDownload() {
+        long metadataId = prepareSuccessfulMetadataDownload(new File("log_list.sig"));
+        long contentId = prepareContentDownload("http://test-content.org");
+
+        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(contentId)).isFalse();
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(metadataId));
 
@@ -135,162 +204,270 @@
 
     @Test
     public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
-        long metadataId = 123;
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        String contentUrl = "http://test-content.org";
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
+        long metadataId =
+                prepareFailedMetadataDownload(
+                        // Failure cases where we give up on the download.
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        DownloadManager.ERROR_HTTP_DATA_ERROR);
         Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
-        // In all these failure cases we give up on the download.
-        when(mDownloadHelper.getDownloadStatus(metadataId))
-                .thenReturn(
-                        makeHttpErrorDownloadStatus(metadataId),
-                        makeStorageErrorDownloadStatus(metadataId));
+        long contentId = prepareContentDownload("http://test-content.org");
 
+        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(contentId)).isFalse();
         mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
         mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
 
-        verify(mDownloadHelper, never()).startDownload(contentUrl);
+        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(contentId)).isFalse();
+        verify(mDownloadHelper, never()).startDownload(anyString());
     }
 
     @Test
     public void testDownloader_contentDownloadSuccess_installSuccess_updateDataStore()
             throws Exception {
-        String version = "456";
-        long contentId = 666;
-        File logListFile = File.createTempFile("log_list", "json");
-        Uri contentUri = Uri.fromFile(logListFile);
-        long metadataId = 123;
+        String newVersion = "456";
+        File logListFile = makeLogListFile(newVersion);
         File metadataFile = sign(logListFile);
-        Uri metadataUri = Uri.fromFile(metadataFile);
-        mCertificateTransparencyDownloader.setPublicKey(
-                Base64.getEncoder().encodeToString(mPublicKey.getEncoded()));
-        setUpContentDownloadCompleteSuccessful(
-                version, metadataId, metadataUri, contentId, contentUri);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        prepareSuccessfulMetadataDownload(metadataFile);
+        long contentId = prepareSuccessfulContentDownload(logListFile);
         when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
+                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
                 .thenReturn(true);
 
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+        assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
-        verify(mCertificateTransparencyInstaller, times(1))
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), eq(version));
-        assertThat(mDataStore.getProperty(Config.VERSION)).isEqualTo(version);
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isEqualTo(contentUri.toString());
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isEqualTo(metadataUri.toString());
+        assertInstallSuccessful(newVersion);
     }
 
     @Test
     public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
-        mDataStore.setProperty(Config.VERSION_PENDING, "123");
-        long contentId = 666;
+        long contentId =
+                prepareFailedContentDownload(
+                        // Failure cases where we give up on the download.
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        DownloadManager.ERROR_HTTP_DATA_ERROR);
         Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
-        // In all these failure cases we give up on the download.
-        when(mDownloadHelper.getDownloadStatus(contentId))
-                .thenReturn(
-                        makeHttpErrorDownloadStatus(contentId),
-                        makeStorageErrorDownloadStatus(contentId));
 
         mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
         mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
 
         verify(mCertificateTransparencyInstaller, never()).install(any(), any(), any());
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+        assertNoVersionIsInstalled();
     }
 
     @Test
     public void testDownloader_contentDownloadSuccess_installFail_doNotUpdateDataStore()
             throws Exception {
-        String version = "456";
-        long contentId = 666;
-        File logListFile = File.createTempFile("log_list", "json");
-        Uri contentUri = Uri.fromFile(logListFile);
-        long metadataId = 123;
+        File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
-        Uri metadataUri = Uri.fromFile(metadataFile);
-        setUpContentDownloadCompleteSuccessful(
-                version, metadataId, metadataUri, contentId, contentUri);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        prepareSuccessfulMetadataDownload(metadataFile);
+        long contentId = prepareSuccessfulContentDownload(logListFile);
         when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
+                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
                 .thenReturn(false);
 
+        assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+        assertNoVersionIsInstalled();
     }
 
     @Test
     public void testDownloader_contentDownloadSuccess_verificationFail_doNotInstall()
-            throws IOException {
-        String version = "456";
-        long contentId = 666;
-        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
-        long metadataId = 123;
-        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-wrong_metadata", "sig"));
-        setUpContentDownloadCompleteSuccessful(
-                version, metadataId, metadataUri, contentId, contentUri);
+            throws Exception {
+        File logListFile = makeLogListFile("456");
+        File metadataFile = File.createTempFile("log_list-wrong_metadata", "sig");
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        prepareSuccessfulMetadataDownload(metadataFile);
+        long contentId = prepareSuccessfulContentDownload(logListFile);
 
+        assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
         verify(mCertificateTransparencyInstaller, never())
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), eq(version));
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+                .install(eq(Config.COMPATIBILITY_VERSION), any(), anyString());
+        assertNoVersionIsInstalled();
     }
 
     @Test
     public void testDownloader_contentDownloadSuccess_missingVerificationPublicKey_doNotInstall()
             throws Exception {
-        String version = "456";
-        long contentId = 666;
-        File logListFile = File.createTempFile("log_list", "json");
-        Uri contentUri = Uri.fromFile(logListFile);
-        long metadataId = 123;
+        File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
-        Uri metadataUri = Uri.fromFile(metadataFile);
-        setUpContentDownloadCompleteSuccessful(
-                version, metadataId, metadataUri, contentId, contentUri);
+        mSignatureVerifier.resetPublicKey();
+        prepareSuccessfulMetadataDownload(metadataFile);
+        long contentId = prepareSuccessfulContentDownload(logListFile);
 
+        assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
         verify(mCertificateTransparencyInstaller, never())
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), eq(version));
+                .install(eq(Config.COMPATIBILITY_VERSION), any(), anyString());
+        assertNoVersionIsInstalled();
+    }
+
+    @Test
+    public void testDownloader_endToEndSuccess_installNewVersion() throws Exception {
+        String newVersion = "456";
+        File logListFile = makeLogListFile(newVersion);
+        File metadataFile = sign(logListFile);
+        File publicKeyFile = writePublicKeyToFile(mPublicKey);
+
+        assertNoVersionIsInstalled();
+
+        // 1. Start download of public key.
+        String publicKeyUrl = "http://test-public-key.org";
+        long publicKeyId = preparePublicKeyDownload(publicKeyUrl);
+
+        mCertificateTransparencyDownloader.startPublicKeyDownload(publicKeyUrl);
+
+        // 2. On successful public key download, set the key and start the metatadata download.
+        setSuccessfulDownload(publicKeyId, publicKeyFile);
+        long metadataId = prepareMetadataDownload("http://test-metadata.org");
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(publicKeyId));
+
+        // 3. On successful metadata download, start the content download.
+        setSuccessfulDownload(metadataId, metadataFile);
+        long contentId = prepareContentDownload("http://test-content.org");
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(metadataId));
+
+        // 4. On successful content download, verify the signature and install the new version.
+        setSuccessfulDownload(contentId, logListFile);
+        when(mCertificateTransparencyInstaller.install(
+                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
+                .thenReturn(true);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(contentId));
+
+        assertInstallSuccessful(newVersion);
+    }
+
+    private void assertNoVersionIsInstalled() {
         assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
         assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
         assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
     }
 
+    private void assertInstallSuccessful(String version) {
+        assertThat(mDataStore.getProperty(Config.VERSION)).isEqualTo(version);
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL))
+                .isEqualTo(mDataStore.getProperty(Config.CONTENT_URL_PENDING));
+        assertThat(mDataStore.getProperty(Config.METADATA_URL))
+                .isEqualTo(mDataStore.getProperty(Config.METADATA_URL_PENDING));
+    }
+
     private Intent makeDownloadCompleteIntent(long downloadId) {
         return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
                 .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
 
-    private void setUpContentDownloadCompleteSuccessful(
-            String version, long metadataId, Uri metadataUri, long contentId, Uri contentUri)
-            throws IOException {
-        mDataStore.setProperty(Config.VERSION_PENDING, version);
+    private long prepareDownloadId(String url) {
+        long downloadId = mNextDownloadId++;
+        when(mDownloadHelper.startDownload(url)).thenReturn(downloadId);
+        return downloadId;
+    }
 
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
-        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
+    private long preparePublicKeyDownload(String url) {
+        long downloadId = prepareDownloadId(url);
+        mDataStore.setProperty(Config.PUBLIC_KEY_URL_PENDING, url);
+        return downloadId;
+    }
 
-        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.getDownloadStatus(contentId))
-                .thenReturn(makeSuccessfulDownloadStatus(contentId));
-        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+    private long prepareMetadataDownload(String url) {
+        long downloadId = prepareDownloadId(url);
+        mDataStore.setProperty(Config.METADATA_URL_PENDING, url);
+        return downloadId;
+    }
+
+    private long prepareContentDownload(String url) {
+        long downloadId = prepareDownloadId(url);
+        mDataStore.setProperty(Config.CONTENT_URL_PENDING, url);
+        return downloadId;
+    }
+
+    private long prepareSuccessfulDownload(String propertyKey) {
+        long downloadId = mNextDownloadId++;
+        mDataStore.setPropertyLong(propertyKey, downloadId);
+        when(mDownloadHelper.getDownloadStatus(downloadId))
+                .thenReturn(makeSuccessfulDownloadStatus(downloadId));
+        return downloadId;
+    }
+
+    private long prepareSuccessfulDownload(String propertyKey, File file) {
+        long downloadId = prepareSuccessfulDownload(propertyKey);
+        when(mDownloadHelper.getUri(downloadId)).thenReturn(Uri.fromFile(file));
+        return downloadId;
+    }
+
+    private long prepareSuccessfulPublicKeyDownload(File file) {
+        long downloadId = prepareSuccessfulDownload(Config.PUBLIC_KEY_URL_KEY, file);
+        mDataStore.setProperty(
+                Config.METADATA_URL_PENDING, "http://public-key-was-downloaded-here.org");
+        return downloadId;
+    }
+
+    private long prepareSuccessfulMetadataDownload(File file) {
+        long downloadId = prepareSuccessfulDownload(Config.METADATA_URL_KEY, file);
+        mDataStore.setProperty(
+                Config.METADATA_URL_PENDING, "http://metadata-was-downloaded-here.org");
+        return downloadId;
+    }
+
+    private long prepareSuccessfulContentDownload(File file) {
+        long downloadId = prepareSuccessfulDownload(Config.CONTENT_URL_KEY, file);
+        mDataStore.setProperty(
+                Config.CONTENT_URL_PENDING, "http://content-was-downloaded-here.org");
+        return downloadId;
+    }
+
+    private void setSuccessfulDownload(long downloadId, File file) {
+        when(mDownloadHelper.getDownloadStatus(downloadId))
+                .thenReturn(makeSuccessfulDownloadStatus(downloadId));
+        when(mDownloadHelper.getUri(downloadId)).thenReturn(Uri.fromFile(file));
+    }
+
+    private long prepareFailedDownload(String propertyKey, int... downloadManagerErrors) {
+        long downloadId = mNextDownloadId++;
+        mDataStore.setPropertyLong(propertyKey, downloadId);
+        DownloadStatus firstError =
+                DownloadStatus.builder()
+                        .setDownloadId(downloadId)
+                        .setStatus(DownloadManager.STATUS_FAILED)
+                        .setReason(downloadManagerErrors[0])
+                        .build();
+        DownloadStatus[] otherErrors = new DownloadStatus[downloadManagerErrors.length - 1];
+        for (int i = 1; i < downloadManagerErrors.length; i++) {
+            otherErrors[i - 1] =
+                    DownloadStatus.builder()
+                            .setDownloadId(downloadId)
+                            .setStatus(DownloadManager.STATUS_FAILED)
+                            .setReason(downloadManagerErrors[i])
+                            .build();
+        }
+        when(mDownloadHelper.getDownloadStatus(downloadId)).thenReturn(firstError, otherErrors);
+        return downloadId;
+    }
+
+    private long prepareFailedPublicKeyDownload(int... downloadManagerErrors) {
+        return prepareFailedDownload(Config.PUBLIC_KEY_URL_KEY, downloadManagerErrors);
+    }
+
+    private long prepareFailedMetadataDownload(int... downloadManagerErrors) {
+        return prepareFailedDownload(Config.METADATA_URL_KEY, downloadManagerErrors);
+    }
+
+    private long prepareFailedContentDownload(int... downloadManagerErrors) {
+        return prepareFailedDownload(Config.CONTENT_URL_KEY, downloadManagerErrors);
     }
 
     private DownloadStatus makeSuccessfulDownloadStatus(long downloadId) {
@@ -300,20 +477,29 @@
                 .build();
     }
 
-    private DownloadStatus makeStorageErrorDownloadStatus(long downloadId) {
-        return DownloadStatus.builder()
-                .setDownloadId(downloadId)
-                .setStatus(DownloadManager.STATUS_FAILED)
-                .setReason(DownloadManager.ERROR_INSUFFICIENT_SPACE)
-                .build();
+    private File writePublicKeyToFile(PublicKey publicKey)
+            throws IOException, GeneralSecurityException {
+        return writeToFile(Base64.getEncoder().encode(publicKey.getEncoded()));
     }
 
-    private DownloadStatus makeHttpErrorDownloadStatus(long downloadId) {
-        return DownloadStatus.builder()
-                .setDownloadId(downloadId)
-                .setStatus(DownloadManager.STATUS_FAILED)
-                .setReason(DownloadManager.ERROR_HTTP_DATA_ERROR)
-                .build();
+    private File writeToFile(byte[] bytes) throws IOException, GeneralSecurityException {
+        File file = File.createTempFile("temp_file", "tmp");
+
+        try (OutputStream outputStream = new FileOutputStream(file)) {
+            outputStream.write(bytes);
+        }
+
+        return file;
+    }
+
+    private File makeLogListFile(String version) throws IOException, JSONException {
+        File logListFile = File.createTempFile("log_list", "json");
+
+        try (OutputStream outputStream = new FileOutputStream(logListFile)) {
+            outputStream.write(new JSONObject().put("version", version).toString().getBytes(UTF_8));
+        }
+
+        return logListFile;
     }
 
     private File sign(File file) throws IOException, GeneralSecurityException {
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 67d0891..5228aab 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -139,8 +139,8 @@
     private int mTetheringInterfaceMode = INTERFACE_MODE_CLIENT;
     // Tracks whether clients were notified that the tethered interface is available
     private boolean mTetheredInterfaceWasAvailable = false;
-
-    private int mEthernetState = ETHERNET_STATE_ENABLED;
+    // Tracks the current state of ethernet as configured by EthernetManager#setEthernetEnabled.
+    private boolean mIsEthernetEnabled = true;
 
     private class TetheredInterfaceRequestList extends
             RemoteCallbackList<ITetheredInterfaceCallback> {
@@ -215,9 +215,6 @@
         // Note: processNetlinkMessage is called on the handler thread.
         @Override
         protected void processNetlinkMessage(NetlinkMessage nlMsg, long whenMs) {
-            // ignore all updates when ethernet is disabled.
-            if (mEthernetState == ETHERNET_STATE_DISABLED) return;
-
             if (nlMsg instanceof RtNetlinkLinkMessage) {
                 processRtNetlinkLinkMessage((RtNetlinkLinkMessage) nlMsg);
             } else {
@@ -447,7 +444,7 @@
                 unicastInterfaceStateChange(listener, mTetheringInterface);
             }
 
-            unicastEthernetStateChange(listener, mEthernetState);
+            unicastEthernetStateChange(listener, mIsEthernetEnabled);
         });
     }
 
@@ -596,10 +593,13 @@
             // Read the flags before attempting to bring up the interface. If the interface is
             // already running an UP event is created after adding the interface.
             config = NetdUtils.getInterfaceConfigParcel(mNetd, iface);
-            if (NetdUtils.hasFlag(config, INetd.IF_STATE_DOWN)) {
+            // Only bring the interface up when ethernet is enabled.
+            if (mIsEthernetEnabled) {
                 // As a side-effect, NetdUtils#setInterfaceUp() also clears the interface's IPv4
                 // address and readds it which *could* lead to unexpected behavior in the future.
                 NetdUtils.setInterfaceUp(mNetd, iface);
+            } else {
+                NetdUtils.setInterfaceDown(mNetd, iface);
             }
         } catch (IllegalStateException e) {
             // Either the system is crashing or the interface has disappeared. Just ignore the
@@ -646,6 +646,10 @@
     }
 
     private void setInterfaceAdministrativeState(String iface, boolean up, EthernetCallback cb) {
+        if (!mIsEthernetEnabled) {
+            cb.onError("Cannot enable/disable interface when ethernet is disabled");
+            return;
+        }
         if (getInterfaceState(iface) == EthernetManager.STATE_ABSENT) {
             cb.onError("Failed to enable/disable absent interface: " + iface);
             return;
@@ -960,43 +964,51 @@
     @VisibleForTesting(visibility = PACKAGE)
     protected void setEthernetEnabled(boolean enabled) {
         mHandler.post(() -> {
-            int newState = enabled ? ETHERNET_STATE_ENABLED : ETHERNET_STATE_DISABLED;
-            if (mEthernetState == newState) return;
+            if (mIsEthernetEnabled == enabled) return;
 
-            mEthernetState = newState;
+            mIsEthernetEnabled = enabled;
 
-            if (enabled) {
-                trackAvailableInterfaces();
-            } else {
-                // TODO: maybe also disable server mode interface as well.
-                untrackFactoryInterfaces();
+            // Interface in server mode should also be included.
+            ArrayList<String> interfaces =
+                    new ArrayList<>(
+                    List.of(mFactory.getAvailableInterfaces(/* includeRestricted */ true)));
+
+            if (mTetheringInterfaceMode == INTERFACE_MODE_SERVER) {
+                interfaces.add(mTetheringInterface);
             }
-            broadcastEthernetStateChange(mEthernetState);
+
+            for (String iface : interfaces) {
+                if (enabled) {
+                    NetdUtils.setInterfaceUp(mNetd, iface);
+                } else {
+                    NetdUtils.setInterfaceDown(mNetd, iface);
+                }
+            }
+            broadcastEthernetStateChange(mIsEthernetEnabled);
         });
     }
 
-    private void untrackFactoryInterfaces() {
-        for (String iface : mFactory.getAvailableInterfaces(true /* includeRestricted */)) {
-            stopTrackingInterface(iface);
-        }
+    private int isEthernetEnabledAsInt(boolean state) {
+        return state ? ETHERNET_STATE_ENABLED : ETHERNET_STATE_DISABLED;
     }
 
     private void unicastEthernetStateChange(@NonNull IEthernetServiceListener listener,
-            int state) {
+            boolean enabled) {
         ensureRunningOnEthernetServiceThread();
         try {
-            listener.onEthernetStateChanged(state);
+            listener.onEthernetStateChanged(isEthernetEnabledAsInt(enabled));
         } catch (RemoteException e) {
             // Do nothing here.
         }
     }
 
-    private void broadcastEthernetStateChange(int state) {
+    private void broadcastEthernetStateChange(boolean enabled) {
         ensureRunningOnEthernetServiceThread();
         final int n = mListeners.beginBroadcast();
         for (int i = 0; i < n; i++) {
             try {
-                mListeners.getBroadcastItem(i).onEthernetStateChanged(state);
+                mListeners.getBroadcastItem(i)
+                            .onEthernetStateChanged(isEthernetEnabledAsInt(enabled));
             } catch (RemoteException e) {
                 // Do nothing here.
             }
@@ -1008,7 +1020,7 @@
         postAndWaitForRunnable(() -> {
             pw.println(getClass().getSimpleName());
             pw.println("Ethernet State: "
-                    + (mEthernetState == ETHERNET_STATE_ENABLED ? "enabled" : "disabled"));
+                    + (mIsEthernetEnabled ? "enabled" : "disabled"));
             pw.println("Ethernet interface name filter: " + mIfaceMatch);
             pw.println("Interface used for tethering: " + mTetheringInterface);
             pw.println("Tethering interface mode: " + mTetheringInterfaceMode);
diff --git a/service/Android.bp b/service/Android.bp
index 567c079..fd3d4a3 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -90,7 +90,6 @@
     static_libs: [
         "libnet_utils_device_common_bpfjni",
         "libnet_utils_device_common_bpfutils",
-        "libnet_utils_device_common_timerfdjni",
     ],
     shared_libs: [
         "liblog",
@@ -126,6 +125,7 @@
         "libmodules-utils-build",
         "libnetjniutils",
         "libnet_utils_device_common_bpfjni",
+        "libnet_utils_device_common_timerfdjni",
         "netd_aidl_interface-lateststable-ndk",
     ],
     shared_libs: [
diff --git a/service/ServiceConnectivityResources/res/values-sw/strings.xml b/service/ServiceConnectivityResources/res/values-sw/strings.xml
index 29ec013..9ff9ada 100644
--- a/service/ServiceConnectivityResources/res/values-sw/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-sw/strings.xml
@@ -25,7 +25,7 @@
     <string name="mobile_network_available_no_internet" msgid="1000871587359324217">"Hakuna intaneti"</string>
     <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"Huenda data ya <xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> imeisha. Gusa ili upate chaguo."</string>
     <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"Huenda data yako imeisha. Gusa ili upate chaguo."</string>
-    <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> haina uwezo wa kufikia intaneti"</string>
+    <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> haina intaneti"</string>
     <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"Gusa ili upate chaguo"</string>
     <string name="mobile_no_internet" msgid="4087718456753201450">"Mtandao wa simu hauna uwezo wa kufikia intaneti"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"Mtandao hauna uwezo wa kufikia intaneti"</string>
diff --git a/service/jni/onload.cpp b/service/jni/onload.cpp
index bb70d4f..8e01260 100644
--- a/service/jni/onload.cpp
+++ b/service/jni/onload.cpp
@@ -26,6 +26,8 @@
 int register_android_server_net_NetworkStatsFactory(JNIEnv* env);
 int register_android_server_net_NetworkStatsService(JNIEnv* env);
 int register_com_android_server_ServiceManagerWrapper(JNIEnv* env);
+int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+                                                      char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
     JNIEnv *env;
@@ -56,6 +58,12 @@
         }
     }
 
+    if (register_com_android_net_module_util_TimerFdUtils(
+            env, "android/net/connectivity/com/android/net/module/util/"
+                 "TimerFdUtils") < 0) {
+      return JNI_ERR;
+    }
+
     return JNI_VERSION_1_6;
 }
 
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index f484027..b4a3b8a 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -32,6 +32,8 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+// This library shouldn't be used anymore (no class should be added), and per-user libraries like
+// net-utils-service-connectivity or net-utils-framework-wifi should be used instead.
 java_library {
     name: "net-utils-device-common",
     srcs: [
@@ -725,3 +727,34 @@
     ],
     apex_available: ["com.android.wifi"],
 }
+
+genrule {
+    name: "statslog-framework-connectivity-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module connectivity --javaPackage com.android.net.module.util --javaClass FrameworkConnectivityStatsLog",
+    out: ["com/android/net/module/util/FrameworkConnectivityStatsLog.java"],
+}
+
+java_library {
+    name: "net-utils-service-vcn",
+    sdk_version: "module_current",
+    min_sdk_version: "30",
+    srcs: [
+        "device/com/android/net/module/util/HandlerUtils.java",
+    ],
+    libs: [
+        "framework-annotations-lib",
+    ],
+    visibility: [
+        // TODO: b/374174952 Remove it when VCN modularization is released
+        "//frameworks/base/packages/Vcn/service-b",
+
+        "//packages/modules/Connectivity/service-b",
+    ],
+    apex_available: [
+        // TODO: b/374174952 Remove it when VCN modularization is released
+        "//apex_available:platform",
+
+        "com.android.tethering",
+    ],
+}
diff --git a/staticlibs/device/com/android/net/module/util/TimerFdUtils.java b/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
new file mode 100644
index 0000000..310dbc9
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
@@ -0,0 +1,79 @@
+/*
+ * 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.Process;
+import android.util.Log;
+
+import java.io.IOException;
+
+/**
+ * Contains mostly timerfd functionality.
+ */
+public class TimerFdUtils {
+    static {
+        if (Process.myUid() == Process.SYSTEM_UID) {
+            // This library is part of service-connectivity.jar when in the system server,
+            // so libservice-connectivity.so is the library to load.
+            System.loadLibrary("service-connectivity");
+        } else {
+            System.loadLibrary(JniUtil.getJniLibraryName(TimerFdUtils.class.getPackage()));
+        }
+    }
+
+    private static final String TAG = TimerFdUtils.class.getSimpleName();
+
+    /**
+     * Create a timerfd.
+     *
+     * @throws IOException if the timerfd creation is failed.
+     */
+    private static native int createTimerFd() throws IOException;
+
+    /**
+     * Set given time to the timerfd.
+     *
+     * @param timeMs target time
+     * @throws IOException if setting expiration time is failed.
+     */
+    private static native void setTime(int fd, long timeMs) throws IOException;
+
+    /**
+     * Create a timerfd
+     */
+    static int createTimerFileDescriptor() {
+        try {
+            return createTimerFd();
+        } catch (IOException e) {
+            Log.e(TAG, "createTimerFd failed", e);
+            return -1;
+        }
+    }
+
+    /**
+     * Set expiration time to timerfd
+     */
+    static boolean setExpirationTime(int id, long expirationTimeMs) {
+        try {
+            setTime(id, expirationTimeMs);
+        } catch (IOException e) {
+            Log.e(TAG, "setExpirationTime failed", e);
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/staticlibs/framework/com/android/net/module/util/DnsPacket.java b/staticlibs/framework/com/android/net/module/util/DnsPacket.java
index 63106a1..b0c5e2e 100644
--- a/staticlibs/framework/com/android/net/module/util/DnsPacket.java
+++ b/staticlibs/framework/com/android/net/module/util/DnsPacket.java
@@ -50,7 +50,7 @@
  *
  * @hide
  */
-public abstract class DnsPacket {
+public class DnsPacket {
     /**
      * Type of the canonical name for an alias. Refer to RFC 1035 section 3.2.2.
      */
@@ -515,7 +515,14 @@
     protected final DnsHeader mHeader;
     protected final List<DnsRecord>[] mRecords;
 
-    protected DnsPacket(@NonNull byte[] data) throws ParseException {
+    /**
+     * Returns the list of DNS records for a given section.
+     */
+    public List<DnsRecord> getRecords(@RecordType int section) {
+        return mRecords[section];
+    }
+
+    public DnsPacket(@NonNull byte[] data) throws ParseException {
         if (null == data) {
             throw new ParseException("Parse header failed, null input data");
         }
@@ -548,7 +555,7 @@
      *
      * Note that authority records section and additional records section is not supported.
      */
-    protected DnsPacket(@NonNull DnsHeader header, @NonNull List<DnsRecord> qd,
+    public DnsPacket(@NonNull DnsHeader header, @NonNull List<DnsRecord> qd,
             @NonNull List<DnsRecord> an) {
         mHeader = Objects.requireNonNull(header);
         mRecords = new List[NUM_SECTIONS];
diff --git a/staticlibs/native/bpfmapjni/Android.bp b/staticlibs/native/bpfmapjni/Android.bp
index 969ebd4..9a58a93 100644
--- a/staticlibs/native/bpfmapjni/Android.bp
+++ b/staticlibs/native/bpfmapjni/Android.bp
@@ -26,6 +26,7 @@
     header_libs: [
         "bpf_headers",
         "jni_headers",
+        "libbase_headers",
     ],
     shared_libs: [
         "liblog",
diff --git a/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp b/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp
index 1923ceb..d862f6b 100644
--- a/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp
+++ b/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp
@@ -24,37 +24,38 @@
 #include "nativehelper/scoped_primitive_array.h"
 #include "nativehelper/scoped_utf_chars.h"
 
-#define BPF_FD_JUST_USE_INT
+#include <android-base/unique_fd.h>
 #include "BpfSyscallWrappers.h"
-
 #include "bpf/KernelUtils.h"
 
 namespace android {
 
+using ::android::base::unique_fd;
+
 static jint com_android_net_module_util_BpfMap_nativeBpfFdGet(JNIEnv *env, jclass clazz,
         jstring path, jint mode, jint keySize, jint valueSize) {
     ScopedUtfChars pathname(env, path);
 
-    jint fd = -1;
+    unique_fd fd;
     switch (mode) {
       case 0:
-        fd = bpf::mapRetrieveRW(pathname.c_str());
+        fd.reset(bpf::mapRetrieveRW(pathname.c_str()));
         break;
       case BPF_F_RDONLY:
-        fd = bpf::mapRetrieveRO(pathname.c_str());
+        fd.reset(bpf::mapRetrieveRO(pathname.c_str()));
         break;
       case BPF_F_WRONLY:
-        fd = bpf::mapRetrieveWO(pathname.c_str());
+        fd.reset(bpf::mapRetrieveWO(pathname.c_str()));
         break;
       case BPF_F_RDONLY|BPF_F_WRONLY:
-        fd = bpf::mapRetrieveExclusiveRW(pathname.c_str());
+        fd.reset(bpf::mapRetrieveExclusiveRW(pathname.c_str()));
         break;
       default:
         errno = EINVAL;
         break;
     }
 
-    if (fd < 0) {
+    if (!fd.ok()) {
         jniThrowErrnoException(env, "nativeBpfFdGet", errno);
         return -1;
     }
@@ -62,18 +63,16 @@
     if (bpf::isAtLeastKernelVersion(4, 14, 0)) {
         // These likely fail with -1 and set errno to EINVAL on <4.14
         if (bpf::bpfGetFdKeySize(fd) != keySize) {
-            close(fd);
             jniThrowErrnoException(env, "nativeBpfFdGet KeySize", EBADFD);
             return -1;
         }
         if (bpf::bpfGetFdValueSize(fd) != valueSize) {
-            close(fd);
             jniThrowErrnoException(env, "nativeBpfFdGet ValueSize", EBADFD);
             return -1;
         }
     }
 
-    return fd;
+    return fd.release();
 }
 
 static void com_android_net_module_util_BpfMap_nativeWriteToMapEntry(JNIEnv *env, jobject self,
diff --git a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
index 46e511e..78b34a8 100644
--- a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
@@ -123,7 +123,13 @@
             """telephony/com\.android\.internal\.telephony\.flags\.force_iwlan_mms:""" +
                     """.*ENABLED \(system\)""")
         ParcelFileDescriptor.AutoCloseInputStream(
-            uiAutomation.executeShellCommand("printflags")).bufferedReader().use { reader ->
+            // If the command fails (for example if printflags is missing) this will return false
+            // and the IWLAN disable will be skipped, which should be fine at it only helps with
+            // flakiness.
+            // This uses "sh -c" to cover that case as if "printflags" is used directly and the
+            // binary is missing, the remote end will crash and the InputStream EOF is never
+            // reached, so the read would hang.
+            uiAutomation.executeShellCommand("sh -c printflags")).bufferedReader().use { reader ->
                 return reader.lines().anyMatch {
                     it.contains(flagEnabledRegex)
                 }
diff --git a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
index e634f0e..8e27c62 100644
--- a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
@@ -16,27 +16,167 @@
 
 package com.android.testutils.connectivitypreparer
 
+import android.Manifest.permission.NETWORK_SETTINGS
 import android.content.pm.PackageManager.FEATURE_TELEPHONY
 import android.content.pm.PackageManager.FEATURE_WIFI
+import android.net.LinkAddress
+import android.net.Network
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.wifi.WifiInfo
 import android.telephony.TelephonyManager
+import android.util.Log
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.net.module.util.HexDump
+import com.android.net.module.util.NetworkStackConstants.IPV4_ADDR_ANY
+import com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_ANY
+import com.android.testutils.AutoReleaseNetworkCallbackRule
 import com.android.testutils.ConnectUtil
+import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.runAsShell
+import com.android.testutils.tryTest
+import java.io.IOException
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.util.Random
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
+private const val QUIC_SOCKET_TIMEOUT_MS = 5_000
+private const val QUIC_RETRY_COUNT = 5
+
 @RunWith(AndroidJUnit4::class)
 class ConnectivityCheckTest {
+    @get:Rule
+    val networkCallbackRule = AutoReleaseNetworkCallbackRule()
+
+    private val logTag = ConnectivityCheckTest::class.simpleName
     private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
     private val pm by lazy { context.packageManager }
     private val connectUtil by lazy { ConnectUtil(context) }
 
+    // Skip IPv6 checks on virtual devices which do not support it. Tests that require IPv6 will
+    // still fail even if the preparer does not.
+    private fun ipv6Unsupported(wifiSsid: String?) = ConnectUtil.VIRTUAL_SSIDS.contains(
+        WifiInfo.sanitizeSsid(wifiSsid))
+
     @Test
     fun testCheckWifiSetup() {
         if (!pm.hasSystemFeature(FEATURE_WIFI)) return
         connectUtil.ensureWifiValidated()
+
+        val (wifiNetwork, wifiSsid) = runAsShell(NETWORK_SETTINGS) {
+            val cb = networkCallbackRule.requestNetwork(
+                NetworkRequest.Builder()
+                    .addTransportType(TRANSPORT_WIFI)
+                    .addCapability(NET_CAPABILITY_INTERNET)
+                    .build()
+            )
+            val capChanged = cb.eventuallyExpect<CapabilitiesChanged>(from = 0)
+            val network = capChanged.network
+            val ssid = capChanged.caps.ssid
+            assertFalse(ssid.isNullOrEmpty(), "No SSID for wifi network $network")
+            // Expect a global IPv6 address, and native or stacked IPv4
+            val lpChange = cb.history.poll(
+                pos = 0,
+                timeoutMs = 30_000L
+            ) {
+                it is LinkPropertiesChanged &&
+                it.network == network &&
+                it.lp.allLinkAddresses.any(LinkAddress::isIpv4) &&
+                        (ipv6Unsupported(ssid) || it.lp.hasGlobalIpv6Address())
+            }
+            assertNotNull(lpChange, "Wifi network $network needs an IPv4 address" +
+                    if (ipv6Unsupported(ssid)) "" else " and a global IPv6 address")
+
+            Pair(network, ssid)
+        }
+
+        // Checking QUIC is more important on Wi-Fi than cellular, as it finds firewall
+        // configuration problems on Wi-Fi, but cellular is not actionable by the test lab.
+        checkQuic(wifiNetwork, wifiSsid, ipv6 = false)
+        if (!ipv6Unsupported(wifiSsid)) {
+            checkQuic(wifiNetwork, wifiSsid, ipv6 = true)
+        }
+    }
+
+    /**
+     * Check that QUIC is working on the specified network.
+     *
+     * Some tests require QUIC (UDP), and some lab networks have been observed to not let it
+     * through due to firewalling. Ensure that devices are setup on a network that has the proper
+     * allowlists before trying to run the tests.
+     */
+    private fun checkQuic(network: Network, ssid: String, ipv6: Boolean) {
+        // Same endpoint as used in MultinetworkApiTest in CTS
+        val hostname = "connectivitycheck.android.com"
+        val targetAddrs = network.getAllByName(hostname)
+        val bindAddr = if (ipv6) IPV6_ADDR_ANY else IPV4_ADDR_ANY
+        if (targetAddrs.isEmpty()) {
+            Log.d(logTag, "No addresses found for $hostname")
+            return
+        }
+
+        val socket = DatagramSocket(0, bindAddr)
+        tryTest {
+            socket.soTimeout = QUIC_SOCKET_TIMEOUT_MS
+            network.bindSocket(socket)
+
+            // For reference see Version-Independent Properties of QUIC:
+            // https://datatracker.ietf.org/doc/html/rfc8999
+            // This packet just contains a long header with an unsupported version number, to force
+            // a version-negotiation packet in response.
+            val connectionId = ByteArray(8).apply { Random().nextBytes(this) }
+            val quicData = byteArrayOf(
+                // long header
+                0xc0.toByte(),
+                // version number (should be an unknown version for the server)
+                0xaa.toByte(), 0xda.toByte(), 0xca.toByte(), 0xca.toByte(),
+                // destination connection ID length
+                0x08,
+            ) + connectionId + byteArrayOf(
+                // source connection ID length
+                0x00,
+            ) + ByteArray(1185) // Ensure the packet is 1200 bytes long
+            val targetAddr = targetAddrs.firstOrNull { it.javaClass == bindAddr.javaClass }
+                ?: fail("No ${bindAddr.javaClass} found for $hostname " +
+                        "(got ${targetAddrs.joinToString()})")
+            repeat(QUIC_RETRY_COUNT) { i ->
+                socket.send(DatagramPacket(quicData, quicData.size, targetAddr, 443))
+
+                val receivedPacket = DatagramPacket(ByteArray(1500), 1500)
+                try {
+                    socket.receive(receivedPacket)
+                } catch (e: IOException) {
+                    Log.d(logTag, "No response from $hostname ($targetAddr) on QUIC try $i", e)
+                    return@repeat
+                }
+
+                val receivedConnectionId = receivedPacket.data.copyOfRange(7, 7 + 8)
+                if (connectionId.contentEquals(receivedConnectionId)) {
+                    return@tryTest
+                } else {
+                    val headerBytes = receivedPacket.data.copyOfRange(
+                        0, receivedPacket.length.coerceAtMost(15))
+                    Log.d(logTag, "Received invalid connection ID on QUIC try $i: " +
+                            HexDump.toHexString(headerBytes))
+                }
+            }
+            fail("QUIC is not working on SSID $ssid connecting to $targetAddr " +
+                    "with local source port ${socket.localPort}: check the firewall for UDP port " +
+                    "443 access."
+            )
+        } cleanup {
+            socket.close()
+        }
     }
 
     @Test
@@ -53,12 +193,16 @@
         if (tm.simState == TelephonyManager.SIM_STATE_ABSENT) {
             fail("The device has no SIM card inserted. $commonError")
         } else if (tm.simState != TelephonyManager.SIM_STATE_READY) {
-            fail("The device is not setup with a usable SIM card. Sim state was ${tm.simState}. " +
-                    commonError)
+            fail(
+                "The device is not setup with a usable SIM card. Sim state was ${tm.simState}. " +
+                    commonError
+            )
         }
-        assertTrue(tm.isDataConnectivityPossible,
+        assertTrue(
+            tm.isDataConnectivityPossible,
             "The device has a SIM card, but it does not supports data connectivity. " +
-            "Check the data plan, and verify that mobile data is working. " + commonError)
+            "Check the data plan, and verify that mobile data is working. " + commonError
+        )
         connectUtil.ensureCellularValidated()
     }
 }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
index 3857810..d60ab59 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
@@ -53,6 +53,10 @@
 private const val WIFI_ERROR_BUSY = 2
 
 class ConnectUtil(private val context: Context) {
+    companion object {
+        @JvmStatic
+        val VIRTUAL_SSIDS = listOf("VirtWifi", "AndroidWifi")
+    }
     private val TAG = ConnectUtil::class.java.simpleName
 
     private val cm = context.getSystemService(ConnectivityManager::class.java)
@@ -207,9 +211,8 @@
      */
     private fun maybeConfigureVirtualNetwork(scanResults: List<ScanResult>): WifiConfiguration? {
         // Virtual wifi networks used on the emulator and cloud testing infrastructure
-        val virtualSsids = listOf("VirtWifi", "AndroidWifi")
         Log.d(TAG, "Wifi scan results: $scanResults")
-        val virtualScanResult = scanResults.firstOrNull { virtualSsids.contains(it.SSID) }
+        val virtualScanResult = scanResults.firstOrNull { VIRTUAL_SSIDS.contains(it.SSID) }
                 ?: return null
 
         // Only add the virtual configuration if the virtual AP is detected in scans
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
index ea86281..9e63910 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
@@ -76,11 +76,13 @@
         private const val MAX_DUMPS = 20
 
         private val TAG = ConnectivityDiagnosticsCollector::class.simpleName
+        @JvmStatic
         var instance: ConnectivityDiagnosticsCollector? = null
     }
 
     private var failureHeader: String? = null
     private val buffer = ByteArrayOutputStream()
+    private val failureHeaderExtras = mutableMapOf<String, Any>()
     private val collectorDir: File by lazy {
         createAndEmptyDirectory(COLLECTOR_DIR)
     }
@@ -218,6 +220,8 @@
         val canUseShell = !isAtLeastS() ||
                 instr.uiAutomation.getAdoptedShellPermissions().isNullOrEmpty()
         val headerObj = JSONObject()
+        failureHeaderExtras.forEach { (k, v) -> headerObj.put(k, v) }
+        failureHeaderExtras.clear()
         if (canUseShell) {
             runAsShell(READ_PRIVILEGED_PHONE_STATE, NETWORK_SETTINGS) {
                 headerObj.apply {
@@ -332,6 +336,15 @@
         }
     }
 
+    /**
+     * Add a key->value attribute to the failure data, to be written to the diagnostics file.
+     *
+     * <p>This is to be called by tests that know they will fail.
+     */
+    fun addFailureAttribute(key: String, value: Any) {
+        failureHeaderExtras[key] = value
+    }
+
     private fun maybeWriteExceptionContext(writer: PrintWriter, exceptionContext: Throwable?) {
         if (exceptionContext == null) return
         writer.println("At: ")
diff --git a/tests/common/Android.bp b/tests/common/Android.bp
index bb1009b..60a02fb 100644
--- a/tests/common/Android.bp
+++ b/tests/common/Android.bp
@@ -147,6 +147,7 @@
         // meaning @hide APIs in framework-connectivity are resolved before @SystemApi
         // stubs in framework
         "framework-connectivity.impl",
+        "framework-connectivity-b.impl",
         "framework-connectivity-t.impl",
         "framework-tethering.impl",
         "framework",
diff --git a/tests/cts/net/jni/NativeMultinetworkJni.cpp b/tests/cts/net/jni/NativeMultinetworkJni.cpp
index f2214a3..1d848ec 100644
--- a/tests/cts/net/jni/NativeMultinetworkJni.cpp
+++ b/tests/cts/net/jni/NativeMultinetworkJni.cpp
@@ -415,9 +415,17 @@
     strlcpy(dst, buf, size);
 }
 
+static jobject create_query_test_result(JNIEnv* env, uint16_t src_port, int attempts, int errnum) {
+    jclass clazz = env->FindClass(
+        "android/net/cts/MultinetworkApiTest$QueryTestResult");
+    jmethodID ctor = env->GetMethodID(clazz, "<init>", "(III)V");
+
+    return env->NewObject(clazz, ctor, src_port, attempts, errnum);
+}
+
 extern "C"
-JNIEXPORT jint Java_android_net_cts_MultinetworkApiTest_runDatagramCheck(
-        JNIEnv*, jclass, jlong nethandle) {
+JNIEXPORT jobject Java_android_net_cts_MultinetworkApiTest_runDatagramCheck(
+        JNIEnv* env, jclass, jlong nethandle, jint src_port) {
     const struct addrinfo kHints = {
         .ai_flags = AI_ADDRCONFIG,
         .ai_family = AF_UNSPEC,
@@ -433,7 +441,7 @@
         LOGD("android_getaddrinfofornetwork(%llu, %s) returned rval=%d errno=%d",
               handle, kHostname, rval, errno);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
 
     // Rely upon getaddrinfo sorting the best destination to the front.
@@ -442,7 +450,7 @@
         LOGD("socket(%d, %d, %d) failed, errno=%d",
               res->ai_family, res->ai_socktype, res->ai_protocol, errno);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
 
     rval = android_setsocknetwork(handle, fd);
@@ -451,7 +459,31 @@
     if (rval != 0) {
         close(fd);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
+    }
+
+    sockaddr_storage src_addr;
+    socklen_t src_addrlen = sizeof(src_addr);
+    if (src_port) {
+        if (res->ai_family == AF_INET6) {
+            *reinterpret_cast<sockaddr_in6*>(&src_addr) = (sockaddr_in6) {
+                .sin6_family = AF_INET6,
+                .sin6_port = htons(src_port),
+                .sin6_addr = in6addr_any,
+            };
+        } else {
+            *reinterpret_cast<sockaddr_in*>(&src_addr) = (sockaddr_in) {
+                .sin_family = AF_INET,
+                .sin_port = htons(src_port),
+                .sin_addr = { .s_addr = INADDR_ANY },
+            };
+        }
+        if (bind(fd, (sockaddr *)&src_addr, src_addrlen) != 0) {
+            LOGD("Error binding to port %d", src_port);
+            close(fd);
+            freeaddrinfo(res);
+            return create_query_test_result(env, 0, 0, errno);
+        }
     }
 
     char addrstr[kSockaddrStrLen+1];
@@ -462,19 +494,28 @@
     if (rval != 0) {
         close(fd);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
     freeaddrinfo(res);
 
-    struct sockaddr_storage src_addr;
-    socklen_t src_addrlen = sizeof(src_addr);
     if (getsockname(fd, (struct sockaddr *)&src_addr, &src_addrlen) != 0) {
         close(fd);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
     sockaddr_ntop((const struct sockaddr *)&src_addr, sizeof(src_addr), addrstr, sizeof(addrstr));
     LOGD("... from %s", addrstr);
 
+    uint16_t socket_src_port;
+    if (res->ai_family == AF_INET6) {
+        socket_src_port = ntohs(reinterpret_cast<sockaddr_in6*>(&src_addr)->sin6_port);
+    } else if (src_addr.ss_family == AF_INET) {
+        socket_src_port = ntohs(reinterpret_cast<sockaddr_in*>(&src_addr)->sin_port);
+    } else {
+        LOGD("Invalid source address family %d", src_addr.ss_family);
+        close(fd);
+        return create_query_test_result(env, 0, 0, EAFNOSUPPORT);
+    }
+
     // Don't let reads or writes block indefinitely.
     const struct timeval timeo = { 2, 0 };  // 2 seconds
     setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeo, sizeof(timeo));
@@ -503,7 +544,7 @@
             errnum = errno;
             LOGD("send(QUIC packet) returned sent=%zd, errno=%d", sent, errnum);
             close(fd);
-            return -errnum;
+            return create_query_test_result(env, socket_src_port, i + 1, errnum);
         }
 
         rcvd = recv(fd, response, sizeof(response), 0);
@@ -521,18 +562,19 @@
             LOGD("Does this network block UDP port %s?", kPort);
         }
         close(fd);
-        return -EPROTO;
+        return create_query_test_result(env, socket_src_port, i + 1,
+                rcvd <= 0 ? errnum : EPROTO);
     }
 
     int conn_id_cmp = memcmp(quic_packet + 6, response + 7, 8);
     if (conn_id_cmp != 0) {
         LOGD("sent and received connection IDs do not match");
         close(fd);
-        return -EPROTO;
+        return create_query_test_result(env, socket_src_port, i + 1, EPROTO);
     }
 
     // TODO: Replace this quick 'n' dirty test with proper QUIC-capable code.
 
     close(fd);
-    return 0;
+    return create_query_test_result(env, socket_src_port, i + 1, 0);
 }
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
index 9ac2c67..8e77b5d 100644
--- a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -19,6 +19,7 @@
 
 package android.net.cts
 
+import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
 import android.content.pm.PackageManager.FEATURE_WIFI
 import android.net.ConnectivityManager
@@ -165,7 +166,7 @@
             // created.
             // APF adb cmds are only implemented in ApfFilter.java. Enable experiment to prevent
             // LegacyApfFilter.java from being used.
-            runAsShell(WRITE_DEVICE_CONFIG) {
+            runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
                 DeviceConfig.setProperty(
                         NAMESPACE_CONNECTIVITY,
                         APF_NEW_RA_FILTER_VERSION,
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index 3a8252a..88309ed 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -145,6 +145,7 @@
 import android.net.ConnectivityManager;
 import android.net.ConnectivityManager.NetworkCallback;
 import android.net.ConnectivitySettingsManager;
+import android.net.DnsResolver;
 import android.net.InetAddresses;
 import android.net.IpSecManager;
 import android.net.IpSecManager.UdpEncapsulationSocket;
@@ -201,6 +202,7 @@
 import com.android.internal.util.ArrayUtils;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.DnsPacket;
 import com.android.networkstack.apishim.ConnectivityManagerShimImpl;
 import com.android.networkstack.apishim.ConstantsShim;
 import com.android.networkstack.apishim.NetworkInformationShimImpl;
@@ -249,7 +251,6 @@
 import java.net.Socket;
 import java.net.SocketException;
 import java.net.URL;
-import java.net.UnknownHostException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -306,6 +307,7 @@
     private static final int LISTEN_ACTIVITY_TIMEOUT_MS = 30_000;
     private static final int NO_CALLBACK_TIMEOUT_MS = 100;
     private static final int NETWORK_REQUEST_TIMEOUT_MS = 3000;
+    private static final int DNS_REQUEST_TIMEOUT_MS = 1000;
     private static final int SOCKET_TIMEOUT_MS = 100;
     private static final int NUM_TRIES_MULTIPATH_PREF_CHECK = 20;
     private static final long INTERVAL_MULTIPATH_PREF_CHECK_MS = 500;
@@ -861,6 +863,55 @@
         });
     }
 
+    @NonNull
+    private static String getDeviceIpv6AddressThroughDnsQuery(Network network) throws Exception {
+        final InetAddress dnsAddr = getAddrByName("ns1.google.com", AF_INET6);
+        assertNotNull("IPv6 address for ns1.google.com should not be null", dnsAddr);
+
+        try (DatagramSocket udpSocket = new DatagramSocket()) {
+            network.bindSocket(udpSocket);
+
+            final DnsPacket queryDnsPkt = new DnsPacket(
+                    new DnsPacket.DnsHeader(new Random().nextInt(), DnsResolver.FLAG_EMPTY,
+                            1 /* qdcount */,
+                            0 /* ancount */),
+                    List.of(DnsPacket.DnsRecord.makeQuestion("o-o.myaddr.l.google.com",
+                            DnsResolver.TYPE_TXT, DnsResolver.CLASS_IN)),
+                    List.of() /* an */
+            );
+            final byte[] queryDnsRawBytes = queryDnsPkt.getBytes();
+            final byte[] receiveBuffer = new byte[1500];
+            final int maxRetry = 3;
+            for (int attempt = 1; attempt <= maxRetry; ++attempt) {
+                try {
+                    final DatagramPacket queryUdpPkt = new DatagramPacket(queryDnsRawBytes,
+                            queryDnsRawBytes.length, dnsAddr, 53 /* port */);
+                    udpSocket.send(queryUdpPkt);
+
+                    final DatagramPacket replyUdpPkt = new DatagramPacket(receiveBuffer,
+                            receiveBuffer.length);
+                    udpSocket.setSoTimeout(DNS_REQUEST_TIMEOUT_MS);
+                    udpSocket.receive(replyUdpPkt);
+                    break;
+                } catch (IOException e) {
+                    if (attempt == maxRetry) {
+                        throw e; // If the last attempt fails, rethrow the exception.
+                    } else {
+                        Log.e(TAG, "DNS request failed (attempt " + attempt + ")" + e);
+                    }
+                }
+            }
+
+            final DnsPacket replyDnsPkt = new DnsPacket(receiveBuffer);
+            final DnsPacket.DnsRecord answerRecord = replyDnsPkt.getRecords(
+                    DnsPacket.ANSECTION).get(0);
+            final byte[] txtReplyRecord = answerRecord.getRR();
+            final byte dataLength = txtReplyRecord[0];
+            assertEquals(dataLength, txtReplyRecord.length - 1);
+            return new String(Arrays.copyOfRange(txtReplyRecord, 1, txtReplyRecord.length));
+        }
+    }
+
     /**
      * Tests that connections can be opened on WiFi and cellphone networks,
      * and that they are made from different IP addresses.
@@ -886,8 +937,31 @@
 
         // Verify that the IP addresses that the requests appeared to come from are actually on the
         // respective networks.
-        assertOnNetwork(wifiAddressString, wifiNetwork);
-        assertOnNetwork(cellAddressString, cellNetwork);
+        final InetAddress wifiAddress = InetAddresses.parseNumericAddress(wifiAddressString);
+        final LinkProperties wifiLinkProperties = mCm.getLinkProperties(wifiNetwork);
+        // To make sure that the request went out on the right network, check that
+        // the IP address seen by the server is assigned to the expected network.
+        // We can only do this for IPv6 addresses, because in IPv4 we will likely
+        // have a private IPv4 address, and that won't match what the server sees.
+        if (wifiAddress instanceof Inet6Address) {
+            assertContains(wifiLinkProperties.getAddresses(), wifiAddress);
+        }
+
+        final LinkProperties cellLinkProperties = mCm.getLinkProperties(cellNetwork);
+        final InetAddress cellAddress = InetAddresses.parseNumericAddress(cellAddressString);
+        final List<InetAddress> cellNetworkAddresses = cellLinkProperties.getAddresses();
+        // In userdebug build, on cellular network, if the onNetwork check failed, we also try to
+        // re-verify it by obtaining the IP address through DNS query.
+        boolean isUserDebug = Build.isDebuggable();
+        if (cellAddress instanceof Inet6Address) {
+            if (isUserDebug && !cellNetworkAddresses.contains(cellAddress)) {
+                final InetAddress ipv6AddressThroughDns = InetAddresses.parseNumericAddress(
+                        getDeviceIpv6AddressThroughDnsQuery(cellNetwork));
+                assertContains(cellNetworkAddresses, ipv6AddressThroughDns);
+            } else {
+                assertContains(cellNetworkAddresses, cellAddress);
+            }
+        }
 
         assertFalse("Unexpectedly equal: " + wifiNetwork, wifiNetwork.equals(cellNetwork));
     }
@@ -919,17 +993,6 @@
         }
     }
 
-    private void assertOnNetwork(String adressString, Network network) throws UnknownHostException {
-        InetAddress address = InetAddress.getByName(adressString);
-        LinkProperties linkProperties = mCm.getLinkProperties(network);
-        // To make sure that the request went out on the right network, check that
-        // the IP address seen by the server is assigned to the expected network.
-        // We can only do this for IPv6 addresses, because in IPv4 we will likely
-        // have a private IPv4 address, and that won't match what the server sees.
-        if (address instanceof Inet6Address) {
-            assertContains(linkProperties.getAddresses(), address);
-        }
-    }
 
     private static<T> void assertContains(Collection<T> collection, T element) {
         assertTrue(element + " not found in " + collection, collection.contains(element));
@@ -1713,7 +1776,8 @@
         }
     }
 
-    private InetAddress getAddrByName(final String hostname, final int family) throws Exception {
+    private static InetAddress getAddrByName(final String hostname, final int family)
+            throws Exception {
         final InetAddress[] allAddrs = InetAddress.getAllByName(hostname);
         for (InetAddress addr : allAddrs) {
             if (family == AF_INET && addr instanceof Inet4Address) return addr;
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index 1e2a212..9be579b 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -145,6 +145,8 @@
 
     private var tetheredInterfaceRequest: TetheredInterfaceRequest? = null
 
+    private var ethernetEnabled = true
+
     private class EthernetTestInterface(
         context: Context,
         private val handler: Handler,
@@ -428,7 +430,7 @@
 
         // when an interface comes up, we should always see a down cb before an up cb.
         ifaceListener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
-        if (hasCarrier) {
+        if (hasCarrier && ethernetEnabled) {
             ifaceListener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
         }
         return iface
@@ -514,6 +516,7 @@
     private fun setEthernetEnabled(enabled: Boolean) {
         runAsShell(NETWORK_SETTINGS) { em.setEthernetEnabled(enabled) }
 
+        ethernetEnabled = enabled
         val listener = EthernetStateListener()
         addEthernetStateListener(listener)
         listener.eventuallyExpect(if (enabled) ETHERNET_STATE_ENABLED else ETHERNET_STATE_DISABLED)
@@ -600,26 +603,6 @@
         }
     }
 
-    @Test
-    fun testCallbacks_withRunningInterface() {
-        assumeFalse(isAdbOverEthernet())
-        // Only run this test when no non-restricted / physical interfaces are present.
-        assumeNoInterfaceForTetheringAvailable()
-
-        val iface = createInterface()
-        val listener = EthernetStateListener()
-        addInterfaceStateListener(listener)
-        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
-
-        // Remove running interface. The interface stays running but is no longer tracked.
-        setEthernetEnabled(false)
-        listener.expectCallback(iface, STATE_ABSENT, ROLE_NONE)
-
-        setEthernetEnabled(true)
-        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
-        listener.assertNoCallback()
-    }
-
     private fun assumeNoInterfaceForTetheringAvailable() {
         // Interfaces that have configured NetworkCapabilities will never be used for tethering,
         // see aosp/2123900.
@@ -911,6 +894,30 @@
     }
 
     @Test
+    fun testEnableDisableInterface_disableEnableEthernet() {
+        val iface = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+
+        // When ethernet is disabled, interface should be down and enable/disableInterface()
+        // should not bring the interfaces up.
+        setEthernetEnabled(false)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+        enableInterface(iface).expectError()
+        disableInterface(iface).expectError()
+        listener.assertNoCallback()
+
+        // When ethernet is enabled, enable/disableInterface() should succeed.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+        disableInterface(iface).expectResult(iface.name)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+        enableInterface(iface).expectResult(iface.name)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+    @Test
     fun testUpdateConfiguration_forBothIpConfigAndCapabilities() {
         val iface = createInterface()
         val cb = requestNetwork(ETH_REQUEST.copyWithEthernetSpecifier(iface.name))
@@ -1018,4 +1025,68 @@
         cb.eventuallyExpectCapabilities(TEST_CAPS)
         cb.eventuallyExpectLpForStaticConfig(STATIC_IP_CONFIGURATION.staticIpConfiguration)
     }
+
+    @Test
+    fun testAddInterface_disableEnableEthernet() {
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+
+        // When ethernet is disabled, newly added interfaces should not be brought up.
+        setEthernetEnabled(false)
+        val iface = createInterface(/* hasCarrier */ true)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        // When ethernet is re-enabled after interface is added, it will be brought up.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+
+    @Test
+    fun testRemoveInterface_disableEnableEthernet() {
+        // Set up 2 interfaces for testing
+        val iface1 = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.eventuallyExpect(iface1, STATE_LINK_UP, ROLE_CLIENT)
+        val iface2 = createInterface()
+        listener.eventuallyExpect(iface2, STATE_LINK_UP, ROLE_CLIENT)
+
+        // Removing interfaces when ethernet is enabled will first send link down, then
+        // STATE_ABSENT/ROLE_NONE.
+        removeInterface(iface1)
+        listener.expectCallback(iface1, STATE_LINK_DOWN, ROLE_CLIENT)
+        listener.expectCallback(iface1, STATE_ABSENT, ROLE_NONE)
+
+        // Removing interfaces after ethernet is disabled will first send link down when ethernet is
+        // disabled, then STATE_ABSENT/ROLE_NONE when interface is removed.
+        setEthernetEnabled(false)
+        listener.expectCallback(iface2, STATE_LINK_DOWN, ROLE_CLIENT)
+        removeInterface(iface2)
+        listener.expectCallback(iface2, STATE_ABSENT, ROLE_NONE)
+    }
+
+    @Test
+    fun testSetTetheringInterfaceMode_disableEnableEthernet() {
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+
+        val iface = createInterface()
+        requestTetheredInterface().expectOnAvailable()
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_SERVER)
+
+        // (b/234743836): Currently the state of server mode interfaces always returns true due to
+        // that interface state for server mode interfaces is not tracked properly.
+        // So we do not get any state change when disabling ethernet.
+        setEthernetEnabled(false)
+        listener.assertNoCallback()
+
+        // When ethernet is disabled, change interface mode will not bring the interface up.
+        releaseTetheredInterface()
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        // When ethernet is re-enabled, interface will be brought up.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
index 2c7d5c6..c67443e 100644
--- a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
+++ b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
@@ -39,10 +39,13 @@
 import android.system.ErrnoException;
 import android.system.OsConstants;
 import android.util.ArraySet;
+import android.util.Log;
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.net.module.util.CollectionUtils;
 import com.android.testutils.AutoReleaseNetworkCallbackRule;
+import com.android.testutils.ConnectivityDiagnosticsCollector;
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.DeviceConfigRule;
 
@@ -51,6 +54,8 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Set;
 
 @DevSdkIgnoreRunner.RestoreDefaultNetwork
@@ -70,13 +75,34 @@
     private static final String TAG = "MultinetworkNativeApiTest";
     static final String GOOGLE_PRIVATE_DNS_SERVER = "dns.google";
 
+    public static class QueryTestResult {
+        public final int sourcePort;
+        public final int attempts;
+        public final int errNo;
+
+        public QueryTestResult(int sourcePort, int attempts, int errNo) {
+            this.sourcePort = sourcePort;
+            this.attempts = attempts;
+            this.errNo = errNo;
+        }
+
+        @Override
+        public String toString() {
+            return "QueryTestResult{"
+                    + "sourcePort=" + sourcePort
+                    + ", attempts=" + attempts
+                    + ", errNo=" + errNo
+                    + '}';
+        }
+    }
+
     /**
      * @return 0 on success
      */
     private static native int runGetaddrinfoCheck(long networkHandle);
     private static native int runSetprocnetwork(long networkHandle);
     private static native int runSetsocknetwork(long networkHandle);
-    private static native int runDatagramCheck(long networkHandle);
+    private static native QueryTestResult runDatagramCheck(long networkHandle, int sourcePort);
     private static native void runResNapiMalformedCheck(long networkHandle);
     private static native void runResNcancelCheck(long networkHandle);
     private static native void runResNqueryCheck(long networkHandle);
@@ -165,14 +191,69 @@
         }
     }
 
+    private void runNativeDatagramTransmissionDiagnostics(Network network,
+            QueryTestResult failedResult) {
+        final ConnectivityDiagnosticsCollector collector = ConnectivityDiagnosticsCollector
+                .getInstance();
+        if (collector == null) {
+            Log.e(TAG, "Missing ConnectivityDiagnosticsCollector, not adding diagnostics");
+            return;
+        }
+
+        final int numReruns = 10;
+        final ArrayList<QueryTestResult> reruns = new ArrayList<>(numReruns);
+        for (int i = 0; i < numReruns; i++) {
+            final QueryTestResult rerunResult =
+                    runDatagramCheck(network.getNetworkHandle(), 0 /* sourcePort */);
+            Log.d(TAG, "Rerun result " + i + ": " + rerunResult);
+            reruns.add(rerunResult);
+        }
+        // Rerun on the original port after trying the other ports, to check that the results are
+        // consistent, as opposed to the network recovering halfway through.
+        int originalPortFailedReruns = 0;
+        for (int i = 0; i < numReruns; i++) {
+            final QueryTestResult originalPortRerun = runDatagramCheck(network.getNetworkHandle(),
+                    failedResult.sourcePort);
+            Log.d(TAG, "Rerun result " + i + " with original port: " + originalPortRerun);
+            if (originalPortRerun.errNo != 0) {
+                originalPortFailedReruns++;
+            }
+        }
+
+        final int noRetrySuccessResults = reruns.stream()
+                .filter(result -> result.errNo == 0 && result.attempts == 1)
+                .mapToInt(result -> 1)
+                .sum();
+        final int failedResults = reruns.stream()
+                .filter(result -> result.errNo != 0)
+                .mapToInt(result -> 1)
+                .sum();
+        collector.addFailureAttribute("numReruns", numReruns);
+        collector.addFailureAttribute("noRetrySuccessReruns", noRetrySuccessResults);
+        collector.addFailureAttribute("failedReruns", failedResults);
+        collector.addFailureAttribute("originalPortFailedReruns", originalPortFailedReruns);
+    }
+
     @Test
     public void testNativeDatagramTransmission() throws Exception {
         for (Network network : getTestableNetworks()) {
-            int errno = runDatagramCheck(network.getNetworkHandle());
-            if (errno != 0) {
-                throw new ErrnoException(
-                        "DatagramCheck on " + mCM.getNetworkInfo(network), -errno);
+            final QueryTestResult result = runDatagramCheck(network.getNetworkHandle(),
+                    0 /* sourcePort */);
+            if (result.errNo == 0) {
+                continue;
             }
+            final NetworkCapabilities nc = mCM.getNetworkCapabilities(network);
+            final int[] transports = nc != null ? nc.getTransportTypes() : null;
+            if (CollectionUtils.contains(transports, TRANSPORT_WIFI)) {
+                runNativeDatagramTransmissionDiagnostics(network, result);
+            }
+
+            // Log the whole result (with source port and attempts) to logcat, but use only the
+            // errno and transport in the fail message so similar failures have consistent messages
+            final String error = "DatagramCheck on transport " + Arrays.toString(transports)
+                    + " failed: " + result.errNo;
+            Log.e(TAG, error + ", result: " + result);
+            fail(error);
         }
     }
 
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index fef085d..e3d7240 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -63,6 +63,7 @@
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.SystemClock;
+import android.os.UserHandle;
 import android.platform.test.annotations.AppModeFull;
 import android.telephony.TelephonyManager;
 import android.text.TextUtils;
@@ -113,8 +114,8 @@
 
 
     private static final String LOG_TAG = "NetworkStatsManagerTest";
-    private static final String APPOPS_SET_SHELL_COMMAND = "appops set {0} {1} {2}";
-    private static final String APPOPS_GET_SHELL_COMMAND = "appops get {0} {1}";
+    private static final String APPOPS_SET_SHELL_COMMAND = "appops set --user {0} {1} {2} {3}";
+    private static final String APPOPS_GET_SHELL_COMMAND = "appops get --user {0} {1} {2}";
 
     private static final long MINUTE = 1000 * 60;
     private static final int TIMEOUT_MILLIS = 15000;
@@ -329,12 +330,14 @@
     }
 
     private void setAppOpsMode(String appop, String mode) throws Exception {
-        final String command = MessageFormat.format(APPOPS_SET_SHELL_COMMAND, mPkg, appop, mode);
+        final String command = MessageFormat.format(APPOPS_SET_SHELL_COMMAND,
+                UserHandle.myUserId(), mPkg, appop, mode);
         SystemUtil.runShellCommand(mInstrumentation, command);
     }
 
     private String getAppOpsMode(String appop) throws Exception {
-        final String command = MessageFormat.format(APPOPS_GET_SHELL_COMMAND, mPkg, appop);
+        final String command = MessageFormat.format(APPOPS_GET_SHELL_COMMAND,
+                UserHandle.myUserId(), mPkg, appop);
         String result = SystemUtil.runShellCommand(mInstrumentation, command);
         if (result == null) {
             Log.w(LOG_TAG, "App op " + appop + " could not be read.");
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
index 0dd2a23..173d13f 100644
--- a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
@@ -53,6 +53,7 @@
 import android.os.Build;
 import android.os.ConditionVariable;
 import android.os.IBinder;
+import android.os.UserHandle;
 import android.system.Os;
 import android.system.OsConstants;
 import android.telephony.SubscriptionManager;
@@ -145,7 +146,8 @@
         for (final String pkg : new String[] {"com.android.shell", mContext.getPackageName()}) {
             final String cmd =
                     String.format(
-                            "appops set %s %s %s",
+                            "appops set --user %d %s %s %s",
+                            UserHandle.myUserId(), // user id
                             pkg, // Package name
                             opName, // Appop
                             (allow ? "allow" : "deny")); // Action
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index 47d444f..b294d63 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -77,6 +77,7 @@
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
 
+import androidx.annotation.NonNull;
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
@@ -225,19 +226,23 @@
 
     }
 
-    @Test
-    public void testTetheringRequest() {
-        SoftApConfiguration softApConfiguration;
+    private SoftApConfiguration createSoftApConfiguration(@NonNull String ssid) {
+        SoftApConfiguration config;
         if (SdkLevel.isAtLeastT()) {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes(
-                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
+            config = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes(ssid.getBytes(StandardCharsets.UTF_8)))
                     .build();
         } else {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setSsid("This is an SSID!")
+            config = new SoftApConfiguration.Builder()
+                    .setSsid(ssid)
                     .build();
         }
+        return config;
+    }
+
+    @Test
+    public void testTetheringRequest() {
+        SoftApConfiguration softApConfiguration = createSoftApConfiguration("SSID");
         final TetheringRequest tr = new TetheringRequest.Builder(TETHERING_WIFI)
                 .setSoftApConfiguration(softApConfiguration)
                 .build();
@@ -302,17 +307,7 @@
 
     @Test
     public void testTetheringRequestSetSoftApConfigurationFailsWhenNotWifi() {
-        final SoftApConfiguration softApConfiguration;
-        if (SdkLevel.isAtLeastT()) {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes(
-                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
-                    .build();
-        } else {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setSsid("This is an SSID!")
-                    .build();
-        }
+        final SoftApConfiguration softApConfiguration = createSoftApConfiguration("SSID");
         for (int type : List.of(TETHERING_USB, TETHERING_BLUETOOTH, TETHERING_WIFI_P2P,
                 TETHERING_NCM, TETHERING_ETHERNET)) {
             try {
@@ -326,17 +321,7 @@
 
     @Test
     public void testTetheringRequestParcelable() {
-        final SoftApConfiguration softApConfiguration;
-        if (SdkLevel.isAtLeastT()) {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes(
-                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
-                    .build();
-        } else {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setSsid("This is an SSID!")
-                    .build();
-        }
+        final SoftApConfiguration softApConfiguration = createSoftApConfiguration("SSID");
         final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
         final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
         final TetheringRequest withConfig = new TetheringRequest.Builder(TETHERING_WIFI)
@@ -363,9 +348,7 @@
             tetherEventCallback.assumeWifiTetheringSupported(mContext);
             tetherEventCallback.expectNoTetheringActive();
 
-            SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes("This is an SSID!"
-                            .getBytes(StandardCharsets.UTF_8))).build();
+            SoftApConfiguration softApConfig = createSoftApConfiguration("SSID");
             final TetheringInterface tetheredIface =
                     mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
 
diff --git a/tests/unit/jni/android_net_frameworktests_util/onload.cpp b/tests/unit/jni/android_net_frameworktests_util/onload.cpp
index 06a3986..a0ce4f8 100644
--- a/tests/unit/jni/android_net_frameworktests_util/onload.cpp
+++ b/tests/unit/jni/android_net_frameworktests_util/onload.cpp
@@ -24,6 +24,8 @@
 
 int register_com_android_net_module_util_BpfMap(JNIEnv* env, char const* class_name);
 int register_com_android_net_module_util_TcUtils(JNIEnv* env, char const* class_name);
+int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+                                                      char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
     JNIEnv *env;
@@ -38,6 +40,10 @@
     if (register_com_android_net_module_util_TcUtils(env,
             "android/net/frameworktests/util/TcUtils") < 0) return JNI_ERR;
 
+    if (register_com_android_net_module_util_TimerFdUtils(
+            env, "android/net/frameworktests/util/TimerFdUtils") < 0)
+      return JNI_ERR;
+
     return JNI_VERSION_1_6;
 }
 
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index 34d67bb..40842f1 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -13,6 +13,9 @@
   "postsubmit": [
     {
       "name": "ThreadNetworkMultiDeviceTests"
+    },
+    {
+      "name": "ThreadNetworkTrelDisabledTests"
     }
   ]
 }
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 8f082a4..798a51e 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -62,3 +62,23 @@
     ],
     compile_multilib: "both",
 }
+
+android_test {
+    name: "ThreadNetworkTrelDisabledTests",
+    platform_apis: true,
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTestTrelDisabled.xml",
+    defaults: [
+        "framework-connectivity-test-defaults",
+        "ThreadNetworkIntegrationTestsDefaults",
+    ],
+    test_suites: [
+        "mts-tethering",
+        "general-tests",
+    ],
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
+    compile_multilib: "both",
+}
diff --git a/thread/tests/integration/AndroidTestTrelDisabled.xml b/thread/tests/integration/AndroidTestTrelDisabled.xml
new file mode 100644
index 0000000..600652a
--- /dev/null
+++ b/thread/tests/integration/AndroidTestTrelDisabled.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+ -->
+
+<configuration description="Config for Thread integration tests with TREL disabled">
+    <option name="test-tag" value="ThreadNetworkTrelDisabledTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <!--
+        Only run tests if the device under test is SDK version 34 (Android 14) or above.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk34ModuleController" />
+
+    <!-- Run tests in MTS only if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.DeviceFeatureModuleController">
+        <option name="required-feature" value="android.hardware.thread_network" />
+    </object>
+
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+    <!-- Install test -->
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadNetworkTrelDisabledTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.thread.tests.integration" />
+    </test>
+
+    <!-- Disable TREL for integration tests -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/TrelFeature__enabled=false"/>
+    </target_preparer>
+</configuration>
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 15259c8..6c2a9bb 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -30,6 +30,8 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -50,6 +52,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.net.module.util.HexDump;
 
 import com.google.common.truth.Correspondence;
@@ -445,9 +448,13 @@
     }
 
     @Test
-    // TODO: move this case out to BorderRoutingTest when the service discovery utilities
+    // TODO: move this case out of ServiceDiscoveryTest when the service discovery utilities
     // are decoupled from this test.
     public void trelFeatureFlagEnabled_trelServicePublished() throws Exception {
+        assumeTrue(
+                DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                        "thread_network", "TrelFeature__enabled", false));
+
         NsdServiceInfo discoveredService = discoverService(mNsdManager, "_trel._udp");
         assertThat(discoveredService).isNotNull();
         // Resolve service with the current TREL port, otherwise it may return stale service from
@@ -463,6 +470,17 @@
                 .isEqualTo(mOtCtl.getExtendedPanId().toLowerCase(Locale.ROOT));
     }
 
+    @Test
+    // TODO: move this case out of ServiceDiscoveryTest when the service discovery utilities
+    // are decoupled from this test.
+    public void trelFeatureFlagDisabled_trelServiceNotPublished() throws Exception {
+        assumeFalse(
+                DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                        "thread_network", "TrelFeature__enabled", false));
+
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_trel._udp"));
+    }
+
     private void registerService(NsdServiceInfo serviceInfo, RegistrationListener listener)
             throws InterruptedException, ExecutionException, TimeoutException {
         mNsdManager.registerService(serviceInfo, PROTOCOL_DNS_SD, listener);
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 9af0b53..dcbb3f5 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -17,6 +17,7 @@
 package com.android.server.thread;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
@@ -266,6 +267,7 @@
     public void tearDown() throws Exception {
         runAsShell(
                 WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
                 () -> DeviceConfig.deleteProperty("thread_network", "TrelFeature__enabled"));
     }
 
@@ -338,6 +340,7 @@
     public void initialize_trelFeatureDisabled_trelDisabledAtOtDaemon() throws Exception {
         runAsShell(
                 WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
                 () ->
                         DeviceConfig.setProperty(
                                 "thread_network", "TrelFeature__enabled", "false", false));
@@ -352,6 +355,7 @@
     public void initialize_trelFeatureEnabled_setTrelEnabledAtOtDamon() throws Exception {
         runAsShell(
                 WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
                 () ->
                         DeviceConfig.setProperty(
                                 "thread_network", "TrelFeature__enabled", "true", false));