Merge "Remove VpnProfileStore dependency from CSTest" into main
diff --git a/Cronet/tests/common/AndroidTest.xml b/Cronet/tests/common/AndroidTest.xml
index bded8fb..ae6b65b 100644
--- a/Cronet/tests/common/AndroidTest.xml
+++ b/Cronet/tests/common/AndroidTest.xml
@@ -35,26 +35,17 @@
         <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
         <!-- b/298380508 -->
         <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsIgnoredInNativeCronetEngineBuilderImpl" />
-        <!-- b/316571753 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testBaseFeatureFlagsOverridesEnabled" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAppIdMatches" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAreLoaded" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAtMinVersion" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAboveMinVersion" />
-        <!-- b/316567693 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestTest#testSSLCertificateError" />
         <!-- b/316559294 -->
         <option name="exclude-filter" value="org.chromium.net.NQETest#testQuicDisabled" />
         <!-- b/316559294 -->
         <option name="exclude-filter" value="org.chromium.net.NQETest#testPrefsWriteRead" />
         <!-- b/316554711-->
-        <option name="exclude-filter" value="org.chromium.net.NetworkChangesTest" /> 
+        <option name="exclude-filter" value="org.chromium.net.NetworkChangesTest" />
         <!-- b/316550794 -->
         <option name="exclude-filter" value="org.chromium.net.impl.CronetLoggerTest#testEngineCreation" />
         <option name="hidden-api-checks" value="false"/>
         <option name="isolated-storage" value="false"/>
+        <option name="orchestrator" value="true"/>
         <option
             name="device-listeners"
             value="com.android.modules.utils.testing.NativeCoverageHackInstrumentationListener" />
diff --git a/Cronet/tests/mts/AndroidTest.xml b/Cronet/tests/mts/AndroidTest.xml
index bccbe29..5aed655 100644
--- a/Cronet/tests/mts/AndroidTest.xml
+++ b/Cronet/tests/mts/AndroidTest.xml
@@ -35,16 +35,6 @@
         <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
         <!-- b/298380508 -->
         <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsIgnoredInNativeCronetEngineBuilderImpl" />
-        <!-- b/316571753 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testBaseFeatureFlagsOverridesEnabled" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAppIdMatches" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAreLoaded" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testSetLibraryLoaderIsEnforcedByDefaultEmbeddedProvider" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAtMinVersion" />
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestContextTest#testHttpFlagsAppliedIfAboveMinVersion" />
-        <!-- b/316567693 -->
-        <option name="exclude-filter" value="org.chromium.net.CronetUrlRequestTest#testSSLCertificateError" />
         <!-- b/316559294 -->
         <option name="exclude-filter" value="org.chromium.net.NQETest#testQuicDisabled" />
         <!-- b/316559294 -->
@@ -55,6 +45,7 @@
         <option name="exclude-filter" value="org.chromium.net.impl.CronetLoggerTest#testEngineCreation" />
         <option name="hidden-api-checks" value="false"/>
         <option name="isolated-storage" value="false"/>
+        <option name="orchestrator" value="true"/>
     </test>
 
     <!-- Only run NetHttpTests in MTS if the Tethering Mainline module is installed. -->
diff --git a/Cronet/tools/import/copy.bara.sky b/Cronet/tools/import/copy.bara.sky
deleted file mode 100644
index 61e3ba4..0000000
--- a/Cronet/tools/import/copy.bara.sky
+++ /dev/null
@@ -1,127 +0,0 @@
-# Copyright 2023 Google Inc. All rights reserved.
-#
-# 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.
-
-common_excludes = [
-    # Exclude all Android build files
-    "**/Android.bp",
-    "**/Android.mk",
-
-    # Exclude existing *OWNERS files
-    "**/*OWNERS",
-    "**/.git/**",
-    "**/.gitignore",
-]
-
-cronet_origin_files = glob(
-    include = [
-        "base/**",
-        "build/**",
-        "build/buildflag.h",
-        "chrome/VERSION",
-        "components/cronet/**",
-        "components/metrics/**",
-        "components/nacl/**",
-        "components/prefs/**",
-        "crypto/**",
-        "ipc/**",
-        "net/**",
-        # Note: Only used for tests.
-        "testing/**",
-        "url/**",
-        "LICENSE",
-    ],
-    exclude = common_excludes + [
-        # Per aosp/2367109
-        "build/android/CheckInstallApk-debug.apk",
-        "build/android/unused_resources/**",
-        "build/linux/**",
-
-        # Per aosp/2374766
-        "components/cronet/ios/**",
-        "components/cronet/native/**",
-
-        # Per aosp/2399270
-        "testing/buildbot/**",
-
-        # Exclude all third-party directories. Those are specified explicitly
-        # below, so no dependency can accidentally creep in.
-        "**/third_party/**",
-    ],
-) + glob(
-    # Explicitly include third-party dependencies.
-    # Note: some third-party dependencies include a third_party folder within
-    # them. So far, this has not become a problem.
-    include = [
-        "base/third_party/cityhash/**",
-        "base/third_party/cityhash_v103/**",
-        "base/third_party/double_conversion/**",
-        "base/third_party/dynamic_annotations/**",
-        "base/third_party/icu/**",
-        "base/third_party/nspr/**",
-        "base/third_party/superfasthash/**",
-        "base/third_party/valgrind/**",
-        # Those are temporarily needed until Chromium finish the migration
-        # of libc++[abi]
-        "buildtools/third_party/libc++/**",
-        "buildtools/third_party/libc++abi/**",
-        # Note: Only used for tests.
-        "net/third_party/nist-pkits/**",
-        "net/third_party/quiche/**",
-        "net/third_party/uri_template/**",
-        "third_party/abseil-cpp/**",
-        "third_party/android_ndk/sources/android/cpufeatures/**",
-        "third_party/ashmem/**",
-        "third_party/boringssl/**",
-        "third_party/brotli/**",
-        # Note: Only used for tests.
-        "third_party/ced/**",
-        "third_party/cpu_features/**",
-        # Note: Only used for tests.
-        "third_party/google_benchmark/**",
-        # Note: Only used for tests.
-        "third_party/googletest/**",
-        "third_party/icu/**",
-        "third_party/jni_zero/**",
-        "third_party/libc++/**",
-        "third_party/libc++abi/**",
-        "third_party/libevent/**",
-        # Note: Only used for tests.
-        "third_party/libxml/**",
-        # Note: Only used for tests.
-        "third_party/lss/**",
-        "third_party/metrics_proto/**",
-        "third_party/modp_b64/**",
-        "third_party/protobuf/**",
-        # Note: Only used for tests.
-        "third_party/quic_trace/**",
-        # Note: Cronet currently uses Android's zlib
-        # "third_party/zlib/**",
-        "url/third_party/mozilla/**",
-    ],
-    exclude = common_excludes,
-)
-
-core.workflow(
-    name = "import_cronet",
-    authoring = authoring.overwrite("Cronet Mainline Eng <cronet-mainline-eng+copybara@google.com>"),
-    # Origin folder is specified via source_ref argument, see import_cronet.sh
-    origin = folder.origin(),
-    origin_files = cronet_origin_files,
-    destination = git.destination(
-        # The destination URL is set by the invoking script.
-        url = "overwritten/by/script",
-        push = "upstream-import",
-    ),
-    mode = "SQUASH",
-)
diff --git a/Cronet/tools/import/import_cronet.sh b/Cronet/tools/import/import_cronet.sh
deleted file mode 100755
index 0f04af7..0000000
--- a/Cronet/tools/import/import_cronet.sh
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/bin/bash
-
-# Copyright 2023 Google Inc. All rights reserved.
-#
-# 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.
-
-# Script to invoke copybara locally to import Cronet into Android.
-# Inputs:
-#  Environment:
-#   ANDROID_BUILD_TOP: path the root of the current Android directory.
-#  Arguments:
-#   -l rev: The last revision that was imported.
-#  Optional Arguments:
-#   -n rev: The new revision to import.
-#   -f: Force copybara to ignore a failure to find the last imported revision.
-
-set -e -x
-
-OPTSTRING=fl:n:
-
-usage() {
-    cat <<EOF
-Usage: import_cronet.sh -n new-rev [-l last-rev] [-f]
-EOF
-    exit 1
-}
-
-COPYBARA_FOLDER_ORIGIN="/tmp/copybara-origin"
-
-#######################################
-# Create local upstream-import branch in external/cronet.
-# Globals:
-#   ANDROID_BUILD_TOP
-# Arguments:
-#   none
-#######################################
-setup_upstream_import_branch() {
-    local git_dir="${ANDROID_BUILD_TOP}/external/cronet"
-
-    (cd "${git_dir}" && git fetch aosp upstream-import:upstream-import)
-}
-
-#######################################
-# Setup folder.origin for copybara inside /tmp
-# Globals:
-#   COPYBARA_FOLDER_ORIGIN
-# Arguments:
-#   new_rev, string
-#######################################
-setup_folder_origin() (
-    local _new_rev=$1
-    mkdir -p "${COPYBARA_FOLDER_ORIGIN}"
-    cd "${COPYBARA_FOLDER_ORIGIN}"
-
-    if [ -d src ]; then
-        (cd src && git fetch --tags && git checkout "${_new_rev}")
-    else
-        # For this to work _new_rev must be a branch or a tag.
-        git clone --depth=1 --branch "${_new_rev}" https://chromium.googlesource.com/chromium/src.git
-    fi
-
-
-    cat <<EOF >.gclient
-solutions = [
-  {
-    "name": "src",
-    "url": "https://chromium.googlesource.com/chromium/src.git",
-    "managed": False,
-    "custom_deps": {},
-    "custom_vars": {},
-  },
-]
-target_os = ["android"]
-EOF
-    cd src
-    # Set appropriate gclient flags to speed up syncing.
-    gclient sync \
-        --no-history \
-        --shallow \
-        --delete_unversioned_trees
-)
-
-#######################################
-# Runs the copybara import of Chromium
-# Globals:
-#   ANDROID_BUILD_TOP
-#   COPYBARA_FOLDER_ORIGIN
-# Arguments:
-#   last_rev, string or empty
-#   force, string or empty
-#######################################
-do_run_copybara() {
-    local _last_rev=$1
-    local _force=$2
-
-    local -a flags
-    flags+=(--git-destination-url="file://${ANDROID_BUILD_TOP}/external/cronet")
-    flags+=(--repo-timeout 3m)
-
-    # buildtools/third_party/libc++ contains an invalid symlink
-    flags+=(--folder-origin-ignore-invalid-symlinks)
-    flags+=(--git-no-verify)
-
-    if [ ! -z "${_force}" ]; then
-        flags+=(--force)
-    fi
-
-    if [ ! -z "${_last_rev}" ]; then
-        flags+=(--last-rev "${_last_rev}")
-    fi
-
-    /google/bin/releases/copybara/public/copybara/copybara \
-        "${flags[@]}" \
-        "${ANDROID_BUILD_TOP}/packages/modules/Connectivity/Cronet/tools/import/copy.bara.sky" \
-        import_cronet "${COPYBARA_FOLDER_ORIGIN}/src"
-}
-
-while getopts $OPTSTRING opt; do
-    case "${opt}" in
-        f) force=true ;;
-        l) last_rev="${OPTARG}" ;;
-        n) new_rev="${OPTARG}" ;;
-        ?) usage ;;
-        *) echo "'${opt}' '${OPTARG}'"
-    esac
-done
-
-if [ -z "${new_rev}" ]; then
-    echo "-n argument required"
-    usage
-fi
-
-setup_upstream_import_branch
-setup_folder_origin "${new_rev}"
-do_run_copybara "${last_rev}" "${force}"
-
diff --git a/OWNERS_core_networking_xts b/OWNERS_core_networking_xts
index 7612210..b24e3ac 100644
--- a/OWNERS_core_networking_xts
+++ b/OWNERS_core_networking_xts
@@ -3,6 +3,8 @@
 
 # For cherry-picks of CLs that are already merged in aosp/master, or flaky test fixes.
 jchalard@google.com #{LAST_RESORT_SUGGESTION}
+# In addition to cherry-picks and flaky test fixes, also for APF firmware tests
+# (to verify correct behaviour of the wifi APF interpreter)
 maze@google.com #{LAST_RESORT_SUGGESTION}
 # In addition to cherry-picks and flaky test fixes, also for incremental changes on NsdManager tests
 # to increase coverage for existing behavior, and testing of bug fixes in NsdManager
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 30bdf37..4bae221 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -103,9 +103,9 @@
         "dscpPolicy.o",
         "netd.o",
         "offload.o",
-        "offload@btf.o",
+        "offload@mainline.o",
         "test.o",
-        "test@btf.o",
+        "test@mainline.o",
     ],
     apps: [
         "ServiceConnectivityResources",
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 2933a44..1022b06 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -83,8 +83,10 @@
 import java.net.NetworkInterface;
 import java.net.SocketException;
 import java.nio.ByteBuffer;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
@@ -146,6 +148,8 @@
     private static final TetheringManager sTm = sContext.getSystemService(TetheringManager.class);
     private static final PackageManager sPackageManager = sContext.getPackageManager();
     private static final CtsNetUtils sCtsNetUtils = new CtsNetUtils(sContext);
+    private static final List<String> sCallbackErrors =
+            Collections.synchronizedList(new ArrayList<>());
 
     // Late initialization in setUp()
     private boolean mRunTests;
@@ -201,6 +205,7 @@
         assumeTrue(mRunTests);
 
         mTetheredInterfaceRequester = new TetheredInterfaceRequester();
+        sCallbackErrors.clear();
     }
 
     private boolean isEthernetTetheringSupported() throws Exception {
@@ -280,6 +285,10 @@
             mHandlerThread.quitSafely();
             mHandlerThread.join();
         }
+
+        if (sCallbackErrors.size() > 0) {
+            fail("Some callbacks had errors: " + sCallbackErrors);
+        }
     }
 
     protected static boolean isInterfaceForTetheringAvailable() throws Exception {
@@ -391,7 +400,7 @@
         }
         @Override
         public void onTetheredInterfacesChanged(List<String> interfaces) {
-            fail("Should only call callback that takes a Set<TetheringInterface>");
+            addCallbackError("Should only call callback that takes a Set<TetheringInterface>");
         }
 
         @Override
@@ -412,7 +421,7 @@
 
         @Override
         public void onLocalOnlyInterfacesChanged(List<String> interfaces) {
-            fail("Should only call callback that takes a Set<TetheringInterface>");
+            addCallbackError("Should only call callback that takes a Set<TetheringInterface>");
         }
 
         @Override
@@ -481,7 +490,7 @@
             // Ignore stale callbacks registered by previous test cases.
             if (mUnregistered) return;
 
-            fail("TetheringEventCallback got error:" + error + " on iface " + ifName);
+            addCallbackError("TetheringEventCallback got error:" + error + " on iface " + ifName);
         }
 
         @Override
@@ -536,6 +545,11 @@
         }
     }
 
+    private static void addCallbackError(String error) {
+        Log.e(TAG, error);
+        sCallbackErrors.add(error);
+    }
+
     protected static MyTetheringEventCallback enableEthernetTethering(String iface,
             TetheringRequest request, Network expectedUpstream) throws Exception {
         // Enable ethernet tethering with null expectedUpstream means the test accept any upstream
@@ -562,7 +576,7 @@
 
             @Override
             public void onTetheringFailed(int resultCode) {
-                fail("Unexpectedly got onTetheringFailed");
+                addCallbackError("Unexpectedly got onTetheringFailed");
             }
         };
         Log.d(TAG, "Starting Ethernet tethering");
diff --git a/bpf_progs/Android.bp b/bpf_progs/Android.bp
index 674cd98..1958aa8 100644
--- a/bpf_progs/Android.bp
+++ b/bpf_progs/Android.bp
@@ -94,13 +94,13 @@
 }
 
 bpf {
-    name: "offload@btf.o",
-    srcs: ["offload@btf.c"],
+    name: "offload@mainline.o",
+    srcs: ["offload@mainline.c"],
     btf: true,
     cflags: [
         "-Wall",
         "-Werror",
-        "-DBTF",
+        "-DMAINLINE",
     ],
 }
 
@@ -114,13 +114,13 @@
 }
 
 bpf {
-    name: "test@btf.o",
-    srcs: ["test@btf.c"],
+    name: "test@mainline.o",
+    srcs: ["test@mainline.c"],
     btf: true,
     cflags: [
         "-Wall",
         "-Werror",
-        "-DBTF",
+        "-DMAINLINE",
     ],
 }
 
diff --git a/bpf_progs/offload.c b/bpf_progs/offload.c
index 90f96a1..dd59dca 100644
--- a/bpf_progs/offload.c
+++ b/bpf_progs/offload.c
@@ -24,16 +24,16 @@
 #define __kernel_udphdr udphdr
 #include <linux/udp.h>
 
-#ifdef BTF
+#ifdef MAINLINE
 // BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
 // ship a different file than for later versions, but we need bpfloader v0.25+
 // for obj@ver.o support
 #define BPFLOADER_MIN_VER BPFLOADER_OBJ_AT_VER_VERSION
-#else /* BTF */
+#else /* MAINLINE */
 // The resulting .o needs to load on the Android S bpfloader
 #define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
 #define BPFLOADER_MAX_VER BPFLOADER_OBJ_AT_VER_VERSION
-#endif /* BTF */
+#endif /* MAINLINE */
 
 // Warning: values other than AID_ROOT don't work for map uid on BpfLoader < v0.21
 #define TETHERING_UID AID_ROOT
diff --git a/bpf_progs/offload@btf.c b/bpf_progs/offload@mainline.c
similarity index 100%
rename from bpf_progs/offload@btf.c
rename to bpf_progs/offload@mainline.c
diff --git a/bpf_progs/test.c b/bpf_progs/test.c
index 70b08b7..e2b8ea5 100644
--- a/bpf_progs/test.c
+++ b/bpf_progs/test.c
@@ -18,16 +18,16 @@
 #include <linux/in.h>
 #include <linux/ip.h>
 
-#ifdef BTF
+#ifdef MAINLINE
 // BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
 // ship a different file than for later versions, but we need bpfloader v0.25+
 // for obj@ver.o support
 #define BPFLOADER_MIN_VER BPFLOADER_OBJ_AT_VER_VERSION
-#else /* BTF */
+#else /* MAINLINE */
 // The resulting .o needs to load on the Android S bpfloader
 #define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
 #define BPFLOADER_MAX_VER BPFLOADER_OBJ_AT_VER_VERSION
-#endif /* BTF */
+#endif /* MAINLINE */
 
 // Warning: values other than AID_ROOT don't work for map uid on BpfLoader < v0.21
 #define TETHERING_UID AID_ROOT
diff --git a/bpf_progs/test@btf.c b/bpf_progs/test@mainline.c
similarity index 100%
rename from bpf_progs/test@btf.c
rename to bpf_progs/test@mainline.c
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 19b522c..30931df 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -6,6 +6,7 @@
 
 flag {
   name: "set_data_saver_via_cm"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Set data saver through ConnectivityManager API"
   bug: "297836825"
@@ -13,6 +14,7 @@
 
 flag {
   name: "support_is_uid_networking_blocked"
+  is_exported: true
   namespace: "android_core_networking"
   description: "This flag controls whether isUidNetworkingBlocked is supported"
   bug: "297836825"
@@ -20,6 +22,7 @@
 
 flag {
   name: "basic_background_restrictions_enabled"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Block network access for apps in a low importance background state"
   bug: "304347838"
@@ -27,6 +30,7 @@
 
 flag {
   name: "ipsec_transform_state"
+  is_exported: true
   namespace: "android_core_networking_ipsec"
   description: "The flag controls the access for getIpSecTransformState and IpSecTransformState"
   bug: "308011229"
@@ -34,6 +38,7 @@
 
 flag {
   name: "tethering_request_with_soft_ap_config"
+  is_exported: true
   namespace: "android_core_networking"
   description: "The flag controls the access for the parcelable TetheringRequest with getSoftApConfiguration/setSoftApConfiguration API"
   bug: "216524590"
@@ -41,6 +46,7 @@
 
 flag {
   name: "request_restricted_wifi"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for API to support requesting restricted wifi"
   bug: "315835605"
@@ -48,6 +54,7 @@
 
 flag {
   name: "net_capability_local_network"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for local network capability API"
   bug: "313000440"
@@ -55,6 +62,7 @@
 
 flag {
   name: "support_transport_satellite"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for satellite transport API"
   bug: "320514105"
@@ -62,6 +70,7 @@
 
 flag {
   name: "nsd_subtypes_support_enabled"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for API to support nsd subtypes"
   bug: "265095929"
@@ -69,6 +78,7 @@
 
 flag {
   name: "register_nsd_offload_engine_api"
+  is_exported: true
   namespace: "android_core_networking"
   description: "Flag for API to register nsd offload engine"
   bug: "301713539"
diff --git a/common/nearby_flags.aconfig b/common/nearby_flags.aconfig
index b957d33..b733849 100644
--- a/common/nearby_flags.aconfig
+++ b/common/nearby_flags.aconfig
@@ -3,6 +3,7 @@
 
 flag {
     name: "powered_off_finding"
+    is_exported: true
     namespace: "nearby"
     description: "Controls whether the Powered Off Finding feature is enabled"
     bug: "307898240"
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index 09595a6..43fc147 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -3,6 +3,7 @@
 
 flag {
     name: "thread_enabled"
+    is_exported: true
     namespace: "thread_network"
     description: "Controls whether the Android Thread feature is enabled"
     bug: "301473012"
diff --git a/framework-t/src/android/net/nsd/AdvertisingRequest.java b/framework-t/src/android/net/nsd/AdvertisingRequest.java
index 2895b0c..6afb2d5 100644
--- a/framework-t/src/android/net/nsd/AdvertisingRequest.java
+++ b/framework-t/src/android/net/nsd/AdvertisingRequest.java
@@ -110,8 +110,9 @@
     }
 
     /**
-     * Returns the time interval that the resource records may be cached on a DNS resolver or
-     * {@code null} if not specified.
+     * Returns the time interval that the resource records may be cached on a DNS resolver.
+     *
+     * The value will be {@code null} if it's not specified with the {@link #Builder}.
      *
      * @hide
      */
@@ -161,7 +162,7 @@
         dest.writeParcelable(mServiceInfo, flags);
         dest.writeInt(mProtocolType);
         dest.writeLong(mAdvertisingConfig);
-        dest.writeLong(mTtl == null ? -1 : mTtl.getSeconds());
+        dest.writeLong(mTtl == null ? -1L : mTtl.getSeconds());
     }
 
 //    @FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
@@ -205,7 +206,9 @@
          * When registering a service, {@link NsdManager#FAILURE_BAD_PARAMETERS} will be returned
          * if {@code ttl} is smaller than 30 seconds.
          *
-         * Note: only number of seconds of {@code ttl} is used.
+         * Note: the value after the decimal point (in unit of seconds) will be discarded. For
+         * example, {@code 30} seconds will be used when {@code Duration.ofSeconds(30L, 50_000L)}
+         * is provided.
          *
          * @param ttl the maximum duration that the DNS resource records will be cached
          *
diff --git a/framework-t/src/android/net/nsd/NsdServiceInfo.java b/framework-t/src/android/net/nsd/NsdServiceInfo.java
index f4cc2ac..dba08a1 100644
--- a/framework-t/src/android/net/nsd/NsdServiceInfo.java
+++ b/framework-t/src/android/net/nsd/NsdServiceInfo.java
@@ -70,7 +70,8 @@
 
     private int mInterfaceIndex;
 
-    // The timestamp that all resource records associated with this service are considered invalid.
+    // The timestamp that one or more resource records associated with this service are considered
+    // invalid.
     @Nullable
     private Instant mExpirationTime;
 
@@ -497,7 +498,9 @@
     /**
      * Sets the timestamp after when this service is expired.
      *
-     * Note: only number of seconds of {@code expirationTime} is used.
+     * Note: the value after the decimal point (in unit of seconds) will be discarded. For
+     * example, {@code 30} seconds will be used when {@code Duration.ofSeconds(30L, 50_000L)}
+     * is provided.
      *
      * @hide
      */
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 915ec52..9e42d2b 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -6284,7 +6284,8 @@
     @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK)
     public boolean isUidNetworkingBlocked(int uid, boolean isNetworkMetered) {
         if (!SdkLevel.isAtLeastU()) {
-            Log.wtf(TAG, "isUidNetworkingBlocked is not supported on pre-U devices");
+            throw new IllegalStateException(
+                    "isUidNetworkingBlocked is not supported on pre-U devices");
         }
         final BpfNetMapsReader reader = BpfNetMapsReader.getInstance();
         // Note that before V, the data saver status in bpf is written by ConnectivityService
diff --git a/framework/src/android/net/NetworkRequest.java b/framework/src/android/net/NetworkRequest.java
index 4de02ac..f7600b2 100644
--- a/framework/src/android/net/NetworkRequest.java
+++ b/framework/src/android/net/NetworkRequest.java
@@ -20,7 +20,6 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOREGROUND;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
@@ -41,8 +40,6 @@
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.compat.annotation.UnsupportedAppUsage;
-// TODO : replace with android.net.flags.Flags when aconfig is supported on udc-mainline-prod
-// import android.net.NetworkCapabilities.Flags;
 import android.net.NetworkCapabilities.NetCapability;
 import android.net.NetworkCapabilities.Transport;
 import android.os.Build;
@@ -291,18 +288,6 @@
                 NET_CAPABILITY_TRUSTED,
                 NET_CAPABILITY_VALIDATED);
 
-        /**
-         * Capabilities that are forbidden by default.
-         * Forbidden capabilities only make sense in NetworkRequest, not for network agents.
-         * Therefore these capabilities are only in NetworkRequest.
-         */
-        private static final int[] DEFAULT_FORBIDDEN_CAPABILITIES = new int[] {
-            // TODO(b/313030307): this should contain NET_CAPABILITY_LOCAL_NETWORK.
-            // We cannot currently add it because doing so would crash if the module rolls back,
-            // because JobScheduler persists NetworkRequests to disk, and existing production code
-            // does not consider LOCAL_NETWORK to be a valid capability.
-        };
-
         private final NetworkCapabilities mNetworkCapabilities;
 
         // A boolean that represents whether the NOT_VCN_MANAGED capability should be deduced when
@@ -318,16 +303,6 @@
             // it for apps that do not have the NETWORK_SETTINGS permission.
             mNetworkCapabilities = new NetworkCapabilities();
             mNetworkCapabilities.setSingleUid(Process.myUid());
-            // Default forbidden capabilities are foremost meant to help with backward
-            // compatibility. When adding new types of network identified by a capability that
-            // might confuse older apps, a default forbidden capability will have apps not see
-            // these networks unless they explicitly ask for it.
-            // If the app called clearCapabilities() it will see everything, but then it
-            // can be argued that it's fair to send them too, since it asked for everything
-            // explicitly.
-            for (final int forbiddenCap : DEFAULT_FORBIDDEN_CAPABILITIES) {
-                mNetworkCapabilities.addForbiddenCapability(forbiddenCap);
-            }
         }
 
         /**
diff --git a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
index dfe5867..a80db85 100644
--- a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
+++ b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
@@ -84,6 +84,21 @@
     @ChangeId
     @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.TIRAMISU)
     public static final long ENABLE_PLATFORM_MDNS_BACKEND = 270306772L;
+
+    /**
+     * Apps targeting Android V or higher receive network callbacks from local networks as default
+     *
+     * Apps targeting lower than {@link android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM} need
+     * to add {@link android.net.NetworkCapabilities#NET_CAPABILITY_LOCAL_NETWORK} to the
+     * {@link android.net.NetworkCapabilities} of the {@link android.net.NetworkRequest} to receive
+     * {@link android.net.ConnectivityManager.NetworkCallback} from local networks.
+     *
+     * @hide
+     */
+    @ChangeId
+    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    public static final long ENABLE_MATCH_LOCAL_NETWORK = 319212206L;
+
     private ConnectivityCompatChanges() {
     }
 }
diff --git a/netbpfload/Android.bp b/netbpfload/Android.bp
index f397b37..c39b46c 100644
--- a/netbpfload/Android.bp
+++ b/netbpfload/Android.bp
@@ -19,19 +19,15 @@
 }
 
 install_symlink {
-    name: "platform_ethtool_symlink",
+    name: "mainline_tethering_platform_components",
+
     symlink_target: "/apex/com.android.tethering/bin/ethtool",
     // installed_location is relative to /system because that's the default partition for soong
     // modules, unless we add something like `system_ext_specific: true` like in hwservicemanager.
     installed_location: "bin/ethtool",
-}
 
-phony {
-    name: "mainline_tethering_platform_components",
-    required: [
-        "netbpfload",
-        "platform_ethtool_symlink",
-    ],
+    init_rc: ["netbpfload.rc"],
+    required: ["bpfloader"],
 }
 
 cc_binary {
@@ -65,9 +61,7 @@
     // module "netbpfload" variant "android_x86_apex30": should support
     // min_sdk_version(30) for "com.android.tethering": newer SDK(34).
     min_sdk_version: "30",
-
-    init_rc: ["netbpfload.rc"],
-    required: ["bpfloader"],
+    installable: false,
 }
 
 // Versioned netbpfload init rc: init system will process it only on api T/33+ devices
diff --git a/netbpfload/NetBpfLoad.cpp b/netbpfload/NetBpfLoad.cpp
index ed7d048..0688e88 100644
--- a/netbpfload/NetBpfLoad.cpp
+++ b/netbpfload/NetBpfLoad.cpp
@@ -171,8 +171,6 @@
 
 #define APEX_MOUNT_POINT "/apex/com.android.tethering"
 const char * const platformBpfLoader = "/system/bin/bpfloader";
-const char * const platformNetBpfLoad = "/system/bin/netbpfload";
-const char * const apexNetBpfLoad = APEX_MOUNT_POINT "/bin/netbpfload";
 
 int logTetheringApexVersion(void) {
     char * found_blockdev = NULL;
@@ -232,12 +230,6 @@
 
     ALOGI("NetBpfLoad '%s' starting...", argv[0]);
 
-    // true iff we are running from the module
-    const bool is_mainline = !strcmp(argv[0], apexNetBpfLoad);
-
-    // true iff we are running from the platform
-    const bool is_platform = !strcmp(argv[0], platformNetBpfLoad);
-
     const int device_api_level = android_get_device_api_level();
     const bool isAtLeastT = (device_api_level >= __ANDROID_API_T__);
     const bool isAtLeastU = (device_api_level >= __ANDROID_API_U__);
@@ -248,24 +240,11 @@
     // first in U QPR2 beta~2
     const bool has_platform_netbpfload_rc = exists("/system/etc/init/netbpfload.rc");
 
-    ALOGI("NetBpfLoad api:%d/%d kver:%07x platform:%d mainline:%d rc:%d%d",
+    ALOGI("NetBpfLoad api:%d/%d kver:%07x rc:%d%d",
           android_get_application_target_sdk_version(), device_api_level,
-          android::bpf::kernelVersion(), is_platform, is_mainline,
+          android::bpf::kernelVersion(),
           has_platform_bpfloader_rc, has_platform_netbpfload_rc);
 
-    if (!is_platform && !is_mainline) {
-        ALOGE("Unable to determine if we're platform or mainline netbpfload.");
-        return 1;
-    }
-
-    if (is_platform) {
-        ALOGI("Executing apex netbpfload...");
-        const char * args[] = { apexNetBpfLoad, NULL, };
-        execve(args[0], (char**)args, envp);
-        ALOGE("exec '%s' fail: %d[%s]", apexNetBpfLoad, errno, strerror(errno));
-        return 1;
-    }
-
     if (!has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
         ALOGE("Unable to find platform's bpfloader & netbpfload init scripts.");
         return 1;
@@ -278,7 +257,7 @@
 
     logTetheringApexVersion();
 
-    if (is_mainline && has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
+    if (has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
         // Tethering apex shipped initrc file causes us to reach here
         // but we're not ready to correctly handle anything before U QPR2
         // in which the 'bpfloader' vs 'netbpfload' split happened
diff --git a/netbpfload/loader.cpp b/netbpfload/loader.cpp
index c534b2c..013309e 100644
--- a/netbpfload/loader.cpp
+++ b/netbpfload/loader.cpp
@@ -31,13 +31,13 @@
 #include <sys/wait.h>
 #include <unistd.h>
 
-// This is BpfLoader v0.41
+// This is Network BpfLoader v0.42
 // WARNING: If you ever hit cherrypick conflicts here you're doing it wrong:
 // You are NOT allowed to cherrypick bpfloader related patches out of order.
 // (indeed: cherrypicking is probably a bad idea and you should merge instead)
 // Mainline supports ONLY the published versions of the bpfloader for each Android release.
 #define BPFLOADER_VERSION_MAJOR 0u
-#define BPFLOADER_VERSION_MINOR 41u
+#define BPFLOADER_VERSION_MINOR 42u
 #define BPFLOADER_VERSION ((BPFLOADER_VERSION_MAJOR << 16) | BPFLOADER_VERSION_MINOR)
 
 #include "BpfSyscallWrappers.h"
@@ -45,10 +45,6 @@
 #include "bpf/bpf_map_def.h"
 #include "loader.h"
 
-#if BPFLOADER_VERSION < COMPILE_FOR_BPFLOADER_VERSION
-#error "BPFLOADER_VERSION is less than COMPILE_FOR_BPFLOADER_VERSION"
-#endif
-
 #include <cstdlib>
 #include <fstream>
 #include <iostream>
@@ -413,9 +409,6 @@
                         size_t sizeOfBpfProgDef) {
     vector<char> pdData;
     int ret = readSectionByName("progs", elfFile, pdData);
-    // Older file formats do not require a 'progs' section at all.
-    // (We should probably figure out whether this is behaviour which is safe to remove now.)
-    if (ret == -2) return 0;
     if (ret) return ret;
 
     if (pdData.size() % sizeOfBpfProgDef) {
@@ -574,6 +567,14 @@
 
 static bool mapMatchesExpectations(const unique_fd& fd, const string& mapName,
                                    const struct bpf_map_def& mapDef, const enum bpf_map_type type) {
+    // bpfGetFd... family of functions require at minimum a 4.14 kernel,
+    // so on 4.9-T kernels just pretend the map matches our expectations.
+    // Additionally we'll get almost equivalent test coverage on newer devices/kernels.
+    // This is because the primary failure mode we're trying to detect here
+    // is either a source code misconfiguration (which is likely kernel independent)
+    // or a newly introduced kernel feature/bug (which is unlikely to get backported to 4.9).
+    if (!isAtLeastKernelVersion(4, 14, 0)) return true;
+
     // Assuming fd is a valid Bpf Map file descriptor then
     // all the following should always succeed on a 4.14+ kernel.
     // If they somehow do fail, they'll return -1 (and set errno),
@@ -711,6 +712,16 @@
         }
 
         enum bpf_map_type type = md[i].type;
+        if (type == BPF_MAP_TYPE_DEVMAP && !isAtLeastKernelVersion(4, 14, 0)) {
+            // On Linux Kernels older than 4.14 this map type doesn't exist, but it can kind
+            // of be approximated: ARRAY has the same userspace api, though it is not usable
+            // by the same ebpf programs.  However, that's okay because the bpf_redirect_map()
+            // helper doesn't exist on 4.9-T anyway (so the bpf program would fail to load,
+            // and thus needs to be tagged as 4.14+ either way), so there's nothing useful you
+            // could do with a DEVMAP anyway (that isn't already provided by an ARRAY)...
+            // Hence using an ARRAY instead of a DEVMAP simply makes life easier for userspace.
+            type = BPF_MAP_TYPE_ARRAY;
+        }
         if (type == BPF_MAP_TYPE_DEVMAP_HASH && !isAtLeastKernelVersion(5, 4, 0)) {
             // On Linux Kernels older than 5.4 this map type doesn't exist, but it can kind
             // of be approximated: HASH has the same userspace visible api.
@@ -766,7 +777,8 @@
               .max_entries = max_entries,
               .map_flags = md[i].map_flags,
             };
-            strlcpy(req.map_name, mapNames[i].c_str(), sizeof(req.map_name));
+            if (isAtLeastKernelVersion(4, 14, 0))
+                strlcpy(req.map_name, mapNames[i].c_str(), sizeof(req.map_name));
             fd.reset(bpf(BPF_MAP_CREATE, req));
             saved_errno = errno;
             ALOGD("bpf_create_map name %s, ret: %d", mapNames[i].c_str(), fd.get());
@@ -1008,7 +1020,8 @@
               .log_size = static_cast<__u32>(log_buf.size()),
               .expected_attach_type = cs[i].expected_attach_type,
             };
-            strlcpy(req.prog_name, cs[i].name.c_str(), sizeof(req.prog_name));
+            if (isAtLeastKernelVersion(4, 14, 0))
+                strlcpy(req.prog_name, cs[i].name.c_str(), sizeof(req.prog_name));
             fd.reset(bpf(BPF_PROG_LOAD, req));
 
             ALOGD("BPF_PROG_LOAD call for %s (%s) returned fd: %d (%s)", elfPath,
diff --git a/netbpfload/netbpfload.rc b/netbpfload/netbpfload.rc
index 14181dc..e1af47f 100644
--- a/netbpfload/netbpfload.rc
+++ b/netbpfload/netbpfload.rc
@@ -17,15 +17,18 @@
 on load_bpf_programs
     exec_start bpfloader
 
-service bpfloader /system/bin/netbpfload
+# Note: This will actually execute /apex/com.android.tethering/bin/netbpfload
+# by virtue of 'service bpfloader' being overridden by the apex shipped .rc
+# Warning: most of the below settings are irrelevant unless the apex is missing.
+service bpfloader /system/bin/false
     # netbpfload will do network bpf loading, then execute /system/bin/bpfloader
-    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    #! capabilities CHOWN SYS_ADMIN NET_ADMIN
     # The following group memberships are a workaround for lack of DAC_OVERRIDE
     # and allow us to open (among other things) files that we created and are
     # no longer root owned (due to CHOWN) but still have group read access to
     # one of the following groups.  This is not perfect, but a more correct
     # solution requires significantly more effort to implement.
-    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
+    #! group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
     user root
     #
     # Set RLIMIT_MEMLOCK to 1GiB for bpfloader
@@ -55,7 +58,7 @@
     #
     # As such we simply use 1GiB as a reasonable approximation of infinity.
     #
-    rlimit memlock 1073741824 1073741824
+    #! rlimit memlock 1073741824 1073741824
     oneshot
     #
     # How to debug bootloops caused by 'bpfloader-failed'.
@@ -81,6 +84,5 @@
     #    'cannot prove return value is 0 or 1' or 'unsupported / unknown operation / helper',
     #    'invalid bpf_context access', etc.
     #
-    reboot_on_failure reboot,bpfloader-failed
-    # we're not really updatable, but want to be able to load bpf programs shipped in apexes
+    reboot_on_failure reboot,netbpfload-missing
     updatable
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
index c162bcc..98c2d86 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -241,13 +241,10 @@
         }
 
         @Override
-        public void onDestroyed(@NonNull MdnsInterfaceSocket socket) {
-            for (int i = mAdvertiserRequests.size() - 1; i >= 0; i--) {
-                if (mAdvertiserRequests.valueAt(i).onAdvertiserDestroyed(socket)) {
-                    mAdvertiserRequests.removeAt(i);
-                }
-            }
-            mAllAdvertisers.remove(socket);
+        public void onAllServicesRemoved(@NonNull MdnsInterfaceSocket socket) {
+            if (DBG) { mSharedLog.i("onAllServicesRemoved: " + socket); }
+            // Try destroying the advertiser if all services has been removed
+            destroyAdvertiser(socket, false /* interfaceDestroyed */);
         }
     };
 
@@ -318,6 +315,30 @@
     }
 
     /**
+     * Destroys the advertiser for the interface indicated by {@code socket}.
+     *
+     * {@code interfaceDestroyed} should be set to {@code true} if this method is called because
+     * the associated interface has been destroyed.
+     */
+    private void destroyAdvertiser(MdnsInterfaceSocket socket, boolean interfaceDestroyed) {
+        InterfaceAdvertiserRequest advertiserRequest;
+
+        MdnsInterfaceAdvertiser advertiser = mAllAdvertisers.remove(socket);
+        if (advertiser != null) {
+            advertiser.destroyNow();
+            if (DBG) { mSharedLog.i("MdnsInterfaceAdvertiser is destroyed: " + advertiser); }
+        }
+
+        for (int i = mAdvertiserRequests.size() - 1; i >= 0; i--) {
+            advertiserRequest = mAdvertiserRequests.valueAt(i);
+            if (advertiserRequest.onAdvertiserDestroyed(socket, interfaceDestroyed)) {
+                if (DBG) { mSharedLog.i("AdvertiserRequest is removed: " + advertiserRequest); }
+                mAdvertiserRequests.removeAt(i);
+            }
+        }
+    }
+
+    /**
      * A request for a {@link MdnsInterfaceAdvertiser}.
      *
      * This class tracks services to be advertised on all sockets provided via a registered
@@ -336,13 +357,22 @@
         }
 
         /**
-         * Called when an advertiser was destroyed, after all services were unregistered and it sent
-         * exit announcements, or the interface is gone.
+         * Called when the interface advertiser associated with {@code socket} has been destroyed.
          *
-         * @return true if this {@link InterfaceAdvertiserRequest} should now be deleted.
+         * {@code interfaceDestroyed} should be set to {@code true} if this method is called because
+         * the associated interface has been destroyed.
+         *
+         * @return true if the {@link InterfaceAdvertiserRequest} should now be deleted
          */
-        boolean onAdvertiserDestroyed(@NonNull MdnsInterfaceSocket socket) {
+        boolean onAdvertiserDestroyed(
+                @NonNull MdnsInterfaceSocket socket, boolean interfaceDestroyed) {
             final MdnsInterfaceAdvertiser removedAdvertiser = mAdvertisers.remove(socket);
+            if (removedAdvertiser != null
+                    && !interfaceDestroyed && mPendingRegistrations.size() > 0) {
+                mSharedLog.wtf(
+                        "unexpected onAdvertiserDestroyed() when there are pending registrations");
+            }
+
             if (mMdnsFeatureFlags.mIsMdnsOffloadFeatureEnabled && removedAdvertiser != null) {
                 final String interfaceName = removedAdvertiser.getSocketInterfaceName();
                 // If the interface is destroyed, stop all hardware offloading on that
@@ -528,7 +558,7 @@
         public void onInterfaceDestroyed(@NonNull SocketKey socketKey,
                 @NonNull MdnsInterfaceSocket socket) {
             final MdnsInterfaceAdvertiser advertiser = mAdvertisers.get(socket);
-            if (advertiser != null) advertiser.destroyNow();
+            if (advertiser != null) destroyAdvertiser(socket, true /* interfaceDestroyed */);
         }
 
         @Override
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
index c2363c0..c1c7d5f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
@@ -102,12 +102,15 @@
                 @NonNull MdnsInterfaceAdvertiser advertiser, int serviceId, int conflictType);
 
         /**
-         * Called by the advertiser when it destroyed itself.
+         * Called when all services on this interface advertiser has already been removed and exit
+         * announcements have been sent.
          *
-         * This can happen after a call to {@link #destroyNow()}, or after all services were
-         * unregistered and the advertiser finished sending exit announcements.
+         * <p>It's guaranteed that there are no service registrations in the
+         * MdnsInterfaceAdvertiser when this callback is invoked.
+         *
+         * <p>This is typically listened by the {@link MdnsAdvertiser} to release the resources
          */
-        void onDestroyed(@NonNull MdnsInterfaceSocket socket);
+        void onAllServicesRemoved(@NonNull MdnsInterfaceSocket socket);
     }
 
     /**
@@ -149,10 +152,11 @@
         public void onFinished(@NonNull BaseAnnouncementInfo info) {
             if (info instanceof MdnsAnnouncer.ExitAnnouncementInfo) {
                 mRecordRepository.removeService(info.getServiceId());
-
-                if (mRecordRepository.getServicesCount() == 0) {
-                    destroyNow();
-                }
+                mCbHandler.post(() -> {
+                    if (mRecordRepository.getServicesCount() == 0) {
+                        mCb.onAllServicesRemoved(mSocket);
+                    }
+                });
             }
         }
     }
@@ -234,8 +238,7 @@
      * Start the advertiser.
      *
      * The advertiser will stop itself when all services are removed and exit announcements sent,
-     * notifying via {@link Callback#onDestroyed}. This can also be triggered manually via
-     * {@link #destroyNow()}.
+     * notifying via {@link Callback#onAllServicesRemoved}.
      */
     public void start() {
         mSocket.addPacketHandler(this);
@@ -283,8 +286,8 @@
         mAnnouncer.stop(id);
         final MdnsAnnouncer.ExitAnnouncementInfo exitInfo = mRecordRepository.exitService(id);
         if (exitInfo != null) {
-            // This effectively schedules destroyNow(), as it is to be called when the exit
-            // announcement finishes if there is no service left.
+            // This effectively schedules onAllServicesRemoved(), as it is to be called when the
+            // exit announcement finishes if there is no service left.
             // A non-zero exit announcement delay follows legacy mdnsresponder behavior, and is
             // also useful to ensure that when a host receives the exit announcement, the service
             // has been unregistered on all interfaces; so an announcement sent from interface A
@@ -294,9 +297,11 @@
         } else {
             // No exit announcement necessary: remove the service immediately.
             mRecordRepository.removeService(id);
-            if (mRecordRepository.getServicesCount() == 0) {
-                destroyNow();
-            }
+            mCbHandler.post(() -> {
+                if (mRecordRepository.getServicesCount() == 0) {
+                    mCb.onAllServicesRemoved(mSocket);
+                }
+            });
         }
     }
 
@@ -330,7 +335,8 @@
     /**
      * Destroy the advertiser immediately, not sending any exit announcement.
      *
-     * <p>Useful when the underlying network went away. This will trigger an onDestroyed callback.
+     * <p>This is typically called when all services on the interface are removed or when the
+     * underlying network went away.
      */
     public void destroyNow() {
         for (int serviceId : mRecordRepository.clearServices()) {
@@ -339,7 +345,6 @@
         }
         mReplySender.cancelAll();
         mSocket.removePacketHandler(this);
-        mCbHandler.post(() -> mCb.onDestroyed(mSocket));
     }
 
     /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java
index f60a95e..1ec9e39 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceInfo.java
@@ -94,57 +94,6 @@
     @NonNull
     private final Instant expirationTime;
 
-    /** Constructs a {@link MdnsServiceInfo} object with default values. */
-    public MdnsServiceInfo(
-            String serviceInstanceName,
-            String[] serviceType,
-            @Nullable List<String> subtypes,
-            String[] hostName,
-            int port,
-            @Nullable String ipv4Address,
-            @Nullable String ipv6Address,
-            @Nullable List<String> textStrings) {
-        this(
-                serviceInstanceName,
-                serviceType,
-                subtypes,
-                hostName,
-                port,
-                List.of(ipv4Address),
-                List.of(ipv6Address),
-                textStrings,
-                /* textEntries= */ null,
-                /* interfaceIndex= */ INTERFACE_INDEX_UNSPECIFIED,
-                /* network= */ null,
-                /* expirationTime= */ Instant.MAX);
-    }
-
-    /** Constructs a {@link MdnsServiceInfo} object with default values. */
-    public MdnsServiceInfo(
-            String serviceInstanceName,
-            String[] serviceType,
-            List<String> subtypes,
-            String[] hostName,
-            int port,
-            @Nullable String ipv4Address,
-            @Nullable String ipv6Address,
-            @Nullable List<String> textStrings,
-            @Nullable List<TextEntry> textEntries) {
-        this(
-                serviceInstanceName,
-                serviceType,
-                subtypes,
-                hostName,
-                port,
-                List.of(ipv4Address),
-                List.of(ipv6Address),
-                textStrings,
-                textEntries,
-                /* interfaceIndex= */ INTERFACE_INDEX_UNSPECIFIED,
-                /* network= */ null,
-                /* expirationTime= */ Instant.MAX);
-    }
-
     /**
      * Constructs a {@link MdnsServiceInfo} object with default values.
      *
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java
index c51811b..653ea6c 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocket.java
@@ -58,7 +58,6 @@
     MdnsSocket(@NonNull MulticastNetworkInterfaceProvider multicastNetworkInterfaceProvider,
             MulticastSocket multicastSocket, SharedLog sharedLog) throws IOException {
         this.multicastNetworkInterfaceProvider = multicastNetworkInterfaceProvider;
-        this.multicastNetworkInterfaceProvider.startWatchingConnectivityChanges();
         this.multicastSocket = multicastSocket;
         this.sharedLog = sharedLog;
         // RFC Spec: https://tools.ietf.org/html/rfc6762
@@ -120,7 +119,6 @@
     public void close() {
         // This is a race with the use of the file descriptor (b/27403984).
         multicastSocket.close();
-        multicastNetworkInterfaceProvider.stopWatchingConnectivityChanges();
     }
 
     /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
index 82c8c5b..7b71e43 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
@@ -106,6 +106,7 @@
     @Nullable private Timer checkMulticastResponseTimer;
     private final SharedLog sharedLog;
     @NonNull private final MdnsFeatureFlags mdnsFeatureFlags;
+    private final MulticastNetworkInterfaceProvider interfaceProvider;
 
     public MdnsSocketClient(@NonNull Context context, @NonNull MulticastLock multicastLock,
             SharedLog sharedLog, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
@@ -118,6 +119,7 @@
             unicastReceiverBuffer = null;
         }
         this.mdnsFeatureFlags = mdnsFeatureFlags;
+        this.interfaceProvider = new MulticastNetworkInterfaceProvider(context, sharedLog);
     }
 
     @Override
@@ -138,6 +140,7 @@
         cannotReceiveMulticastResponse.set(false);
 
         shouldStopSocketLoop = false;
+        interfaceProvider.startWatchingConnectivityChanges();
         try {
             // TODO (changed when importing code): consider setting thread stats tag
             multicastSocket = createMdnsSocket(MdnsConstants.MDNS_PORT, sharedLog);
@@ -183,6 +186,7 @@
         }
 
         multicastLock.release();
+        interfaceProvider.stopWatchingConnectivityChanges();
 
         shouldStopSocketLoop = true;
         waitForSendThreadToStop();
@@ -482,8 +486,7 @@
 
     @VisibleForTesting
     MdnsSocket createMdnsSocket(int port, SharedLog sharedLog) throws IOException {
-        return new MdnsSocket(new MulticastNetworkInterfaceProvider(context, sharedLog), port,
-                sharedLog);
+        return new MdnsSocket(interfaceProvider, port, sharedLog);
     }
 
     private void sendPackets(List<DatagramPacket> packets, MdnsSocket socket) {
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 458d64f..bfcc171 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -382,10 +382,9 @@
         });
     }
 
-    @VisibleForTesting(visibility = PACKAGE)
-    protected void setInterfaceEnabled(@NonNull final String iface, boolean enabled,
-            @Nullable final EthernetCallback cb) {
-        mHandler.post(() -> updateInterfaceState(iface, enabled, cb));
+    /** Configure the administrative state of ethernet interface by toggling IFF_UP. */
+    public void setInterfaceEnabled(String iface, boolean enabled, EthernetCallback cb) {
+        mHandler.post(() -> setInterfaceAdministrativeState(iface, enabled, cb));
     }
 
     IpConfiguration getIpConfiguration(String iface) {
@@ -643,25 +642,40 @@
         }
     }
 
-    private void updateInterfaceState(String iface, boolean up) {
-        updateInterfaceState(iface, up, new EthernetCallback(null /* cb */));
-    }
-
-    // TODO(b/225315248): enable/disableInterface() should not affect link state.
-    private void updateInterfaceState(String iface, boolean up, EthernetCallback cb) {
-        final int mode = getInterfaceMode(iface);
-        if (mode == INTERFACE_MODE_SERVER || !mFactory.hasInterface(iface)) {
-            // The interface is in server mode or is not tracked.
-            cb.onError("Failed to set link state " + (up ? "up" : "down") + " for " + iface);
+    private void setInterfaceAdministrativeState(String iface, boolean up, EthernetCallback cb) {
+        if (getInterfaceState(iface) == EthernetManager.STATE_ABSENT) {
+            cb.onError("Failed to enable/disable absent interface: " + iface);
+            return;
+        }
+        if (getInterfaceRole(iface) == EthernetManager.ROLE_SERVER) {
+            // TODO: support setEthernetState for server mode interfaces.
+            cb.onError("Failed to enable/disable interface in server mode: " + iface);
             return;
         }
 
+        if (up) {
+            // WARNING! setInterfaceUp() clears the IPv4 address and readds it. Calling
+            // enableInterface() on an active interface can lead to a provisioning failure which
+            // will cause IpClient to be restarted.
+            // TODO: use netlink directly rather than calling into netd.
+            NetdUtils.setInterfaceUp(mNetd, iface);
+        } else {
+            NetdUtils.setInterfaceDown(mNetd, iface);
+        }
+        cb.onResult(iface);
+    }
+
+    private void updateInterfaceState(String iface, boolean up) {
+        final int mode = getInterfaceMode(iface);
+        if (mode == INTERFACE_MODE_SERVER) {
+            // TODO: support tracking link state for interfaces in server mode.
+            return;
+        }
+
+        // If updateInterfaceLinkState returns false, the interface is already in the correct state.
         if (mFactory.updateInterfaceLinkState(iface, up)) {
             broadcastInterfaceStateChange(iface);
         }
-        // If updateInterfaceLinkState returns false, the interface is already in the correct state.
-        // Always return success.
-        cb.onResult(iface);
     }
 
     private void maybeUpdateServerModeInterfaceState(String iface, boolean available) {
diff --git a/service-t/src/com/android/server/net/NetworkStatsObservers.java b/service-t/src/com/android/server/net/NetworkStatsObservers.java
index 21cf351..cab29e3 100644
--- a/service-t/src/com/android/server/net/NetworkStatsObservers.java
+++ b/service-t/src/com/android/server/net/NetworkStatsObservers.java
@@ -32,7 +32,6 @@
 import android.net.NetworkTemplate;
 import android.net.netstats.IUsageCallback;
 import android.os.Handler;
-import android.os.HandlerThread;
 import android.os.IBinder;
 import android.os.Looper;
 import android.os.Message;
@@ -46,6 +45,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.PerUidCounter;
 
+import java.util.Objects;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -78,8 +78,11 @@
     // Sequence number of DataUsageRequests
     private final AtomicInteger mNextDataUsageRequestId = new AtomicInteger();
 
-    // Lazily instantiated when an observer is registered.
-    private volatile Handler mHandler;
+    private final Handler mHandler;
+
+    NetworkStatsObservers(@NonNull Looper looper) {
+        mHandler = new Handler(Objects.requireNonNull(looper), mHandlerCallback);
+    }
 
     /**
      * Creates a wrapper that contains the caller context and a normalized request.
@@ -100,7 +103,7 @@
         if (LOG) Log.d(TAG, "Registering observer for " + requestInfo);
         mDataUsageRequestsPerUid.incrementCountOrThrow(callingUid);
 
-        getHandler().sendMessage(mHandler.obtainMessage(MSG_REGISTER, requestInfo));
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_REGISTER, requestInfo));
         return request;
     }
 
@@ -110,7 +113,7 @@
      * <p>It will unregister the observer asynchronously, so it is safe to call from any thread.
      */
     public void unregister(DataUsageRequest request, int callingUid) {
-        getHandler().sendMessage(mHandler.obtainMessage(MSG_UNREGISTER, callingUid, 0 /* ignore */,
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_UNREGISTER, callingUid, 0 /* ignore */,
                 request));
     }
 
@@ -125,34 +128,10 @@
                 long currentTime) {
         StatsContext statsContext = new StatsContext(xtSnapshot, uidSnapshot, activeIfaces,
                 activeUidIfaces, currentTime);
-        getHandler().sendMessage(mHandler.obtainMessage(MSG_UPDATE_STATS, statsContext));
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_UPDATE_STATS, statsContext));
     }
 
-    private Handler getHandler() {
-        if (mHandler == null) {
-            synchronized (this) {
-                if (mHandler == null) {
-                    if (LOGV) Log.v(TAG, "Creating handler");
-                    mHandler = new Handler(getHandlerLooperLocked(), mHandlerCallback);
-                }
-            }
-        }
-        return mHandler;
-    }
-
-    @VisibleForTesting
-    protected Looper getHandlerLooperLocked() {
-        // TODO: Currently, callbacks are dispatched on this thread if the caller register
-        //  callback without supplying a Handler. To ensure that the service handler thread
-        //  is not blocked by client code, the observers must create their own thread. Once
-        //  all callbacks are dispatched outside of the handler thread, the service handler
-        //  thread can be used here.
-        HandlerThread handlerThread = new HandlerThread(TAG);
-        handlerThread.start();
-        return handlerThread.getLooper();
-    }
-
-    private Handler.Callback mHandlerCallback = new Handler.Callback() {
+    private final Handler.Callback mHandlerCallback = new Handler.Callback() {
         @Override
         public boolean handleMessage(Message msg) {
             switch (msg.what) {
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 9684d18..64b17eb 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -593,7 +593,7 @@
                 INetd.Stub.asInterface((IBinder) context.getSystemService(Context.NETD_SERVICE)),
                 alarmManager, wakeLock, getDefaultClock(),
                 new DefaultNetworkStatsSettings(), new NetworkStatsFactory(context),
-                new NetworkStatsObservers(), new Dependencies());
+                new Dependencies());
 
         return service;
     }
@@ -603,8 +603,7 @@
     @VisibleForTesting
     NetworkStatsService(Context context, INetd netd, AlarmManager alarmManager,
             PowerManager.WakeLock wakeLock, Clock clock, NetworkStatsSettings settings,
-            NetworkStatsFactory factory, NetworkStatsObservers statsObservers,
-            @NonNull Dependencies deps) {
+            NetworkStatsFactory factory, @NonNull Dependencies deps) {
         mContext = Objects.requireNonNull(context, "missing Context");
         mNetd = Objects.requireNonNull(netd, "missing Netd");
         mAlarmManager = Objects.requireNonNull(alarmManager, "missing AlarmManager");
@@ -612,7 +611,6 @@
         mSettings = Objects.requireNonNull(settings, "missing NetworkStatsSettings");
         mWakeLock = Objects.requireNonNull(wakeLock, "missing WakeLock");
         mStatsFactory = Objects.requireNonNull(factory, "missing factory");
-        mStatsObservers = Objects.requireNonNull(statsObservers, "missing NetworkStatsObservers");
         mDeps = Objects.requireNonNull(deps, "missing Dependencies");
         mStatsDir = mDeps.getOrCreateStatsDir();
         if (!mStatsDir.exists()) {
@@ -622,6 +620,7 @@
         final HandlerThread handlerThread = mDeps.makeHandlerThread();
         handlerThread.start();
         mHandler = new NetworkStatsHandler(handlerThread.getLooper());
+        mStatsObservers = new NetworkStatsObservers(handlerThread.getLooper());
         mNetworkStatsSubscriptionsMonitor = deps.makeSubscriptionsMonitor(mContext,
                 (command) -> mHandler.post(command) , this);
         mContentResolver = mContext.getContentResolver();
diff --git a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
index 8598ac4..ca97d07 100644
--- a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
+++ b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
@@ -19,12 +19,13 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.net.NetworkStats;
+import android.util.LruCache;
 
 import com.android.internal.annotations.GuardedBy;
 
 import java.time.Clock;
-import java.util.HashMap;
 import java.util.Objects;
+import java.util.function.Supplier;
 
 /**
  * A thread-safe cache for storing and retrieving {@link NetworkStats.Entry} objects,
@@ -39,10 +40,12 @@
      *
      * @param clock The {@link Clock} to use for determining timestamps.
      * @param expiryDurationMs The expiry duration in milliseconds.
+     * @param maxSize Maximum number of entries.
      */
-    TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs) {
+    TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs, int maxSize) {
         mClock = clock;
         mExpiryDurationMs = expiryDurationMs;
+        mMap = new LruCache<>(maxSize);
     }
 
     private static class TrafficStatsCacheKey {
@@ -81,7 +84,7 @@
     }
 
     @GuardedBy("mMap")
-    private final HashMap<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap = new HashMap<>();
+    private final LruCache<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap;
 
     /**
      * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
@@ -105,6 +108,36 @@
     }
 
     /**
+     * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
+     * If the entry is not found in the cache or has expired, computes it using the provided
+     * {@code supplier} and stores the result in the cache.
+     *
+     * @param iface The interface name to include in the cache key. {@code IFACE_ALL}
+     *              if not applicable.
+     * @param uid The UID to include in the cache key. {@code UID_ALL} if not applicable.
+     * @param supplier The {@link Supplier} to compute the {@link NetworkStats.Entry} if not found.
+     * @return The cached or computed {@link NetworkStats.Entry}, or null if not found, expired,
+     *         or if the {@code supplier} returns null.
+     */
+    @Nullable
+    NetworkStats.Entry getOrCompute(String iface, int uid,
+            @NonNull Supplier<NetworkStats.Entry> supplier) {
+        synchronized (mMap) {
+            final NetworkStats.Entry cachedValue = get(iface, uid);
+            if (cachedValue != null) {
+                return cachedValue;
+            }
+
+            // Entry not found or expired, compute it
+            final NetworkStats.Entry computedEntry = supplier.get();
+            if (computedEntry != null && !computedEntry.isEmpty()) {
+                put(iface, uid, computedEntry);
+            }
+            return computedEntry;
+        }
+    }
+
+    /**
      * Stores a {@link NetworkStats.Entry} in the cache, associated with the given key.
      *
      * @param iface The interface name to include in the cache key. Null if not applicable.
@@ -124,7 +157,7 @@
      */
     void clear() {
         synchronized (mMap) {
-            mMap.clear();
+            mMap.evictAll();
         }
     }
 
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index fe3bbbd..f27e645 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -97,6 +97,8 @@
 import static android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY;
 import static android.net.OemNetworkPreferences.OEM_NETWORK_PREFERENCE_TEST;
 import static android.net.OemNetworkPreferences.OEM_NETWORK_PREFERENCE_TEST_ONLY;
+import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK;
+import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION;
 import static android.os.Process.INVALID_UID;
 import static android.os.Process.VPN_UID;
 import static android.system.OsConstants.ETH_P_ALL;
@@ -115,6 +117,9 @@
 import static com.android.net.module.util.PermissionUtils.hasAnyPermissionOf;
 import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE;
 import static com.android.server.connectivity.ConnectivityFlags.REQUEST_RESTRICTED_WIFI;
+import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
+
+import static java.util.Map.Entry;
 
 import android.Manifest;
 import android.annotation.CheckResult;
@@ -214,7 +219,6 @@
 import android.net.Uri;
 import android.net.VpnManager;
 import android.net.VpnTransportInfo;
-import android.net.connectivity.ConnectivityCompatChanges;
 import android.net.metrics.IpConnectivityLog;
 import android.net.metrics.NetworkEvent;
 import android.net.netd.aidl.NativeUidRangeConfig;
@@ -1001,6 +1005,9 @@
     // Uids that ConnectivityService is pending to close sockets of.
     private final Set<Integer> mPendingFrozenUids = new ArraySet<>();
 
+    // Flag to drop packets to VPN addresses ingressing via non-VPN interfaces.
+    private final boolean mIngressToVpnAddressFiltering;
+
     /**
      * Implements support for the legacy "one network per network type" model.
      *
@@ -1978,6 +1985,8 @@
             activityManager.registerUidFrozenStateChangedCallback(
                     (Runnable r) -> r.run(), frozenStateChangedCallback);
         }
+        mIngressToVpnAddressFiltering = mDeps.isAtLeastT()
+                && mDeps.isFeatureNotChickenedOut(mContext, INGRESS_TO_VPN_ADDRESS_FILTERING);
     }
 
     /**
@@ -2607,7 +2616,7 @@
                 // Not the system, so it's an app requesting on its own behalf.
                 type = RequestType.RT_APP.getNumber();
             }
-            countPerType.put(type, countPerType.get(type, 0));
+            countPerType.put(type, countPerType.get(type, 0) + 1);
         }
         for (int i = countPerType.size() - 1; i >= 0; --i) {
             final RequestCountForType.Builder r = RequestCountForType.newBuilder();
@@ -2765,6 +2774,7 @@
 
     private boolean canSeeAllowedUids(final int pid, final int uid, final int netOwnerUid) {
         return Process.SYSTEM_UID == uid
+                || netOwnerUid == uid
                 || hasAnyPermissionOf(mContext, pid, uid,
                         android.Manifest.permission.NETWORK_FACTORY);
     }
@@ -2792,7 +2802,6 @@
         }
         if (!canSeeAllowedUids(callerPid, callerUid, newNc.getOwnerUid())) {
             newNc.setAllowedUids(new ArraySet<>());
-            newNc.setSubscriptionIds(Collections.emptySet());
         }
         redactUnderlyingNetworksForCapabilities(newNc, callerPid, callerUid);
 
@@ -3020,6 +3029,23 @@
         }
     }
 
+    private void maybeDisableLocalNetworkMatching(NetworkCapabilities nc, int callingUid) {
+        if (mDeps.isChangeEnabled(ENABLE_MATCH_LOCAL_NETWORK, callingUid)) {
+            return;
+        }
+        // If NET_CAPABILITY_LOCAL_NETWORK is not added to capability, request should not be
+        // satisfied by local networks.
+        if (!nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+            nc.addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        }
+    }
+
+    private void restrictRequestNetworkCapabilitiesForCaller(NetworkCapabilities nc,
+            int callingUid, String callerPackageName) {
+        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callerPackageName);
+        maybeDisableLocalNetworkMatching(nc, callingUid);
+    }
+
     @Override
     public @RestrictBackgroundStatus int getRestrictBackgroundStatusByCaller() {
         enforceAccessPermission();
@@ -5326,6 +5352,7 @@
         // was is being disconnected the callbacks have already been sent, and if it is being
         // destroyed pending replacement they will be sent when it is disconnected.
         maybeDisableForwardRulesForDisconnectingNai(nai, false /* sendCallbacks */);
+        updateIngressToVpnAddressFiltering(null, nai.linkProperties, nai);
         try {
             mNetd.networkDestroy(nai.network.getNetId());
         } catch (RemoteException | ServiceSpecificException e) {
@@ -7574,15 +7601,6 @@
                     "Insufficient permissions to request a specific signal strength");
         }
         mAppOpsManager.checkPackage(callerUid, callerPackageName);
-
-        if (nc.getSubscriptionIds().isEmpty()) {
-            return;
-        }
-        if (mRequestRestrictedWifiEnabled
-                && canRequestRestrictedNetworkDueToCarrierPrivileges(nc, callerUid)) {
-            return;
-        }
-        enforceNetworkFactoryPermission();
     }
 
     private int[] getSignalStrengthThresholds(@NonNull final NetworkAgentInfo nai) {
@@ -7716,10 +7734,12 @@
                 //  the state of the app when the request is filed, but we never change the
                 //  request if the app changes network state. http://b/29964605
                 enforceMeteredApnPolicy(networkCapabilities);
+                maybeDisableLocalNetworkMatching(networkCapabilities, callingUid);
                 break;
             case LISTEN_FOR_BEST:
                 enforceAccessPermission();
                 networkCapabilities = new NetworkCapabilities(networkCapabilities);
+                maybeDisableLocalNetworkMatching(networkCapabilities, callingUid);
                 break;
             default:
                 throw new IllegalArgumentException("Unsupported request type " + reqType);
@@ -7806,7 +7826,7 @@
         final UserHandle user = UserHandle.getUserHandleForUid(callingUid);
         // Only run the check if the change is enabled.
         if (!mDeps.isChangeEnabled(
-                ConnectivityCompatChanges.ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION,
+                ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION,
                 callingPackageName, user)) {
             return false;
         }
@@ -7958,8 +7978,8 @@
         ensureRequestableCapabilities(networkCapabilities);
         ensureSufficientPermissionsForRequest(networkCapabilities,
                 Binder.getCallingPid(), callingUid, callingPackageName);
-        restrictRequestUidsForCallerAndSetRequestorInfo(networkCapabilities,
-                callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(
+                networkCapabilities, callingUid, callingPackageName);
 
         NetworkRequest networkRequest = new NetworkRequest(networkCapabilities, TYPE_NONE,
                 nextNetworkRequestId(), NetworkRequest.Type.REQUEST);
@@ -8019,7 +8039,7 @@
         NetworkCapabilities nc = new NetworkCapabilities(networkCapabilities);
         ensureSufficientPermissionsForRequest(networkCapabilities,
                 Binder.getCallingPid(), callingUid, callingPackageName);
-        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(nc, callingUid, callingPackageName);
         // Apps without the CHANGE_NETWORK_STATE permission can't use background networks, so
         // make all their listens include NET_CAPABILITY_FOREGROUND. That way, they will get
         // onLost and onAvailable callbacks when networks move in and out of the background.
@@ -8052,7 +8072,7 @@
         ensureSufficientPermissionsForRequest(networkCapabilities,
                 Binder.getCallingPid(), callingUid, callingPackageName);
         final NetworkCapabilities nc = new NetworkCapabilities(networkCapabilities);
-        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(nc, callingUid, callingPackageName);
 
         NetworkRequest networkRequest = new NetworkRequest(nc, TYPE_NONE, nextNetworkRequestId(),
                 NetworkRequest.Type.LISTEN);
@@ -8683,6 +8703,8 @@
         // new interface (the interface name -> index map becomes initialized)
         updateVpnFiltering(newLp, oldLp, networkAgent);
 
+        updateIngressToVpnAddressFiltering(newLp, oldLp, networkAgent);
+
         updateMtu(newLp, oldLp);
         // TODO - figure out what to do for clat
 //        for (LinkProperties lp : newLp.getStackedLinks()) {
@@ -8974,6 +8996,87 @@
         }
     }
 
+    /**
+     * Returns ingress discard rules to drop packets to VPN addresses ingressing via non-VPN
+     * interfaces.
+     * Ingress discard rule is added to the address iff
+     *   1. The address is not a link local address
+     *   2. The address is used by a single VPN interface and not used by any other
+     *      interfaces even non-VPN ones
+     * This method can be called during network disconnects, when nai has already been removed from
+     * mNetworkAgentInfos.
+     *
+     * @param nai This method generates rules assuming lp of this nai is the lp at the second
+     *            argument.
+     * @param lp  This method generates rules assuming lp of nai at the first argument is this lp.
+     *            Caller passes old lp to generate old rules and new lp to generate new rules.
+     * @return    ingress discard rules. Set of pairs of addresses and interface names
+     */
+    private Set<Pair<InetAddress, String>> generateIngressDiscardRules(
+            @NonNull final NetworkAgentInfo nai, @Nullable final LinkProperties lp) {
+        Set<NetworkAgentInfo> nais = new ArraySet<>(mNetworkAgentInfos);
+        nais.add(nai);
+        // Determine how many networks each IP address is currently configured on.
+        // Ingress rules are added only for IP addresses that are configured on single interface.
+        final Map<InetAddress, Integer> addressOwnerCounts = new ArrayMap<>();
+        for (final NetworkAgentInfo agent : nais) {
+            if (agent.isDestroyed()) {
+                continue;
+            }
+            final LinkProperties agentLp = (nai == agent) ? lp : agent.linkProperties;
+            if (agentLp == null) {
+                continue;
+            }
+            for (final InetAddress addr: agentLp.getAllAddresses()) {
+                addressOwnerCounts.put(addr, addressOwnerCounts.getOrDefault(addr, 0) + 1);
+            }
+        }
+
+        // Iterates all networks instead of only generating rule for nai that was passed in since
+        // lp of the nai change could cause/resolve address collision and result in affecting rule
+        // for different network.
+        final Set<Pair<InetAddress, String>> ingressDiscardRules = new ArraySet<>();
+        for (final NetworkAgentInfo agent : nais) {
+            if (!agent.isVPN() || agent.isDestroyed()) {
+                continue;
+            }
+            final LinkProperties agentLp = (nai == agent) ? lp : agent.linkProperties;
+            if (agentLp == null || agentLp.getInterfaceName() == null) {
+                continue;
+            }
+
+            for (final InetAddress addr: agentLp.getAllAddresses()) {
+                if (addressOwnerCounts.get(addr) == 1 && !addr.isLinkLocalAddress()) {
+                    ingressDiscardRules.add(new Pair<>(addr, agentLp.getInterfaceName()));
+                }
+            }
+        }
+        return ingressDiscardRules;
+    }
+
+    private void updateIngressToVpnAddressFiltering(@Nullable LinkProperties newLp,
+            @Nullable LinkProperties oldLp, @NonNull NetworkAgentInfo nai) {
+        // Having isAtleastT to avoid NewApi linter error (b/303382209)
+        if (!mIngressToVpnAddressFiltering || !mDeps.isAtLeastT()) {
+            return;
+        }
+        final CompareOrUpdateResult<InetAddress, Pair<InetAddress, String>> ruleDiff =
+                new CompareOrUpdateResult<>(
+                        generateIngressDiscardRules(nai, oldLp),
+                        generateIngressDiscardRules(nai, newLp),
+                        (rule) -> rule.first);
+        for (Pair<InetAddress, String> rule: ruleDiff.removed) {
+            mBpfNetMaps.removeIngressDiscardRule(rule.first);
+        }
+        for (Pair<InetAddress, String> rule: ruleDiff.added) {
+            mBpfNetMaps.setIngressDiscardRule(rule.first, rule.second);
+        }
+        // setIngressDiscardRule overrides the existing rule
+        for (Pair<InetAddress, String> rule: ruleDiff.updated) {
+            mBpfNetMaps.setIngressDiscardRule(rule.first, rule.second);
+        }
+    }
+
     private void updateWakeOnLan(@NonNull LinkProperties lp) {
         if (mWolSupportedInterfaces == null) {
             mWolSupportedInterfaces = new ArraySet<>(mResources.get().getStringArray(
@@ -9164,7 +9267,7 @@
         //   3. The app doesn't have Carrier Privileges
         //   4. The app doesn't have permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS
         for (final NetworkRequest nr : mNetworkRequests.keySet()) {
-            if ((nr.isRequest() || nr.isListen())
+            if (nr.isRequest()
                     && !nr.hasCapability(NET_CAPABILITY_NOT_RESTRICTED)
                     && nr.getRequestorUid() == uid
                     && getSubscriptionIdFromNetworkCaps(nr.networkCapabilities) == subId
@@ -12025,7 +12128,7 @@
         // This NetworkCapabilities is only used for matching to Networks. Clear out its owner uid
         // and administrator uids to be safe.
         final NetworkCapabilities nc = new NetworkCapabilities(request.networkCapabilities);
-        restrictRequestUidsForCallerAndSetRequestorInfo(nc, callingUid, callingPackageName);
+        restrictRequestNetworkCapabilitiesForCaller(nc, callingUid, callingPackageName);
 
         final NetworkRequest requestWithId =
                 new NetworkRequest(
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index bf09160..a55c683 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -38,6 +38,10 @@
 
     public static final String REQUEST_RESTRICTED_WIFI =
             "request_restricted_wifi";
+
+    public static final String INGRESS_TO_VPN_ADDRESS_FILTERING =
+            "ingress_to_vpn_address_filtering";
+
     private boolean mNoRematchAllRequestsOnRegister;
 
     /**
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h b/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
index baff09b..92ddf44 100644
--- a/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
+++ b/staticlibs/native/bpf_headers/include/bpf/bpf_helpers.h
@@ -39,6 +39,15 @@
 // Android U / 14 (api level 34) - various new program types added
 #define BPFLOADER_U_VERSION 37u
 
+// Android V / 15 (api level 35) - platform only
+// (note: the platform bpfloader in V isn't really versioned at all,
+//  as there is no need as it can only load objects compiled at the
+//  same time as itself and the rest of the platform)
+#define BPFLOADER_V_VERSION 41u
+
+// Android Mainline - this bpfloader should eventually go back to T
+#define BPFLOADER_MAINLINE_VERSION 42u
+
 /* For mainline module use, you can #define BPFLOADER_{MIN/MAX}_VER
  * before #include "bpf_helpers.h" to change which bpfloaders will
  * process the resulting .o file.
@@ -48,7 +57,7 @@
  * In which case it's just best to use the default.
  */
 #ifndef BPFLOADER_MIN_VER
-#define BPFLOADER_MIN_VER COMPILE_FOR_BPFLOADER_VERSION
+#define BPFLOADER_MIN_VER BPFLOADER_V_VERSION
 #endif
 
 #ifndef BPFLOADER_MAX_VER
@@ -111,10 +120,12 @@
 #define KVER_NONE KVER_(0)
 #define KVER_4_14 KVER(4, 14, 0)
 #define KVER_4_19 KVER(4, 19, 0)
-#define KVER_5_4 KVER(5, 4, 0)
-#define KVER_5_8 KVER(5, 8, 0)
-#define KVER_5_9 KVER(5, 9, 0)
+#define KVER_5_4  KVER(5, 4, 0)
+#define KVER_5_8  KVER(5, 8, 0)
+#define KVER_5_9  KVER(5, 9, 0)
 #define KVER_5_15 KVER(5, 15, 0)
+#define KVER_6_1  KVER(6, 1, 0)
+#define KVER_6_6  KVER(6, 6, 0)
 #define KVER_INF KVER_(0xFFFFFFFFu)
 
 #define KVER_IS_AT_LEAST(kver, a, b, c) ((kver).kver >= KVER(a, b, c).kver)
diff --git a/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h b/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
index ef03c4d..00ef91a 100644
--- a/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
+++ b/staticlibs/native/bpf_headers/include/bpf/bpf_map_def.h
@@ -48,9 +48,6 @@
 #define DEFAULT_SIZEOF_BPF_MAP_DEF 32       // v0.0 struct: enum (uint sized) + 7 uint
 #define DEFAULT_SIZEOF_BPF_PROG_DEF 20      // v0.0 struct: 4 uint + bool + 3 byte alignment pad
 
-// By default, unless otherwise specified, allow the use of features only supported by v0.37.
-#define COMPILE_FOR_BPFLOADER_VERSION 37u
-
 /*
  * The bpf_{map,prog}_def structures are compiled for different architectures.
  * Once by the BPF compiler for the BPF architecture, and once by a C++
diff --git a/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt b/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt
index af4f96d..c6e5f25 100644
--- a/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt
+++ b/staticlibs/testutils/hostdevice/com/android/testutils/ConcurrentUtils.kt
@@ -19,10 +19,77 @@
 package com.android.testutils
 
 import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
 import java.util.concurrent.TimeUnit
+import java.util.function.Consumer
 import kotlin.system.measureTimeMillis
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
 
 // For Java usage
 fun durationOf(fn: Runnable) = measureTimeMillis { fn.run() }
 
 fun CountDownLatch.await(timeoutMs: Long): Boolean = await(timeoutMs, TimeUnit.MILLISECONDS)
+
+/**
+ * Quit resources provided as a list by a supplier.
+ *
+ * The supplier may return more resources as the process progresses, for example while interrupting
+ * threads and waiting for them to finish they may spawn more threads, so this implements a
+ * [maxRetryCount] which, in this case, would be the maximum length of the thread chain that can be
+ * terminated.
+ */
+fun <T> quitResources(
+    maxRetryCount: Int,
+    supplier: () -> List<T>,
+    terminator: Consumer<T>
+) {
+    // Run it multiple times since new threads might be generated in a thread
+    // that is about to be terminated
+    for (retryCount in 0 until maxRetryCount) {
+        val resourcesToBeCleared = supplier()
+        if (resourcesToBeCleared.isEmpty()) return
+        for (resource in resourcesToBeCleared) {
+            terminator.accept(resource)
+        }
+    }
+    assertEmpty(supplier())
+}
+
+/**
+ * Implementation of [quitResources] to interrupt and wait for [ExecutorService]s to finish.
+ */
+@JvmOverloads
+fun quitExecutorServices(
+    maxRetryCount: Int,
+    interrupt: Boolean = true,
+    timeoutMs: Long = 10_000L,
+    supplier: () -> List<ExecutorService>
+) {
+    quitResources(maxRetryCount, supplier) { ecs ->
+        if (interrupt) {
+            ecs.shutdownNow()
+        }
+        assertTrue(ecs.awaitTermination(timeoutMs, TimeUnit.MILLISECONDS),
+            "ExecutorServices did not terminate within timeout")
+    }
+}
+
+/**
+ * Implementation of [quitResources] to interrupt and wait for [Thread]s to finish.
+ */
+@JvmOverloads
+fun quitThreads(
+    maxRetryCount: Int,
+    interrupt: Boolean = true,
+    timeoutMs: Long = 10_000L,
+    supplier: () -> List<Thread>
+) {
+    quitResources(maxRetryCount, supplier) { th ->
+        if (interrupt) {
+            th.interrupt()
+        }
+        th.join(timeoutMs)
+        assertFalse(th.isAlive, "Threads did not terminate within timeout.")
+    }
+}
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
index df2e7a6..f81a03d 100755
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
@@ -24,6 +24,7 @@
 import static android.content.pm.PackageManager.FEATURE_WIFI;
 import static android.net.ConnectivityManager.TYPE_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
+import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.os.Process.INVALID_UID;
 import static android.system.OsConstants.AF_INET;
 import static android.system.OsConstants.AF_INET6;
@@ -72,6 +73,7 @@
 import android.database.Cursor;
 import android.net.ConnectivityManager;
 import android.net.ConnectivityManager.NetworkCallback;
+import android.net.InetAddresses;
 import android.net.IpSecManager;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
@@ -91,6 +93,7 @@
 import android.net.cts.util.CtsNetUtils;
 import android.net.util.KeepaliveUtils;
 import android.net.wifi.WifiManager;
+import android.os.Binder;
 import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
@@ -249,6 +252,7 @@
 
     @Before
     public void setUp() throws Exception {
+        assumeTrue(supportedHardware());
         mNetwork = null;
         mTestContext = getInstrumentation().getContext();
         mTargetContext = getInstrumentation().getTargetContext();
@@ -879,7 +883,6 @@
 
     @Test @IgnoreUpTo(SC_V2) // TODO: Use to Build.VERSION_CODES.SC_V2 when available
     public void testChangeUnderlyingNetworks() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_TELEPHONY));
         final TestableNetworkCallback callback = new TestableNetworkCallback();
@@ -938,7 +941,6 @@
 
     @Test
     public void testDefault() throws Exception {
-        assumeTrue(supportedHardware());
         if (!SdkLevel.isAtLeastS() && (
                 SystemProperties.getInt("persist.adb.tcp.port", -1) > -1
                         || SystemProperties.getInt("service.adb.tcp.port", -1) > -1)) {
@@ -1033,8 +1035,6 @@
 
     @Test
     public void testAppAllowed() throws Exception {
-        assumeTrue(supportedHardware());
-
         FileDescriptor fd = openSocketFdInOtherApp(TEST_HOST, 80, TIMEOUT_MS);
 
         // Shell app must not be put in here or it would kill the ADB-over-network use case
@@ -1142,8 +1142,6 @@
     }
 
     private void doTestAutomaticOnOffKeepaliveMode(final boolean closeSocket) throws Exception {
-        assumeTrue(supportedHardware());
-
         // Get default network first before starting VPN
         final Network defaultNetwork = mCM.getActiveNetwork();
         final TestableNetworkCallback cb = new TestableNetworkCallback();
@@ -1231,8 +1229,6 @@
 
     @Test
     public void testAppDisallowed() throws Exception {
-        assumeTrue(supportedHardware());
-
         FileDescriptor localFd = openSocketFd(TEST_HOST, 80, TIMEOUT_MS);
         FileDescriptor remoteFd = openSocketFdInOtherApp(TEST_HOST, 80, TIMEOUT_MS);
 
@@ -1265,8 +1261,6 @@
 
     @Test
     public void testSocketClosed() throws Exception {
-        assumeTrue(supportedHardware());
-
         final FileDescriptor localFd = openSocketFd(TEST_HOST, 80, TIMEOUT_MS);
         final List<FileDescriptor> remoteFds = new ArrayList<>();
 
@@ -1290,7 +1284,6 @@
 
     @Test
     public void testExcludedRoutes() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(SdkLevel.isAtLeastT());
 
         // Shell app must not be put in here or it would kill the ADB-over-network use case
@@ -1311,8 +1304,6 @@
 
     @Test
     public void testIncludedRoutes() throws Exception {
-        assumeTrue(supportedHardware());
-
         // Shell app must not be put in here or it would kill the ADB-over-network use case
         String allowedApps = mRemoteSocketFactoryClient.getPackageName() + "," + mPackageName;
         startVpn(new String[]{"192.0.2.2/32", "2001:db8:1:2::ffe/128"} /* addresses */,
@@ -1330,7 +1321,6 @@
 
     @Test
     public void testInterleavedRoutes() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(SdkLevel.isAtLeastT());
 
         // Shell app must not be put in here or it would kill the ADB-over-network use case
@@ -1358,8 +1348,6 @@
 
     @Test
     public void testGetConnectionOwnerUidSecurity() throws Exception {
-        assumeTrue(supportedHardware());
-
         DatagramSocket s;
         InetAddress address = InetAddress.getByName("localhost");
         s = new DatagramSocket();
@@ -1380,7 +1368,6 @@
 
     @Test
     public void testSetProxy() throws  Exception {
-        assumeTrue(supportedHardware());
         ProxyInfo initialProxy = mCM.getDefaultProxy();
         // Receiver for the proxy change broadcast.
         BlockingBroadcastReceiver proxyBroadcastReceiver = new ProxyChangeBroadcastReceiver();
@@ -1420,7 +1407,6 @@
 
     @Test
     public void testSetProxyDisallowedApps() throws Exception {
-        assumeTrue(supportedHardware());
         ProxyInfo initialProxy = mCM.getDefaultProxy();
 
         String disallowedApps = mPackageName;
@@ -1446,7 +1432,6 @@
 
     @Test
     public void testNoProxy() throws Exception {
-        assumeTrue(supportedHardware());
         ProxyInfo initialProxy = mCM.getDefaultProxy();
         BlockingBroadcastReceiver proxyBroadcastReceiver = new ProxyChangeBroadcastReceiver();
         proxyBroadcastReceiver.register();
@@ -1481,7 +1466,6 @@
 
     @Test
     public void testBindToNetworkWithProxy() throws Exception {
-        assumeTrue(supportedHardware());
         String allowedApps = mPackageName;
         Network initialNetwork = mCM.getActiveNetwork();
         ProxyInfo initialProxy = mCM.getDefaultProxy();
@@ -1506,9 +1490,6 @@
 
     @Test
     public void testVpnMeterednessWithNoUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         // VPN is not routing any traffic i.e. its underlying networks is an empty array.
         ArrayList<Network> underlyingNetworks = new ArrayList<>();
         String allowedApps = mPackageName;
@@ -1538,9 +1519,6 @@
 
     @Test
     public void testVpnMeterednessWithNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testVpnMeterednessWithNullUnderlyingNetwork cannot execute"
@@ -1567,9 +1545,6 @@
 
     @Test
     public void testVpnMeterednessWithNonNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testVpnMeterednessWithNonNullUnderlyingNetwork cannot execute"
@@ -1609,9 +1584,6 @@
 
     @Test
     public void testAlwaysMeteredVpnWithNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testAlwaysMeteredVpnWithNullUnderlyingNetwork cannot execute"
@@ -1636,9 +1608,6 @@
 
     @Test
     public void testAlwaysMeteredVpnWithNonNullUnderlyingNetwork() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         Network underlyingNetwork = mCM.getActiveNetwork();
         if (underlyingNetwork == null) {
             Log.i(TAG, "testAlwaysMeteredVpnWithNonNullUnderlyingNetwork cannot execute"
@@ -1676,9 +1645,6 @@
 
     @Test
     public void testB141603906() throws Exception {
-        if (!supportedHardware()) {
-            return;
-        }
         final InetSocketAddress src = new InetSocketAddress(0);
         final InetSocketAddress dst = new InetSocketAddress(0);
         final int NUM_THREADS = 8;
@@ -1786,8 +1752,6 @@
      */
     @Test
     public void testDownloadWithDownloadManagerDisallowed() throws Exception {
-        assumeTrue(supportedHardware());
-
         // Start a VPN with DownloadManager package in disallowed list.
         startVpn(new String[] {"192.0.2.2/32", "2001:db8:1:2::ffe/128"},
                 new String[] {"192.0.2.0/24", "2001:db8::/32"},
@@ -1843,7 +1807,6 @@
 
     @Test @IgnoreUpTo(Build.VERSION_CODES.R)
     public void testBlockIncomingPackets() throws Exception {
-        assumeTrue(supportedHardware());
         final Network network = mCM.getActiveNetwork();
         assertNotNull("Requires a working Internet connection", network);
 
@@ -1912,7 +1875,6 @@
 
     @Test
     public void testSetVpnDefaultForUids() throws Exception {
-        assumeTrue(supportedHardware());
         assumeTrue(SdkLevel.isAtLeastU());
 
         final Network defaultNetwork = mCM.getActiveNetwork();
@@ -1958,6 +1920,81 @@
             });
     }
 
+    /**
+     * Check if packets to a VPN interface's IP arriving on a non-VPN interface are dropped or not.
+     * If the test interface has a different address from the VPN interface, packets must be dropped
+     * If the test interface has the same address as the VPN interface, packets must not be
+     * dropped
+     *
+     * @param duplicatedAddress true to bring up the test interface with the same address as the VPN
+     *                          interface
+     */
+    private void doTestDropPacketToVpnAddress(final boolean duplicatedAddress)
+            throws Exception {
+        final NetworkRequest request = new NetworkRequest.Builder()
+                .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+                .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
+                .addTransportType(TRANSPORT_TEST)
+                .build();
+        final CtsNetUtils.TestNetworkCallback callback = new CtsNetUtils.TestNetworkCallback();
+        mCM.requestNetwork(request, callback);
+        final FileDescriptor srcTunFd = runWithShellPermissionIdentity(() -> {
+            final TestNetworkManager tnm = mTestContext.getSystemService(TestNetworkManager.class);
+            List<LinkAddress> linkAddresses = duplicatedAddress
+                    ? List.of(new LinkAddress("192.0.2.2/24"),
+                            new LinkAddress("2001:db8:1:2::ffe/64")) :
+                    List.of(new LinkAddress("198.51.100.2/24"),
+                            new LinkAddress("2001:db8:3:4::ffe/64"));
+            final TestNetworkInterface iface = tnm.createTunInterface(linkAddresses);
+            tnm.setupTestNetwork(iface.getInterfaceName(), new Binder());
+            return iface.getFileDescriptor().getFileDescriptor();
+        }, MANAGE_TEST_NETWORKS);
+        final Network testNetwork = callback.waitForAvailable();
+        assertNotNull(testNetwork);
+        final DatagramSocket dstSock = new DatagramSocket();
+
+        testAndCleanup(() -> {
+            startVpn(new String[] {"192.0.2.2/32", "2001:db8:1:2::ffe/128"} /* addresses */,
+                    new String[]{"0.0.0.0/0", "::/0"} /* routes */,
+                    "" /* allowedApplications */, "" /* disallowedApplications */,
+                    null /* proxyInfo */, null /* underlyingNetworks */,
+                    false /* isAlwaysMetered */);
+
+            final FileDescriptor dstUdpFd = dstSock.getFileDescriptor$();
+            checkBlockUdp(srcTunFd, dstUdpFd,
+                    InetAddresses.parseNumericAddress("192.0.2.2") /* dstAddress */,
+                    InetAddresses.parseNumericAddress("192.0.2.1") /* srcAddress */,
+                    duplicatedAddress ? EXPECT_PASS : EXPECT_BLOCK);
+            checkBlockUdp(srcTunFd, dstUdpFd,
+                    InetAddresses.parseNumericAddress("2001:db8:1:2::ffe") /* dstAddress */,
+                    InetAddresses.parseNumericAddress("2001:db8:1:2::ffa") /* srcAddress */,
+                    duplicatedAddress ? EXPECT_PASS : EXPECT_BLOCK);
+
+            // Traffic on VPN should not be affected
+            checkTrafficOnVpn();
+        }, /* cleanup */ () -> {
+                Os.close(srcTunFd);
+                dstSock.close();
+            }, /* cleanup */ () -> {
+                runWithShellPermissionIdentity(() -> {
+                    mTestContext.getSystemService(TestNetworkManager.class)
+                            .teardownTestNetwork(testNetwork);
+                }, MANAGE_TEST_NETWORKS);
+            }, /* cleanup */ () -> {
+                mCM.unregisterNetworkCallback(callback);
+            });
+    }
+
+    @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDropPacketToVpnAddress_WithoutDuplicatedAddress() throws Exception {
+        doTestDropPacketToVpnAddress(false /* duplicatedAddress */);
+    }
+
+    @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDropPacketToVpnAddress_WithDuplicatedAddress() throws Exception {
+        doTestDropPacketToVpnAddress(true /* duplicatedAddress */);
+    }
+
     private ByteBuffer buildIpv4UdpPacket(final Inet4Address dstAddr, final Inet4Address srcAddr,
             final short dstPort, final short srcPort, final byte[] payload) throws IOException {
 
@@ -2001,7 +2038,8 @@
     private void checkBlockUdp(
             final FileDescriptor srcTunFd,
             final FileDescriptor dstUdpFd,
-            final boolean ipv6,
+            final InetAddress dstAddress,
+            final InetAddress srcAddress,
             final boolean expectBlock) throws Exception {
         final Random random = new Random();
         final byte[] sendData = new byte[100];
@@ -2009,15 +2047,15 @@
         final short dstPort = (short) ((InetSocketAddress) Os.getsockname(dstUdpFd)).getPort();
 
         ByteBuffer buf;
-        if (ipv6) {
+        if (dstAddress instanceof Inet6Address) {
             buf = buildIpv6UdpPacket(
-                    (Inet6Address) TEST_IP6_DST_ADDR.getAddress(),
-                    (Inet6Address) TEST_IP6_SRC_ADDR.getAddress(),
+                    (Inet6Address) dstAddress,
+                    (Inet6Address) srcAddress,
                     dstPort, TEST_SRC_PORT, sendData);
         } else {
             buf = buildIpv4UdpPacket(
-                    (Inet4Address) TEST_IP4_DST_ADDR.getAddress(),
-                    (Inet4Address) TEST_IP4_SRC_ADDR.getAddress(),
+                    (Inet4Address) dstAddress,
+                    (Inet4Address) srcAddress,
                     dstPort, TEST_SRC_PORT, sendData);
         }
 
@@ -2043,8 +2081,10 @@
             final FileDescriptor srcTunFd,
             final FileDescriptor dstUdpFd,
             final boolean expectBlock) throws Exception {
-        checkBlockUdp(srcTunFd, dstUdpFd, false /* ipv6 */, expectBlock);
-        checkBlockUdp(srcTunFd, dstUdpFd, true /* ipv6 */, expectBlock);
+        checkBlockUdp(srcTunFd, dstUdpFd, TEST_IP4_DST_ADDR.getAddress(),
+                TEST_IP4_SRC_ADDR.getAddress(), expectBlock);
+        checkBlockUdp(srcTunFd, dstUdpFd, TEST_IP6_DST_ADDR.getAddress(),
+                TEST_IP6_SRC_ADDR.getAddress(), expectBlock);
     }
 
     private class DetailedBlockedStatusCallback extends TestableNetworkCallback {
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java b/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java
index 4f21af7..f0a87af 100644
--- a/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideVpnTests.java
@@ -171,4 +171,16 @@
     public void testSetVpnDefaultForUids() throws Exception {
         runDeviceTests(TEST_PKG, TEST_PKG + ".VpnTest", "testSetVpnDefaultForUids");
     }
+
+    @Test
+    public void testDropPacketToVpnAddress_WithoutDuplicatedAddress() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".VpnTest",
+                "testDropPacketToVpnAddress_WithoutDuplicatedAddress");
+    }
+
+    @Test
+    public void testDropPacketToVpnAddress_WithDuplicatedAddress() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".VpnTest",
+                "testDropPacketToVpnAddress_WithDuplicatedAddress");
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index b7e5205..7af3c83 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -899,6 +899,20 @@
     }
 
     @Test
+    fun testEnableDisableInterface_callbacks() {
+        val iface = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
+
+        disableInterface(iface).expectResult(iface.name)
+        listener.expectCallback(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        enableInterface(iface).expectResult(iface.name)
+        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+    @Test
     fun testUpdateConfiguration_forBothIpConfigAndCapabilities() {
         val iface = createInterface()
         val cb = requestNetwork(ETH_REQUEST.copyWithEthernetSpecifier(iface.name))
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index 6a019b7..2315940 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -805,7 +805,7 @@
                 // harness, which is untagged, won't cause a failure.
                 long firstTotal = resultsWithTraffic.get(0).total;
                 for (QueryResult queryResult : resultsWithTraffic) {
-                    assertWithinPercentage(queryResult + "", firstTotal, queryResult.total, 12);
+                    assertWithinPercentage(queryResult + "", firstTotal, queryResult.total, 16);
                 }
 
                 // Expect to see no traffic when querying for any tag in tagsWithNoTraffic or any
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index dbececf..0daf7fe 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -2108,6 +2108,46 @@
     }
 
     @Test
+    fun testRegisterService_registerImmediatelyAfterUnregister_serviceFound() {
+        val info1 = makeTestServiceInfo(network = testNetwork1.network).apply {
+            serviceName = "service11111"
+            port = 11111
+        }
+        val info2 = makeTestServiceInfo(network = testNetwork1.network).apply {
+            serviceName = "service22222"
+            port = 22222
+        }
+        val registrationRecord1 = NsdRegistrationRecord()
+        val discoveryRecord1 = NsdDiscoveryRecord()
+        val registrationRecord2 = NsdRegistrationRecord()
+        val discoveryRecord2 = NsdDiscoveryRecord()
+        tryTest {
+            registerService(registrationRecord1, info1)
+            nsdManager.discoverServices(serviceType,
+                    NsdManager.PROTOCOL_DNS_SD, testNetwork1.network, { it.run() },
+                    discoveryRecord1)
+            discoveryRecord1.waitForServiceDiscovered(info1.serviceName,
+                    serviceType, testNetwork1.network)
+            nsdManager.stopServiceDiscovery(discoveryRecord1)
+
+            nsdManager.unregisterService(registrationRecord1)
+            registerService(registrationRecord2, info2)
+            nsdManager.discoverServices(serviceType,
+                    NsdManager.PROTOCOL_DNS_SD, testNetwork1.network, { it.run() },
+                    discoveryRecord2)
+            val infoDiscovered = discoveryRecord2.waitForServiceDiscovered(info2.serviceName,
+                    serviceType, testNetwork1.network)
+            val infoResolved = resolveService(infoDiscovered)
+            assertEquals(22222, infoResolved.port)
+        } cleanupStep {
+            nsdManager.stopServiceDiscovery(discoveryRecord2)
+            discoveryRecord2.expectCallback<DiscoveryStopped>()
+        } cleanup {
+            nsdManager.unregisterService(registrationRecord2)
+        }
+    }
+
+    @Test
     fun testServiceTypeClientRemovedAfterSocketDestroyed() {
         val si = makeTestServiceInfo(testNetwork1.network)
         // Register service on testNetwork1
@@ -2159,15 +2199,10 @@
     }
 
     private fun hasServiceTypeClientsForNetwork(clients: List<String>, network: Network): Boolean {
-        for (client in clients) {
-            val netid = client.substring(
-                    client.indexOf("network=") + "network=".length,
-                    client.indexOf("interfaceIndex=") - 1)
-            if (netid == network.toString()) {
-                return true
-            }
+        return clients.any { client -> client.substring(
+                client.indexOf("network=") + "network=".length,
+                client.indexOf("interfaceIndex=") - 1) == network.getNetId().toString()
         }
-        return false
     }
 
     /**
diff --git a/tests/cts/tethering/Android.bp b/tests/cts/tethering/Android.bp
index 3928961..1023173 100644
--- a/tests/cts/tethering/Android.bp
+++ b/tests/cts/tethering/Android.bp
@@ -47,6 +47,7 @@
 
     // Change to system current when TetheringManager move to bootclass path.
     platform_apis: true,
+    min_sdk_version: "30",
     host_required: ["net-tests-utils-host-common"],
 }
 
@@ -80,8 +81,8 @@
 
 // Tethering CTS tests for development and release. These tests always target the platform SDK
 // version, and are subject to all the restrictions appropriate to that version. Before SDK
-// finalization, these tests have a min_sdk_version of 10000, and cannot be installed on release
-// devices.
+// finalization, these tests have a min_sdk_version of 10000, but they can still be installed on
+// release devices as their min_sdk_version is set to a production version.
 android_test {
     name: "CtsTetheringTest",
     defaults: ["CtsTetheringTestDefaults"],
@@ -93,6 +94,14 @@
     // Tag this module as a cts test artifact
     test_suites: [
         "cts",
+        "mts-dnsresolver",
+        "mts-networking",
+        "mts-tethering",
+        "mts-wifi",
+        "mcts-dnsresolver",
+        "mcts-networking",
+        "mcts-tethering",
+        "mcts-wifi",
         "general-tests",
     ],
 
diff --git a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
index 361d68c..035f607 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
@@ -66,6 +66,8 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.tryTest
+import java.util.function.BiConsumer
+import java.util.function.Consumer
 import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
@@ -87,8 +89,6 @@
 import org.mockito.Mockito.mock
 import org.mockito.MockitoAnnotations
 import org.mockito.Spy
-import java.util.function.Consumer
-import java.util.function.BiConsumer
 
 const val SERVICE_BIND_TIMEOUT_MS = 5_000L
 const val TEST_TIMEOUT_MS = 10_000L
@@ -225,6 +225,7 @@
         override fun getSystemProperties() = mock(MockableSystemProperties::class.java)
         override fun makeNetIdManager() = TestNetIdManager()
         override fun getBpfNetMaps(context: Context?, netd: INetd?) = mock(BpfNetMaps::class.java)
+        override fun isChangeEnabled(changeId: Long, uid: Int) = true
 
         override fun makeMultinetworkPolicyTracker(
             c: Context,
diff --git a/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt b/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt
index 104d063..3d948ba 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/NetworkStackInstrumentationService.kt
@@ -18,10 +18,14 @@
 
 import android.app.Service
 import android.content.Intent
+import androidx.annotation.GuardedBy
+import com.android.testutils.quitExecutorServices
+import com.android.testutils.quitThreads
 import java.net.URL
 import java.util.Collections
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.ExecutorService
 import kotlin.collections.ArrayList
 import kotlin.test.fail
 
@@ -37,7 +41,12 @@
                 .run {
                     withDefault { key -> getOrPut(key) { ConcurrentLinkedQueue() } }
                 }
-        private val httpRequestUrls = Collections.synchronizedList(ArrayList<String>())
+        private val httpRequestUrls = Collections.synchronizedList(mutableListOf<String>())
+
+        @GuardedBy("networkMonitorThreads")
+        private val networkMonitorThreads = mutableListOf<Thread>()
+        @GuardedBy("networkMonitorExecutorServices")
+        private val networkMonitorExecutorServices = mutableListOf<ExecutorService>()
 
         /**
          * Called when an HTTP request is being processed by NetworkMonitor. Returns the response
@@ -52,10 +61,47 @@
         }
 
         /**
+         * Called when NetworkMonitor creates a new Thread.
+         */
+        fun onNetworkMonitorThreadCreated(thread: Thread) {
+            synchronized(networkMonitorThreads) {
+                networkMonitorThreads.add(thread)
+            }
+        }
+
+        /**
+         * Called when NetworkMonitor creates a new ExecutorService.
+         */
+        fun onNetworkMonitorExecutorServiceCreated(executorService: ExecutorService) {
+            synchronized(networkMonitorExecutorServices) {
+                networkMonitorExecutorServices.add(executorService)
+            }
+        }
+
+        /**
          * Clear all state of this connector. This is intended for use between two tests, so all
          * state should be reset as if the connector was just created.
          */
         override fun clearAllState() {
+            quitThreads(
+                maxRetryCount = 3,
+                interrupt = true) {
+                synchronized(networkMonitorThreads) {
+                    networkMonitorThreads.toList().also { networkMonitorThreads.clear() }
+                }
+            }
+            quitExecutorServices(
+                maxRetryCount = 3,
+                // NetworkMonitor is expected to have interrupted its executors when probing
+                // finishes, otherwise it's a thread pool leak that should be caught, so they should
+                // not need to be interrupted (the test only needs to wait for them to finish).
+                interrupt = false) {
+                synchronized(networkMonitorExecutorServices) {
+                    networkMonitorExecutorServices.toList().also {
+                        networkMonitorExecutorServices.clear()
+                    }
+                }
+            }
             httpResponses.clear()
             httpRequestUrls.clear()
         }
diff --git a/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt b/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt
index 7e227c4..e43ce29 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/TestNetworkStackService.kt
@@ -30,13 +30,14 @@
 import com.android.server.NetworkStackService.NetworkStackConnector
 import com.android.server.connectivity.NetworkMonitor
 import com.android.server.net.integrationtests.NetworkStackInstrumentationService.InstrumentationConnector
-import org.mockito.Mockito.doReturn
-import org.mockito.Mockito.mock
-import org.mockito.Mockito.spy
 import java.io.ByteArrayInputStream
 import java.net.HttpURLConnection
 import java.net.URL
 import java.nio.charset.StandardCharsets
+import java.util.concurrent.ExecutorService
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.spy
 
 private const val TEST_NETID = 42
 
@@ -60,6 +61,10 @@
     private class NetworkMonitorDeps(private val privateDnsBypassNetwork: Network) :
             NetworkMonitor.Dependencies() {
         override fun getPrivateDnsBypassNetwork(network: Network?) = privateDnsBypassNetwork
+        override fun onThreadCreated(thread: Thread) =
+            InstrumentationConnector.onNetworkMonitorThreadCreated(thread)
+        override fun onExecutorServiceCreated(ecs: ExecutorService) =
+            InstrumentationConnector.onNetworkMonitorExecutorServiceCreated(ecs)
     }
 
     /**
diff --git a/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
index 332f2a3..c491f37 100644
--- a/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
+++ b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
@@ -54,17 +54,17 @@
         assertEquals(beforeParcel.advertisingConfig, afterParcel.advertisingConfig)
     }
 
-@Test
-fun testBuilder_setNullTtl_success() {
-    val info = NsdServiceInfo().apply {
-        serviceType = "_ipp._tcp"
-    }
-    val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
-            .setTtl(null)
-            .build()
+    @Test
+    fun testBuilder_setNullTtl_success() {
+        val info = NsdServiceInfo().apply {
+            serviceType = "_ipp._tcp"
+        }
+        val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
+                .setTtl(null)
+                .build()
 
-    assertNull(request.ttl)
-}
+        assertNull(request.ttl)
+    }
 
     @Test
     fun testBuilder_setPropertiesSuccess() {
diff --git a/tests/unit/java/android/net/nsd/NsdManagerTest.java b/tests/unit/java/android/net/nsd/NsdManagerTest.java
index 76a649e..27c4561 100644
--- a/tests/unit/java/android/net/nsd/NsdManagerTest.java
+++ b/tests/unit/java/android/net/nsd/NsdManagerTest.java
@@ -240,7 +240,7 @@
 
         AdvertisingRequest capturedRequest = getAdvertisingRequest(
                 req -> verify(mServiceConn).registerService(anyInt(), req.capture()));
-        assertEquals(request, capturedRequest);
+        assertEquals(request.getTtl(), capturedRequest.getTtl());
     }
 
     private void doTestRegisterService() throws Exception {
diff --git a/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
index 53baee1..8a9286f 100644
--- a/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
+++ b/tests/unit/java/com/android/metrics/ConnectivitySampleMetricsTest.kt
@@ -1,5 +1,6 @@
 package com.android.metrics
 
+import android.net.ConnectivityThread
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.CONNECTIVITY_MANAGED_CAPABILITIES
 import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL
@@ -15,13 +16,20 @@
 import android.net.NetworkCapabilities.NET_ENTERPRISE_ID_3
 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
 import android.net.NetworkScore
 import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
 import android.net.NetworkScore.POLICY_EXITING
 import android.net.NetworkScore.POLICY_TRANSPORT_PRIMARY
 import android.os.Build
 import android.os.Handler
+import android.os.Process
+import android.os.Process.SYSTEM_UID
 import android.stats.connectivity.MeteredState
+import android.stats.connectivity.RequestType
+import android.stats.connectivity.RequestType.RT_APP
+import android.stats.connectivity.RequestType.RT_SYSTEM
+import android.stats.connectivity.RequestType.RT_SYSTEM_ON_BEHALF_OF_APP
 import android.stats.connectivity.ValidatedState
 import androidx.test.filters.SmallTest
 import com.android.net.module.util.BitUtils
@@ -31,11 +39,13 @@
 import com.android.server.connectivity.FullScore.POLICY_IS_UNMETERED
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
-import org.junit.Test
-import org.junit.runner.RunWith
+import com.android.testutils.TestableNetworkCallback
 import java.util.concurrent.CompletableFuture
 import kotlin.test.assertEquals
 import kotlin.test.fail
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
 
 private fun <T> Handler.onHandler(f: () -> T): T {
     val future = CompletableFuture<T>()
@@ -80,7 +90,7 @@
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
 class ConnectivitySampleMetricsTest : CSTest() {
     @Test
-    fun testSampleConnectivityState() {
+    fun testSampleConnectivityState_Network() {
         val wifi1Caps = NetworkCapabilities.Builder()
                 .addTransportType(TRANSPORT_WIFI)
                 .addCapability(NET_CAPABILITY_NOT_METERED)
@@ -179,4 +189,61 @@
                         "expected ${expectedWifi2Policies.toPolicyString()}, " +
                         "found ${foundWifi2.scorePolicies.toPolicyString()}")
     }
+
+    private fun fileNetworkRequest(requestType: RequestType, requestCount: Int, uid: Int? = null) {
+        if (uid != null) {
+            deps.setCallingUid(uid)
+        }
+        try {
+            repeat(requestCount) {
+                when (requestType) {
+                    RT_APP, RT_SYSTEM -> cm.requestNetwork(
+                            NetworkRequest.Builder().build(),
+                            TestableNetworkCallback()
+                    )
+
+                    RT_SYSTEM_ON_BEHALF_OF_APP -> cm.registerDefaultNetworkCallbackForUid(
+                            Process.myUid(),
+                            TestableNetworkCallback(),
+                            Handler(ConnectivityThread.getInstanceLooper()))
+
+                    else -> fail("invalid requestType: " + requestType)
+                }
+            }
+        } finally {
+            deps.unmockCallingUid()
+        }
+    }
+
+
+    @Test
+    fun testSampleConnectivityState_NetworkRequest() {
+        val requestCount = 5
+        fileNetworkRequest(RT_APP, requestCount);
+        fileNetworkRequest(RT_SYSTEM, requestCount, SYSTEM_UID);
+        fileNetworkRequest(RT_SYSTEM_ON_BEHALF_OF_APP, requestCount, SYSTEM_UID);
+
+        val stats = csHandler.onHandler { service.sampleConnectivityState() }
+
+        assertEquals(3, stats.networkRequestCount.requestCountForTypeList.size)
+        val appRequest = stats.networkRequestCount.requestCountForTypeList.find {
+            it.requestType == RT_APP
+        } ?: fail("Can't find RT_APP request")
+        val systemRequest = stats.networkRequestCount.requestCountForTypeList.find {
+            it.requestType == RT_SYSTEM
+        } ?: fail("Can't find RT_SYSTEM request")
+        val systemOnBehalfOfAppRequest = stats.networkRequestCount.requestCountForTypeList.find {
+            it.requestType == RT_SYSTEM_ON_BEHALF_OF_APP
+        } ?: fail("Can't find RT_SYSTEM_ON_BEHALF_OF_APP request")
+
+        // Verify request count is equal or larger than the number of request this test filed
+        // since ConnectivityService internally files network requests
+        assertTrue("Unexpected RT_APP count, expected >= $requestCount, " +
+                "found ${appRequest.requestCount}", appRequest.requestCount >= requestCount)
+        assertTrue("Unexpected RT_SYSTEM count, expected >= $requestCount, " +
+                "found ${systemRequest.requestCount}", systemRequest.requestCount >= requestCount)
+        assertTrue("Unexpected RT_SYSTEM_ON_BEHALF_OF_APP count, expected >= $requestCount, " +
+                "found ${systemOnBehalfOfAppRequest.requestCount}",
+                systemOnBehalfOfAppRequest.requestCount >= requestCount)
+    }
 }
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index 749e81f..8c30776 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -172,6 +172,7 @@
 import static com.android.server.ConnectivityServiceTestUtils.transportToLegacyType;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackRegister;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackUnregister;
+import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.ConcurrentUtils.await;
 import static com.android.testutils.ConcurrentUtils.durationOf;
@@ -2162,6 +2163,8 @@
                     return true;
                 case ALLOW_SATALLITE_NETWORK_FALLBACK:
                     return true;
+                case INGRESS_TO_VPN_ADDRESS_FILTERING:
+                    return true;
                 default:
                     return super.isFeatureNotChickenedOut(context, name);
             }
@@ -17302,21 +17305,7 @@
     }
 
     @Test
-    public void testSubIdsClearedWithoutNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_DENIED);
-        final NetworkCapabilities nc = new NetworkCapabilities();
-        nc.setSubscriptionIds(Collections.singleton(Process.myUid()));
-
-        final NetworkCapabilities result =
-                mService.networkCapabilitiesRestrictedForCallerPermissions(
-                        nc, Process.myPid(), Process.myUid());
-        assertTrue(result.getSubscriptionIds().isEmpty());
-    }
-
-    @Test
-    public void testSubIdsExistWithNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_GRANTED);
-
+    public void testSubIdsExist() throws Exception {
         final Set<Integer> subIds = Collections.singleton(Process.myUid());
         final NetworkCapabilities nc = new NetworkCapabilities();
         nc.setSubscriptionIds(subIds);
@@ -17342,8 +17331,7 @@
     }
 
     @Test
-    public void testNetworkRequestWithSubIdsWithNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_GRANTED);
+    public void testNetworkRequestWithSubIds() throws Exception {
         final PendingIntent pendingIntent = PendingIntent.getBroadcast(
                 mContext, 0 /* requestCode */, new Intent("a"), FLAG_IMMUTABLE);
         final NetworkCallback networkCallback1 = new NetworkCallback();
@@ -17359,21 +17347,6 @@
     }
 
     @Test
-    public void testNetworkRequestWithSubIdsWithoutNetworkFactoryPermission() throws Exception {
-        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_DENIED);
-        final PendingIntent pendingIntent = PendingIntent.getBroadcast(
-                mContext, 0 /* requestCode */, new Intent("a"), FLAG_IMMUTABLE);
-
-        final Class<SecurityException> expected = SecurityException.class;
-        assertThrows(
-                expected, () -> mCm.requestNetwork(getRequestWithSubIds(), new NetworkCallback()));
-        assertThrows(expected, () -> mCm.requestNetwork(getRequestWithSubIds(), pendingIntent));
-        assertThrows(
-                expected,
-                () -> mCm.registerNetworkCallback(getRequestWithSubIds(), new NetworkCallback()));
-    }
-
-    @Test
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     public void testCarrierConfigAppSendNetworkRequestForRestrictedWifi() throws Exception {
         mServiceContext.setPermission(CONNECTIVITY_USE_RESTRICTED_NETWORKS, PERMISSION_DENIED);
@@ -17508,6 +17481,47 @@
                 false /* expectUnavailable */,
                 true /* expectCapChanged */);
     }
+
+    @Test
+    public void testAllowedUidsExistWithoutNetworkFactoryPermission() throws Exception {
+        // Make sure NETWORK_FACTORY permission is not granted.
+        mServiceContext.setPermission(NETWORK_FACTORY, PERMISSION_DENIED);
+        mServiceContext.setPermission(MANAGE_TEST_NETWORKS, PERMISSION_GRANTED);
+        final TestNetworkCallback cb = new TestNetworkCallback();
+        mCm.requestNetwork(new NetworkRequest.Builder()
+                        .clearCapabilities()
+                        .addTransportType(TRANSPORT_TEST)
+                        .addTransportType(TRANSPORT_CELLULAR)
+                        .build(),
+                cb);
+
+        final ArraySet<Integer> uids = new ArraySet<>();
+        uids.add(200);
+        final NetworkCapabilities nc = new NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_TEST)
+                .removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
+                .setAllowedUids(uids)
+                .setOwnerUid(Process.myUid())
+                .setAdministratorUids(new int[] {Process.myUid()})
+                .build();
+        final TestNetworkAgentWrapper agent = new TestNetworkAgentWrapper(TRANSPORT_TEST,
+                new LinkProperties(), nc);
+        agent.connect(true);
+        cb.expectAvailableThenValidatedCallbacks(agent);
+
+        uids.add(300);
+        uids.add(400);
+        nc.setAllowedUids(uids);
+        agent.setNetworkCapabilities(nc, true /* sendToConnectivityService */);
+        if (mDeps.isAtLeastT()) {
+            // AllowedUids is not cleared even without the NETWORK_FACTORY permission
+            // because the caller is the owner of the network.
+            cb.expectCaps(agent, c -> c.getAllowedUids().equals(uids));
+        } else {
+            cb.assertNoCallback();
+        }
+    }
+
     @Test
     public void testAllowedUids() throws Exception {
         final int preferenceOrder =
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
index b8ebf0f..df48f6c 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
@@ -286,7 +286,6 @@
 
         postSync { socketCb.onInterfaceDestroyed(TEST_SOCKETKEY_1, mockSocket1) }
         verify(mockInterfaceAdvertiser1).destroyNow()
-        postSync { intAdvCbCaptor.value.onDestroyed(mockSocket1) }
         verify(cb).onOffloadStop(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICEINFO_NO_SUBTYPE2))
     }
 
@@ -364,10 +363,10 @@
         verify(cb).onOffloadStop(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICEINFO))
         verify(cb).onOffloadStop(eq(TEST_INTERFACE2), eq(OFFLOAD_SERVICEINFO))
 
-        // Interface advertisers call onDestroyed after sending exit announcements
-        postSync { intAdvCbCaptor1.value.onDestroyed(mockSocket1) }
+        // Interface advertisers call onAllServicesRemoved after sending exit announcements
+        postSync { intAdvCbCaptor1.value.onAllServicesRemoved(mockSocket1) }
         verify(socketProvider, never()).unrequestSocket(any())
-        postSync { intAdvCbCaptor2.value.onDestroyed(mockSocket2) }
+        postSync { intAdvCbCaptor2.value.onAllServicesRemoved(mockSocket2) }
         verify(socketProvider).unrequestSocket(socketCb)
     }
 
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
index 28608bb..69fec85 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
@@ -179,7 +179,7 @@
         // Exit announcements finish: the advertiser has no left service and destroys itself
         announceCb.onFinished(testExitInfo)
         thread.waitForIdle(TIMEOUT_MS)
-        verify(cb).onDestroyed(socket)
+        verify(cb).onAllServicesRemoved(socket)
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
index 8d1dff6..c69b1e1 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
@@ -38,6 +38,7 @@
 import java.net.InetSocketAddress
 import java.net.NetworkInterface
 import java.util.Collections
+import java.time.Duration
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
@@ -146,7 +147,7 @@
         val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, makeFlags())
         assertEquals(0, repository.servicesCount)
         assertEquals(-1,
-                repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* ttl */))
+                repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, Duration.ofSeconds(50)))
         assertEquals(1, repository.servicesCount)
 
         val probingInfo = repository.setServiceProbing(TEST_SERVICE_ID_1)
@@ -169,7 +170,7 @@
         assertEquals(MdnsServiceRecord(expectedName,
                 0L /* receiptTimeMillis */,
                 false /* cacheFlush */,
-                SHORT_TTL /* ttlMillis */,
+                50_000L /* ttlMillis */,
                 0 /* servicePriority */, 0 /* serviceWeight */,
                 TEST_PORT, TEST_HOSTNAME), packet.authorityRecords[0])
 
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
index 8740e80..4ce8ba6 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceInfoTest.java
@@ -54,7 +54,8 @@
                         "192.168.1.1",
                         "2001::1",
                         List.of("vn=Google Inc.", "mn=Google Nest Hub Max"),
-                        /* textEntries= */ null);
+                        /* textEntries= */ null,
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
         assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
@@ -73,7 +74,8 @@
                         "2001::1",
                         /* textStrings= */ null,
                         List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
-                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")),
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertTrue(info.getAttributeByKey("vn").equals("Google Inc."));
         assertTrue(info.getAttributeByKey("mn").equals("Google Nest Hub Max"));
@@ -93,7 +95,8 @@
                         List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
                         List.of(
                                 MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
-                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")));
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max")),
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
                 info.getAttributes());
@@ -113,7 +116,8 @@
                         List.of("vn=Alphabet Inc.", "mn=Google Nest Hub Max", "id=12345"),
                         List.of(MdnsServiceInfo.TextEntry.fromString("vn=Google Inc."),
                                 MdnsServiceInfo.TextEntry.fromString("mn=Google Nest Hub Max"),
-                                MdnsServiceInfo.TextEntry.fromString("mn=Google WiFi Router")));
+                                MdnsServiceInfo.TextEntry.fromString("mn=Google WiFi Router")),
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals(Map.of("vn", "Google Inc.", "mn", "Google Nest Hub Max"),
                 info.getAttributes());
@@ -131,7 +135,8 @@
                         "192.168.1.1",
                         "2001::1",
                         List.of("KEY=Value"),
-                        /* textEntries= */ null);
+                        /* textEntries= */ null,
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals("Value", info.getAttributeByKey("key"));
         assertEquals("Value", info.getAttributeByKey("KEY"));
@@ -150,7 +155,9 @@
                         12345,
                         "192.168.1.1",
                         "2001::1",
-                        List.of());
+                        List.of(),
+                        /* textEntries= */ null,
+                        INTERFACE_INDEX_UNSPECIFIED);
 
         assertEquals(info.getInterfaceIndex(), INTERFACE_INDEX_UNSPECIFIED);
     }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
index 8b7ab71..7ced1cb 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
@@ -26,14 +26,17 @@
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import android.Manifest.permission;
 import android.annotation.RequiresPermission;
 import android.content.Context;
+import android.net.ConnectivityManager;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.MulticastLock;
 import android.text.format.DateUtils;
@@ -48,6 +51,7 @@
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentMatchers;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
@@ -71,6 +75,7 @@
 
     @Mock private Context mContext;
     @Mock private WifiManager mockWifiManager;
+    @Mock private ConnectivityManager mockConnectivityManager;
     @Mock private MdnsSocket mockMulticastSocket;
     @Mock private MdnsSocket mockUnicastSocket;
     @Mock private MulticastLock mockMulticastLock;
@@ -84,6 +89,9 @@
     public void setup() throws RuntimeException, IOException {
         MockitoAnnotations.initMocks(this);
 
+        doReturn(mockConnectivityManager).when(mContext).getSystemService(
+                Context.CONNECTIVITY_SERVICE);
+
         when(mockWifiManager.createMulticastLock(ArgumentMatchers.anyString()))
                 .thenReturn(mockMulticastLock);
 
@@ -320,19 +328,25 @@
 
     @Test
     public void testStartStop() throws IOException {
-        for (int i = 0; i < 5; i++) {
+        for (int i = 1; i <= 5; i++) {
             mdnsClient.startDiscovery();
 
             Thread multicastReceiverThread = mdnsClient.multicastReceiveThread;
             Thread socketThread = mdnsClient.sendThread;
+            final ArgumentCaptor<ConnectivityManager.NetworkCallback> cbCaptor =
+                    ArgumentCaptor.forClass(ConnectivityManager.NetworkCallback.class);
 
             assertTrue(multicastReceiverThread.isAlive());
             assertTrue(socketThread.isAlive());
+            verify(mockConnectivityManager, times(i))
+                    .registerNetworkCallback(any(), cbCaptor.capture());
 
             mdnsClient.stopDiscovery();
 
             assertFalse(multicastReceiverThread.isAlive());
             assertFalse(socketThread.isAlive());
+            verify(mockConnectivityManager, times(i))
+                    .unregisterNetworkCallback(cbCaptor.getValue());
         }
     }
 
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSIngressDiscardRuleTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSIngressDiscardRuleTests.kt
new file mode 100644
index 0000000..e8664c1
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSIngressDiscardRuleTests.kt
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2023 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
+
+import android.net.InetAddresses
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.VpnManager.TYPE_VPN_SERVICE
+import android.net.VpnTransportInfo
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+
+private const val VPN_IFNAME = "tun10041"
+private const val VPN_IFNAME2 = "tun10042"
+private const val WIFI_IFNAME = "wlan0"
+private const val TIMEOUT_MS = 1_000L
+private const val LONG_TIMEOUT_MS = 5_000
+
+private fun vpnNc() = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_VPN)
+        .removeCapability(NET_CAPABILITY_NOT_VPN)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .setTransportInfo(
+                VpnTransportInfo(
+                        TYPE_VPN_SERVICE,
+                        "MySession12345",
+                        false /* bypassable */,
+                        false /* longLivedTcpConnectionsExpensive */))
+        .build()
+
+private fun wifiNc() = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_WIFI)
+        .addCapability(NET_CAPABILITY_INTERNET)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .build()
+
+private fun nr(transport: Int) = NetworkRequest.Builder()
+        .clearCapabilities()
+        .addTransportType(transport).apply {
+            if (transport != TRANSPORT_VPN) {
+                addCapability(NET_CAPABILITY_NOT_VPN)
+            }
+        }.build()
+
+private fun lp(iface: String, vararg linkAddresses: LinkAddress) = LinkProperties().apply {
+    interfaceName = iface
+    for (linkAddress in linkAddresses) {
+        addLinkAddress(linkAddress)
+    }
+}
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class CSIngressDiscardRuleTests : CSTest() {
+    private val IPV6_ADDRESS = InetAddresses.parseNumericAddress("2001:db8:1::1")
+    private val IPV6_LINK_ADDRESS = LinkAddress(IPV6_ADDRESS, 64)
+    private val IPV6_ADDRESS2 = InetAddresses.parseNumericAddress("2001:db8:1::2")
+    private val IPV6_LINK_ADDRESS2 = LinkAddress(IPV6_ADDRESS2, 64)
+    private val IPV6_ADDRESS3 = InetAddresses.parseNumericAddress("2001:db8:1::3")
+    private val IPV6_LINK_ADDRESS3 = LinkAddress(IPV6_ADDRESS3, 64)
+    private val LOCAL_IPV6_ADDRRESS = InetAddresses.parseNumericAddress("fe80::1234")
+    private val LOCAL_IPV6_LINK_ADDRRESS = LinkAddress(LOCAL_IPV6_ADDRRESS, 64)
+
+    @Test
+    fun testVpnIngressDiscardRule_UpdateVpnAddress() {
+        // non-VPN network whose address will be not duplicated with VPN address
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS3)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(nr, cb)
+        val nc = vpnNc()
+        val lp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val agent = Agent(nc = nc, lp = lp)
+        agent.connect()
+        cb.expectAvailableCallbacks(agent.network, validated = false)
+
+        // IngressDiscardRule is added to the VPN address
+        verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        verify(bpfNetMaps, never()).setIngressDiscardRule(LOCAL_IPV6_ADDRRESS, VPN_IFNAME)
+
+        // The VPN address is changed
+        val newLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS2, LOCAL_IPV6_LINK_ADDRRESS)
+        agent.sendLinkProperties(newLp)
+        cb.expect<LinkPropertiesChanged>(agent.network)
+
+        // IngressDiscardRule is removed from the old VPN address and added to the new VPN address
+        verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+        verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS2, VPN_IFNAME)
+        verify(bpfNetMaps, never()).setIngressDiscardRule(LOCAL_IPV6_ADDRRESS, VPN_IFNAME)
+
+        agent.disconnect()
+        verify(bpfNetMaps, timeout(TIMEOUT_MS)).removeIngressDiscardRule(IPV6_ADDRESS2)
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_UpdateInterfaceName() {
+        val inorder = inOrder(bpfNetMaps)
+
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(nr, cb)
+        val nc = vpnNc()
+        val lp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val agent = Agent(nc = nc, lp = lp)
+        agent.connect()
+        cb.expectAvailableCallbacks(agent.network, validated = false)
+
+        // IngressDiscardRule is added to the VPN address
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        inorder.verifyNoMoreInteractions()
+
+        // The VPN interface name is changed
+        val newlp = lp(VPN_IFNAME2, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        agent.sendLinkProperties(newlp)
+        cb.expect<LinkPropertiesChanged>(agent.network)
+
+        // IngressDiscardRule is updated with the new interface name
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME2)
+        inorder.verifyNoMoreInteractions()
+
+        agent.disconnect()
+        inorder.verify(bpfNetMaps, timeout(TIMEOUT_MS)).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_DuplicatedIpAddress_UpdateVpnAddress() {
+        val inorder = inOrder(bpfNetMaps)
+
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        // IngressDiscardRule is not added to non-VPN interfaces
+        inorder.verify(bpfNetMaps, never()).setIngressDiscardRule(any(), any())
+
+        val nr = nr(TRANSPORT_VPN)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+        val vpnNc = vpnNc()
+        val vpnLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val vpnAgent = Agent(nc = vpnNc, lp = vpnLp)
+        vpnAgent.connect()
+        cb.expectAvailableCallbacks(vpnAgent.network, validated = false)
+
+        // IngressDiscardRule is not added since the VPN address is duplicated with the Wi-Fi
+        // address
+        inorder.verify(bpfNetMaps, never()).setIngressDiscardRule(any(), any())
+
+        // The VPN address is changed to a different address from the Wi-Fi interface
+        val newVpnlp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS2, LOCAL_IPV6_LINK_ADDRRESS)
+        vpnAgent.sendLinkProperties(newVpnlp)
+
+        // IngressDiscardRule is added to the VPN address since the VPN address is not duplicated
+        // with the Wi-Fi address
+        cb.expect<LinkPropertiesChanged>(vpnAgent.network)
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS2, VPN_IFNAME)
+
+        // The VPN address is changed back to the same address as the Wi-Fi interface
+        vpnAgent.sendLinkProperties(vpnLp)
+        cb.expect<LinkPropertiesChanged>(vpnAgent.network)
+
+        // IngressDiscardRule for IPV6_ADDRESS2 is removed but IngressDiscardRule for
+        // IPV6_LINK_ADDRESS is not added since Wi-Fi also uses IPV6_LINK_ADDRESS
+        inorder.verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS2)
+        inorder.verifyNoMoreInteractions()
+
+        vpnAgent.disconnect()
+        inorder.verifyNoMoreInteractions()
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_DuplicatedIpAddress_UpdateNonVpnAddress() {
+        val inorder = inOrder(bpfNetMaps)
+
+        val vpnNc = vpnNc()
+        val vpnLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val vpnAgent = Agent(nc = vpnNc, lp = vpnLp)
+        vpnAgent.connect()
+
+        // IngressDiscardRule is added to the VPN address
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        inorder.verifyNoMoreInteractions()
+
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // IngressDiscardRule is removed since the VPN address is duplicated with the Wi-Fi address
+        inorder.verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        // The Wi-Fi address is changed to a different address from the VPN interface
+        val newWifilp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS2, LOCAL_IPV6_LINK_ADDRRESS)
+        wifiAgent.sendLinkProperties(newWifilp)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // IngressDiscardRule is added to the VPN address since the VPN address is not duplicated
+        // with the Wi-Fi address
+        inorder.verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+        inorder.verifyNoMoreInteractions()
+
+        // The Wi-Fi address is changed back to the same address as the VPN interface
+        wifiAgent.sendLinkProperties(wifiLp)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // IngressDiscardRule is removed since the VPN address is duplicated with the Wi-Fi address
+        inorder.verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        // IngressDiscardRule is added to the VPN address since Wi-Fi is disconnected
+        wifiAgent.disconnect()
+        inorder.verify(bpfNetMaps, timeout(TIMEOUT_MS))
+                .setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+
+        vpnAgent.disconnect()
+        inorder.verify(bpfNetMaps, timeout(TIMEOUT_MS)).removeIngressDiscardRule(IPV6_ADDRESS)
+
+        cm.unregisterNetworkCallback(cb)
+    }
+
+    @Test
+    fun testVpnIngressDiscardRule_UnregisterAfterReplacement() {
+        val wifiNc = wifiNc()
+        val wifiLp = lp(WIFI_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        wifiAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+        waitForIdle()
+
+        val vpnNc = vpnNc()
+        val vpnLp = lp(VPN_IFNAME, IPV6_LINK_ADDRESS, LOCAL_IPV6_LINK_ADDRRESS)
+        val vpnAgent = Agent(nc = vpnNc, lp = vpnLp)
+        vpnAgent.connect()
+
+        // IngressDiscardRule is added since the Wi-Fi network is destroyed
+        verify(bpfNetMaps).setIngressDiscardRule(IPV6_ADDRESS, VPN_IFNAME)
+
+        // IngressDiscardRule is removed since the VPN network is destroyed
+        vpnAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+        waitForIdle()
+        verify(bpfNetMaps).removeIngressDiscardRule(IPV6_ADDRESS)
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
index c1730a4..83fff87 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
@@ -38,6 +38,7 @@
 import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
 import android.net.NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK
 import android.net.RouteInfo
+import android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK
 import android.os.Build
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
@@ -47,12 +48,15 @@
 import kotlin.test.assertFailsWith
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
 import org.mockito.Mockito.clearInvocations
 import org.mockito.Mockito.eq
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.never
 import org.mockito.Mockito.timeout
+import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
 
 private const val TIMEOUT_MS = 200L
 private const val MEDIUM_TIMEOUT_MS = 1_000L
@@ -88,10 +92,10 @@
 class CSLocalAgentTests : CSTest() {
     val multicastRoutingConfigMinScope =
                 MulticastRoutingConfig.Builder(MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE, 4)
-                .build();
+                .build()
     val multicastRoutingConfigSelected =
                 MulticastRoutingConfig.Builder(MulticastRoutingConfig.FORWARD_SELECTED)
-                .build();
+                .build()
     val upstreamSelectorAny = NetworkRequest.Builder()
                 .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
                 .build()
@@ -205,6 +209,9 @@
                 nc = nc(TRANSPORT_THREAD, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp(name),
                 lnc = localNetworkConfig,
+                score = FromS(NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build())
         )
         return localAgent
     }
@@ -219,9 +226,12 @@
                 nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET))
     }
 
-    private fun sendLocalNetworkConfig(localAgent: CSAgentWrapper,
-                upstreamSelector: NetworkRequest?, upstreamConfig: MulticastRoutingConfig,
-                downstreamConfig: MulticastRoutingConfig) {
+    private fun sendLocalNetworkConfig(
+            localAgent: CSAgentWrapper,
+            upstreamSelector: NetworkRequest?,
+            upstreamConfig: MulticastRoutingConfig,
+            downstreamConfig: MulticastRoutingConfig
+    ) {
         val newLnc = LocalNetworkConfig.Builder()
                 .setUpstreamSelector(upstreamSelector)
                 .setUpstreamMulticastRoutingConfig(upstreamConfig)
@@ -458,7 +468,6 @@
         wifiAgent.disconnect()
     }
 
-
     @Test
     fun testUnregisterUpstreamAfterReplacement_SameIfaceName() {
         doTestUnregisterUpstreamAfterReplacement(true)
@@ -824,4 +833,59 @@
 
         listenCb.expect<Lost>()
     }
+
+    fun doTestLocalNetworkRequest(
+            request: NetworkRequest,
+            enableMatchLocalNetwork: Boolean,
+            expectCallback: Boolean
+    ) {
+        deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(enableMatchLocalNetwork, ENABLE_MATCH_LOCAL_NETWORK)
+
+        val requestCb = TestableNetworkCallback()
+        val listenCb = TestableNetworkCallback()
+        cm.requestNetwork(request, requestCb)
+        cm.registerNetworkCallback(request, listenCb)
+
+        val localAgent = createLocalAgent("local0", FromS(LocalNetworkConfig.Builder().build()))
+        localAgent.connect()
+
+        if (expectCallback) {
+            requestCb.expectAvailableCallbacks(localAgent.network, validated = false)
+            listenCb.expectAvailableCallbacks(localAgent.network, validated = false)
+        } else {
+            waitForIdle()
+            requestCb.assertNoCallback(timeoutMs = 0)
+            listenCb.assertNoCallback(timeoutMs = 0)
+        }
+        localAgent.disconnect()
+    }
+
+    @Test
+    fun testLocalNetworkRequest() {
+        val request = NetworkRequest.Builder().build()
+        // If ENABLE_MATCH_LOCAL_NETWORK is false, request is not satisfied by local network
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = false,
+                expectCallback = false)
+        // If ENABLE_MATCH_LOCAL_NETWORK is true, request is satisfied by local network
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = true,
+                expectCallback = true)
+    }
+
+    @Test
+    fun testLocalNetworkRequest_withCapability() {
+        val request = NetworkRequest.Builder().addCapability(NET_CAPABILITY_LOCAL_NETWORK).build()
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = false,
+                expectCallback = true)
+        doTestLocalNetworkRequest(
+                request,
+                enableMatchLocalNetwork = true,
+                expectCallback = true)
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index d7343b1..13c5cbc 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -28,6 +28,7 @@
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkProvider
@@ -39,6 +40,9 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.Available
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.TestableNetworkCallback
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.assertEquals
+import kotlin.test.fail
 import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatchers.any
 import org.mockito.ArgumentMatchers.anyInt
@@ -46,9 +50,6 @@
 import org.mockito.Mockito.doNothing
 import org.mockito.Mockito.verify
 import org.mockito.stubbing.Answer
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.test.assertEquals
-import kotlin.test.fail
 
 const val SHORT_TIMEOUT_MS = 200L
 
@@ -140,6 +141,9 @@
         val request = NetworkRequest.Builder().apply {
             clearCapabilities()
             if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+            if (nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+                addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+            }
         }.build()
         val cb = TestableNetworkCallback()
         mgr.registerNetworkCallback(request, cb)
@@ -166,6 +170,9 @@
         val request = NetworkRequest.Builder().apply {
             clearCapabilities()
             if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+            if (nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+                addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+            }
         }.build()
         val cb = TestableNetworkCallback(timeoutMs = SHORT_TIMEOUT_MS)
         mgr.registerNetworkCallback(request, cb)
@@ -178,6 +185,7 @@
 
     fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
     fun sendNetworkCapabilities(nc: NetworkCapabilities) = agent.sendNetworkCapabilities(nc)
+    fun sendLinkProperties(lp: LinkProperties) = agent.sendLinkProperties(lp)
 
     fun connectWithCaptivePortal(redirectUrl: String) {
         setCaptivePortal(redirectUrl)
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index 3b83c41..6c9871c 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -17,6 +17,7 @@
 package com.android.server
 
 import android.app.AlarmManager
+import android.app.AppOpsManager
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
@@ -42,6 +43,7 @@
 import android.net.NetworkProvider
 import android.net.NetworkScore
 import android.net.PacProxyManager
+import android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK
 import android.net.networkstack.NetworkStackClientBase
 import android.os.BatteryStatsManager
 import android.os.Bundle
@@ -51,9 +53,9 @@
 import android.os.UserHandle
 import android.os.UserManager
 import android.permission.PermissionManager.PermissionResult
+import android.telephony.SubscriptionManager
 import android.telephony.TelephonyManager
 import android.testing.TestableContext
-import android.util.ArraySet
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.internal.app.IBatteryStats
 import com.android.internal.util.test.BroadcastInterceptingContext
@@ -75,8 +77,8 @@
 import java.util.concurrent.Executors
 import java.util.concurrent.LinkedBlockingQueue
 import java.util.concurrent.TimeUnit
-import java.util.function.Consumer
 import java.util.function.BiConsumer
+import java.util.function.Consumer
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.fail
@@ -103,6 +105,8 @@
 internal const val VERSION_V = 5
 internal const val VERSION_MAX = VERSION_V
 
+internal const val CALLING_UID_UNMOCKED = Process.INVALID_UID
+
 private fun NetworkCapabilities.getLegacyType() =
         when (transportTypes.getOrElse(0) { TRANSPORT_WIFI }) {
             TRANSPORT_BLUETOOTH -> ConnectivityManager.TYPE_BLUETOOTH
@@ -128,8 +132,10 @@
 
     init {
         if (!SdkLevel.isAtLeastS()) {
-            throw UnsupportedApiLevelException("CSTest subclasses must be annotated to only " +
-                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)")
+            throw UnsupportedApiLevelException(
+                "CSTest subclasses must be annotated to only " +
+                    "run on S+, e.g. @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)"
+            )
         }
     }
 
@@ -147,6 +153,7 @@
         it[ConnectivityService.DELAY_DESTROY_FROZEN_SOCKETS_VERSION] = true
         it[ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS] = true
         it[ConnectivityService.ALLOW_SATALLITE_NETWORK_FALLBACK] = true
+        it[ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING] = true
     }
     fun enableFeature(f: String) = enabledFeatures.set(f, true)
     fun disableFeature(f: String) = enabledFeatures.set(f, false)
@@ -176,9 +183,11 @@
     val systemConfigManager = makeMockSystemConfigManager()
     val batteryStats = mock<IBatteryStats>()
     val batteryManager = BatteryStatsManager(batteryStats)
+    val appOpsManager = mock<AppOpsManager>()
     val telephonyManager = mock<TelephonyManager>().also {
         doReturn(true).`when`(it).isDataCapable()
     }
+    val subscriptionManager = mock<SubscriptionManager>()
 
     val multicastRoutingCoordinatorService = mock<MulticastRoutingCoordinatorService>()
     val satelliteAccessController = mock<SatelliteAccessController>()
@@ -248,8 +257,12 @@
                 AutomaticOnOffKeepaliveTracker(c, h, AOOKTDeps(c))
 
         override fun makeMultinetworkPolicyTracker(c: Context, h: Handler, r: Runnable) =
-                MultinetworkPolicyTracker(c, h, r,
-                        MultinetworkPolicyTrackerTestDependencies(connResources.get()))
+                MultinetworkPolicyTracker(
+                        c,
+                        h,
+                        r,
+                        MultinetworkPolicyTrackerTestDependencies(connResources.get())
+                )
 
         override fun makeNetworkRequestStateStatsMetrics(c: Context) =
                 this@CSTest.networkRequestStateStatsMetrics
@@ -263,7 +276,7 @@
                 enabledFeatures[name] ?: fail("Unmocked feature $name, see CSTest.enabledFeatures")
 
         // Mocked change IDs
-        private val enabledChangeIds = ArraySet<Long>()
+        private val enabledChangeIds = arrayListOf(ENABLE_MATCH_LOCAL_NETWORK)
         fun setChangeIdEnabled(enabled: Boolean, changeId: Long) {
             // enabledChangeIds is read on the handler thread and maybe the test thread, so
             // make sure both threads see it before continuing.
@@ -298,6 +311,19 @@
         override fun isAtLeastT() = if (isSdkUnmocked) super.isAtLeastT() else sdkLevel >= VERSION_T
         override fun isAtLeastU() = if (isSdkUnmocked) super.isAtLeastU() else sdkLevel >= VERSION_U
         override fun isAtLeastV() = if (isSdkUnmocked) super.isAtLeastV() else sdkLevel >= VERSION_V
+
+        private var callingUid = CALLING_UID_UNMOCKED
+
+        fun unmockCallingUid() {
+            setCallingUid(CALLING_UID_UNMOCKED)
+        }
+
+        fun setCallingUid(callingUid: Int) {
+            visibleOnHandlerThread(csHandler) { this.callingUid = callingUid }
+        }
+
+        override fun getCallingUid() =
+                if (callingUid == CALLING_UID_UNMOCKED) super.getCallingUid() else callingUid
     }
 
     inner class CSContext(base: Context) : BroadcastInterceptingContext(base) {
@@ -321,8 +347,12 @@
         override fun enforceCallingOrSelfPermission(permission: String, message: String?) {
             // If the permission result does not set in the mMockedPermissions, it will be
             // considered as PERMISSION_GRANTED as existing design to prevent breaking other tests.
-            val granted = checkMockedPermission(permission, Process.myPid(), Process.myUid(),
-                PERMISSION_GRANTED)
+            val granted = checkMockedPermission(
+                permission,
+                Process.myPid(),
+                Process.myUid(),
+                PERMISSION_GRANTED
+            )
             if (!granted.equals(PERMISSION_GRANTED)) {
                 throw SecurityException("[Test] permission denied: " + permission)
             }
@@ -333,8 +363,12 @@
         override fun checkCallingOrSelfPermission(permission: String) =
             checkMockedPermission(permission, Process.myPid(), Process.myUid(), PERMISSION_GRANTED)
 
-        private fun checkMockedPermission(permission: String, pid: Int, uid: Int, default: Int):
-                Int {
+        private fun checkMockedPermission(
+                permission: String,
+                pid: Int,
+                uid: Int,
+                default: Int
+        ): Int {
             val processSpecificKey = "$permission,$pid,$uid"
             return mMockedPermissions[processSpecificKey]
                     ?: mMockedPermissions[permission] ?: default
@@ -396,16 +430,17 @@
             Context.ACTIVITY_SERVICE -> activityManager
             Context.SYSTEM_CONFIG_SERVICE -> systemConfigManager
             Context.TELEPHONY_SERVICE -> telephonyManager
+            Context.TELEPHONY_SUBSCRIPTION_SERVICE -> subscriptionManager
             Context.BATTERY_STATS_SERVICE -> batteryManager
             Context.STATS_MANAGER -> null // Stats manager is final and can't be mocked
+            Context.APP_OPS_SERVICE -> appOpsManager
             else -> super.getSystemService(serviceName)
         }
 
         internal val orderedBroadcastAsUserHistory = ArrayTrackRecord<Intent>().newReadHead()
 
         fun expectNoDataActivityBroadcast(timeoutMs: Int) {
-            assertNull(orderedBroadcastAsUserHistory.poll(
-                    timeoutMs.toLong()) { intent -> true })
+            assertNull(orderedBroadcastAsUserHistory.poll(timeoutMs.toLong()))
         }
 
         override fun sendOrderedBroadcastAsUser(
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
index e62ac74..0bbc34c 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
@@ -47,7 +47,6 @@
 import android.net.NetworkTemplate;
 import android.os.HandlerThread;
 import android.os.IBinder;
-import android.os.Looper;
 import android.os.Process;
 import android.os.UserHandle;
 import android.telephony.TelephonyManager;
@@ -127,13 +126,7 @@
 
         mObserverHandlerThread = new HandlerThread("NetworkStatsObserversTest");
         mObserverHandlerThread.start();
-        final Looper observerLooper = mObserverHandlerThread.getLooper();
-        mStatsObservers = new NetworkStatsObservers() {
-            @Override
-            protected Looper getHandlerLooperLocked() {
-                return observerLooper;
-            }
-        };
+        mStatsObservers = new NetworkStatsObservers(mObserverHandlerThread.getLooper());
 
         mActiveIfaces = new ArrayMap<>();
         mActiveUidIfaces = new ArrayMap<>();
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 3ed51bc..3d7ad66 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -123,7 +123,6 @@
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.IBinder;
-import android.os.Looper;
 import android.os.PowerManager;
 import android.os.SimpleClock;
 import android.provider.Settings;
@@ -293,7 +292,6 @@
     private String mCompareStatsResult = null;
     private @Mock Resources mResources;
     private Boolean mIsDebuggable;
-    private HandlerThread mObserverHandlerThread;
     final TestDependencies mDeps = new TestDependencies();
 
     private class MockContext extends BroadcastInterceptingContext {
@@ -377,21 +375,8 @@
                 powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
 
         mHandlerThread = new HandlerThread("NetworkStatsServiceTest-HandlerThread");
-        // Create a separate thread for observers to run on. This thread cannot be the same
-        // as the handler thread, because the observer callback is fired on this thread, and
-        // it should not be blocked by client code. Additionally, creating the observers
-        // object requires a looper, which can only be obtained after a thread has been started.
-        mObserverHandlerThread = new HandlerThread("NetworkStatsServiceTest-ObserversThread");
-        mObserverHandlerThread.start();
-        final Looper observerLooper = mObserverHandlerThread.getLooper();
-        final NetworkStatsObservers statsObservers = new NetworkStatsObservers() {
-            @Override
-            protected Looper getHandlerLooperLocked() {
-                return observerLooper;
-            }
-        };
         mService = new NetworkStatsService(mServiceContext, mNetd, mAlarmManager, wakeLock,
-                mClock, mSettings, mStatsFactory, statsObservers, mDeps);
+                mClock, mSettings, mStatsFactory, mDeps);
 
         mElapsedRealtime = 0L;
 
@@ -589,10 +574,6 @@
             mHandlerThread.quitSafely();
             mHandlerThread.join();
         }
-        if (mObserverHandlerThread != null) {
-            mObserverHandlerThread.quitSafely();
-            mObserverHandlerThread.join();
-        }
     }
 
     private void initWifiStats(NetworkStateSnapshot snapshot) throws Exception {
diff --git a/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt b/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
index 27e6f96..99f762d 100644
--- a/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
+++ b/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
@@ -16,30 +16,35 @@
 
 package com.android.server.net
 
-import android.net.NetworkStats
+import android.net.NetworkStats.Entry
 import com.android.testutils.DevSdkIgnoreRunner
 import java.time.Clock
+import java.util.function.Supplier
 import kotlin.test.assertEquals
 import kotlin.test.assertNull
+import kotlin.test.fail
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
 import org.mockito.Mockito.`when`
 
 @RunWith(DevSdkIgnoreRunner::class)
 class TrafficStatsRateLimitCacheTest {
     companion object {
         private const val expiryDurationMs = 1000L
+        private const val maxSize = 2
     }
 
     private val clock = mock(Clock::class.java)
-    private val entry = mock(NetworkStats.Entry::class.java)
-    private val cache = TrafficStatsRateLimitCache(clock, expiryDurationMs)
+    private val entry = mock(Entry::class.java)
+    private val cache = TrafficStatsRateLimitCache(clock, expiryDurationMs, maxSize)
 
     @Test
     fun testGet_returnsEntryIfNotExpired() {
         cache.put("iface", 2, entry)
-        `when`(clock.millis()).thenReturn(500L) // Set clock to before expiry
+        doReturn(500L).`when`(clock).millis() // Set clock to before expiry
         val result = cache.get("iface", 2)
         assertEquals(entry, result)
     }
@@ -47,7 +52,7 @@
     @Test
     fun testGet_returnsNullIfExpired() {
         cache.put("iface", 2, entry)
-        `when`(clock.millis()).thenReturn(2000L) // Set clock to after expiry
+        doReturn(2000L).`when`(clock).millis() // Set clock to after expiry
         assertNull(cache.get("iface", 2))
     }
 
@@ -59,8 +64,8 @@
 
     @Test
     fun testPutAndGet_retrievesCorrectEntryForDifferentKeys() {
-        val entry1 = mock(NetworkStats.Entry::class.java)
-        val entry2 = mock(NetworkStats.Entry::class.java)
+        val entry1 = mock(Entry::class.java)
+        val entry2 = mock(Entry::class.java)
 
         cache.put("iface1", 2, entry1)
         cache.put("iface2", 4, entry2)
@@ -71,8 +76,8 @@
 
     @Test
     fun testPut_overridesExistingEntry() {
-        val entry1 = mock(NetworkStats.Entry::class.java)
-        val entry2 = mock(NetworkStats.Entry::class.java)
+        val entry1 = mock(Entry::class.java)
+        val entry2 = mock(Entry::class.java)
 
         cache.put("iface", 2, entry1)
         cache.put("iface", 2, entry2) // Put with the same key
@@ -81,6 +86,62 @@
     }
 
     @Test
+    fun testPut_removeLru() {
+        // Assumes max size is 2. Verify eldest entry get removed.
+        val entry1 = mock(Entry::class.java)
+        val entry2 = mock(Entry::class.java)
+        val entry3 = mock(Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+        cache.put("iface2", 4, entry2)
+        cache.put("iface3", 8, entry3)
+
+        assertNull(cache.get("iface1", 2))
+        assertEquals(entry2, cache.get("iface2", 4))
+        assertEquals(entry3, cache.get("iface3", 8))
+    }
+
+    @Test
+    fun testGetOrCompute_cacheHit() {
+        val entry1 = mock(Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+
+        // Set clock to before expiry.
+        doReturn(500L).`when`(clock).millis()
+
+        // Now call getOrCompute
+        val result = cache.getOrCompute("iface1", 2) {
+            fail("Supplier should not be called")
+        }
+
+        // Assertions
+        assertEquals(entry1, result) // Should get the cached entry.
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    @Test
+    fun testGetOrCompute_cacheMiss() {
+        val entry1 = mock(Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+
+        // Set clock to after expiry.
+        doReturn(1500L).`when`(clock).millis()
+
+        // Mock the supplier to return our network stats entry.
+        val supplier = mock(Supplier::class.java) as Supplier<Entry>
+        doReturn(entry1).`when`(supplier).get()
+
+        // Now call getOrCompute.
+        val result = cache.getOrCompute("iface1", 2, supplier)
+
+        // Assertions.
+        assertEquals(entry1, result) // Should get the cached entry.
+        verify(supplier).get()
+    }
+
+    @Test
     fun testClear() {
         cache.put("iface", 2, entry)
         cache.clear()
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
index b74a15a..4a7d3a7 100644
--- a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
@@ -74,42 +74,61 @@
 public final class ActiveOperationalDataset implements Parcelable {
     /** The maximum length of the Active Operational Dataset TLV array in bytes. */
     public static final int LENGTH_MAX_DATASET_TLVS = 254;
+
     /** The length of Extended PAN ID in bytes. */
     public static final int LENGTH_EXTENDED_PAN_ID = 8;
+
     /** The minimum length of Network Name as UTF-8 bytes. */
     public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1;
+
     /** The maximum length of Network Name as UTF-8 bytes. */
     public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16;
+
     /** The length of Network Key in bytes. */
     public static final int LENGTH_NETWORK_KEY = 16;
+
     /** The length of Mesh-Local Prefix in bits. */
     public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64;
+
     /** The length of PSKc in bytes. */
     public static final int LENGTH_PSKC = 16;
+
     /** The 2.4 GHz channel page. */
     public static final int CHANNEL_PAGE_24_GHZ = 0;
+
     /** The minimum 2.4GHz channel. */
     public static final int CHANNEL_MIN_24_GHZ = 11;
+
     /** The maximum 2.4GHz channel. */
     public static final int CHANNEL_MAX_24_GHZ = 26;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_CHANNEL = 0;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_PAN_ID = 1;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_EXTENDED_PAN_ID = 2;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_NETWORK_NAME = 3;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_PSKC = 4;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_NETWORK_KEY = 5;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_MESH_LOCAL_PREFIX = 7;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_SECURITY_POLICY = 12;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_ACTIVE_TIMESTAMP = 14;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_CHANNEL_MASK = 53;
 
@@ -975,8 +994,10 @@
     public static final class SecurityPolicy {
         /** The default Rotation Time in hours. */
         public static final int DEFAULT_ROTATION_TIME_HOURS = 672;
+
         /** The minimum length of Security Policy flags in bytes. */
         public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1;
+
         /** The length of Rotation Time TLV value in bytes. */
         private static final int LENGTH_SECURITY_POLICY_ROTATION_TIME = 2;
 
diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java
index 440c2c3..3c7a72b 100644
--- a/thread/service/java/com/android/server/thread/NsdPublisher.java
+++ b/thread/service/java/com/android/server/thread/NsdPublisher.java
@@ -204,8 +204,12 @@
         }
     }
 
-    /** On ot-daemon died, unregister all registrations. */
-    public void onOtDaemonDied() {
+    @Override
+    public void reset() {
+        mHandler.post(this::resetInternal);
+    }
+
+    private void resetInternal() {
         checkOnHandlerThread();
         for (int i = 0; i < mRegistrationListeners.size(); ++i) {
             try {
@@ -222,6 +226,12 @@
             }
         }
         mRegistrationListeners.clear();
+        mRegistrationJobs.clear();
+    }
+
+    /** On ot-daemon died, reset. */
+    public void onOtDaemonDied() {
+        reset();
     }
 
     // TODO: b/323300118 - Remove this mechanism when the race condition in NsdManager is fixed.
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 0623b87..1b36d2b 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -489,7 +489,14 @@
         @Override
         public void onAvailable(@NonNull Network network) {
             checkOnHandlerThread();
-            Log.i(TAG, "Thread network available: " + network);
+            Log.i(TAG, "Thread network is available: " + network);
+        }
+
+        @Override
+        public void onLost(@NonNull Network network) {
+            checkOnHandlerThread();
+            Log.i(TAG, "Thread network is lost: " + network);
+            disableBorderRouting();
         }
 
         @Override
@@ -504,7 +511,7 @@
                             + localNetworkInfo
                             + "}");
             if (localNetworkInfo.getUpstreamNetwork() == null) {
-                mUpstreamNetwork = null;
+                disableBorderRouting();
                 return;
             }
             if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
@@ -523,6 +530,7 @@
                         // requirement.
                         .clearCapabilities()
                         .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
                         .build(),
                 new ThreadNetworkCallback(),
                 mHandler);
@@ -936,23 +944,22 @@
             mBorderRouterConfig.isBorderRoutingEnabled = true;
 
             mOtDaemon.configureBorderRouter(
-                    mBorderRouterConfig,
-                    new IOtStatusReceiver.Stub() {
-                        @Override
-                        public void onSuccess() {
-                            Log.i(TAG, "configure border router successfully");
-                        }
+                    mBorderRouterConfig, new ConfigureBorderRouterStatusReceiver());
+        } catch (RemoteException | IOException e) {
+            Log.w(TAG, "Failed to enable border routing", e);
+        }
+    }
 
-                        @Override
-                        public void onError(int i, String s) {
-                            Log.w(
-                                    TAG,
-                                    String.format(
-                                            "failed to configure border router: %d %s", i, s));
-                        }
-                    });
-        } catch (Exception e) {
-            Log.w(TAG, "enableBorderRouting failed: " + e);
+    private void disableBorderRouting() {
+        mUpstreamNetwork = null;
+        mBorderRouterConfig.infraInterfaceName = null;
+        mBorderRouterConfig.infraInterfaceIcmp6Socket = null;
+        mBorderRouterConfig.isBorderRoutingEnabled = false;
+        try {
+            mOtDaemon.configureBorderRouter(
+                    mBorderRouterConfig, new ConfigureBorderRouterStatusReceiver());
+        } catch (RemoteException e) {
+            Log.w(TAG, "Failed to disable border routing", e);
         }
     }
 
@@ -1073,6 +1080,20 @@
         }
     }
 
+    private static final class ConfigureBorderRouterStatusReceiver extends IOtStatusReceiver.Stub {
+        public ConfigureBorderRouterStatusReceiver() {}
+
+        @Override
+        public void onSuccess() {
+            Log.i(TAG, "Configured border router successfully");
+        }
+
+        @Override
+        public void onError(int i, String s) {
+            Log.w(TAG, String.format("Failed to configure border router: %d %s", i, s));
+        }
+    }
+
     /**
      * Handles and forwards Thread daemon callbacks. This class must be accessed from the thread of
      * {@code mHandler}.
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index aba4193..5cb53fe 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -46,10 +46,13 @@
  */
 public class ThreadPersistentSettings {
     private static final String TAG = "ThreadPersistentSettings";
+
     /** File name used for storing settings. */
     private static final String FILE_NAME = "ThreadPersistentSettings.xml";
+
     /** Current config store data version. This will be incremented for any additions. */
     private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
+
     /**
      * Stores the version of the data. This can be used to handle migration of data if some
      * non-backward compatible change introduced.
@@ -210,7 +213,7 @@
                 mSettings.putAll(bundleRead);
             }
         } catch (FileNotFoundException e) {
-            Log.e(TAG, "No store file to read", e);
+            Log.w(TAG, "No store file to read", e);
         } catch (IOException e) {
             Log.e(TAG, "Read from store file failed", e);
         }
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 5890d26..8cdf38d 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -19,7 +19,6 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
-// TODO: add this test to the CTS test suite
 android_test {
     name: "CtsThreadNetworkTestCases",
     min_sdk_version: "33",
@@ -30,6 +29,7 @@
         "src/**/*.java",
     ],
     test_suites: [
+        "cts",
         "general-tests",
         "mcts-tethering",
         "mts-tethering",
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 36ce4d5..0591c87 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -40,7 +40,6 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
-import static org.junit.Assume.assumeNotNull;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
@@ -60,7 +59,8 @@
 import android.net.thread.ThreadNetworkException;
 import android.net.thread.ThreadNetworkManager;
 import android.net.thread.utils.TapTestNetworkTracker;
-import android.os.Build;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
 
@@ -69,16 +69,12 @@
 import androidx.test.filters.LargeTest;
 
 import com.android.net.module.util.ArrayTrackRecord;
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
-import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
 
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -99,8 +95,7 @@
 
 /** CTS tests for {@link ThreadNetworkController}. */
 @LargeTest
-@RunWith(DevSdkIgnoreRunner.class)
-@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) // Thread is available on only U+
+@RequiresThreadFeature
 public class ThreadNetworkControllerTest {
     private static final int JOIN_TIMEOUT_MILLIS = 30 * 1000;
     private static final int LEAVE_TIMEOUT_MILLIS = 2_000;
@@ -109,11 +104,12 @@
     private static final int CALLBACK_TIMEOUT_MILLIS = 1_000;
     private static final int ENABLED_TIMEOUT_MILLIS = 2_000;
     private static final int SERVICE_DISCOVERY_TIMEOUT_MILLIS = 30_000;
+    private static final int SERVICE_LOST_TIMEOUT_MILLIS = 20_000;
     private static final String MESHCOP_SERVICE_TYPE = "_meshcop._udp";
     private static final String THREAD_NETWORK_PRIVILEGED =
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
-    @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private ExecutorService mExecutor;
@@ -126,14 +122,10 @@
 
     @Before
     public void setUp() throws Exception {
-        ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // TODO: we will also need it in tearDown(), it's better to have a Rule to skip
-        // tests if a feature is not available.
-        assumeNotNull(mController);
+        mController =
+                mContext.getSystemService(ThreadNetworkManager.class)
+                        .getAllThreadNetworkControllers()
+                        .get(0);
 
         mGrantedPermissions = new HashSet<String>();
         mExecutor = Executors.newSingleThreadExecutor();
@@ -146,9 +138,6 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
         dropAllPermissions();
         leaveAndWait(mController);
         tearDownTestNetwork();
@@ -881,7 +870,7 @@
                 discoverForServiceLost(MESHCOP_SERVICE_TYPE, serviceLostFuture);
         setEnabledAndWait(mController, false);
         try {
-            serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT_MILLIS, MILLISECONDS);
+            serviceLostFuture.get(SERVICE_LOST_TIMEOUT_MILLIS, MILLISECONDS);
         } catch (InterruptedException | ExecutionException | TimeoutException ignored) {
             // It's fine if the service lost event didn't show up. The service may not ever be
             // advertised.
@@ -889,7 +878,9 @@
             mNsdManager.stopServiceDiscovery(listener);
         }
 
-        assertThrows(TimeoutException.class, () -> discoverService(MESHCOP_SERVICE_TYPE));
+        assertThrows(
+                TimeoutException.class,
+                () -> discoverService(MESHCOP_SERVICE_TYPE, SERVICE_LOST_TIMEOUT_MILLIS));
     }
 
     private static void dropAllPermissions() {
@@ -1107,6 +1098,12 @@
 
     // Return the first discovered service instance.
     private NsdServiceInfo discoverService(String serviceType) throws Exception {
+        return discoverService(serviceType, SERVICE_DISCOVERY_TIMEOUT_MILLIS);
+    }
+
+    // Return the first discovered service instance.
+    private NsdServiceInfo discoverService(String serviceType, int timeoutMilliseconds)
+            throws Exception {
         CompletableFuture<NsdServiceInfo> serviceInfoFuture = new CompletableFuture<>();
         NsdManager.DiscoveryListener listener =
                 new DefaultDiscoveryListener() {
@@ -1117,7 +1114,7 @@
                 };
         mNsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
         try {
-            serviceInfoFuture.get(SERVICE_DISCOVERY_TIMEOUT_MILLIS, MILLISECONDS);
+            serviceInfoFuture.get(timeoutMilliseconds, MILLISECONDS);
         } finally {
             mNsdManager.stopServiceDiscovery(listener);
         }
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index d84cd20..353db10 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -17,14 +17,10 @@
 package android.net.thread;
 
 import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
-import static android.Manifest.permission.NETWORK_SETTINGS;
-import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.utils.IntegrationTestUtils.JOIN_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isFromIpv6Source;
 import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
-import static android.net.thread.utils.IntegrationTestUtils.isSimulatedThreadRadioSupported;
 import static android.net.thread.utils.IntegrationTestUtils.isToIpv6Destination;
 import static android.net.thread.utils.IntegrationTestUtils.newPacketReader;
 import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
@@ -33,21 +29,17 @@
 
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REQUEST_TYPE;
-import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
 import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
-import static org.junit.Assume.assumeNotNull;
-import static org.junit.Assume.assumeTrue;
 
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.Objects.requireNonNull;
 
 import android.content.Context;
 import android.net.InetAddresses;
@@ -56,6 +48,11 @@
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresIpv6MulticastRouting;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.os.Handler;
 import android.os.HandlerThread;
 
@@ -68,6 +65,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -75,28 +73,16 @@
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
 
 /** Integration test cases for Thread Border Routing feature. */
 @RunWith(AndroidJUnit4.class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
 @LargeTest
 public class BorderRoutingTest {
     private static final String TAG = BorderRoutingTest.class.getSimpleName();
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ThreadNetworkController mController;
-    private OtDaemonController mOtCtl;
-    private HandlerThread mHandlerThread;
-    private Handler mHandler;
-    private TestNetworkTracker mInfraNetworkTracker;
-    private List<FullThreadDevice> mFtds;
-    private TapPacketReader mInfraNetworkReader;
-    private InfraNetworkDevice mInfraDevice;
-
     private static final int NUM_FTD = 2;
-    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
     private static final Inet6Address GROUP_ADDR_SCOPE_5 =
             (Inet6Address) InetAddresses.parseNumericAddress("ff05::1234");
     private static final Inet6Address GROUP_ADDR_SCOPE_4 =
@@ -115,17 +101,21 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private OtDaemonController mOtCtl;
+    private HandlerThread mHandlerThread;
+    private Handler mHandler;
+    private TestNetworkTracker mInfraNetworkTracker;
+    private List<FullThreadDevice> mFtds;
+    private TapPacketReader mInfraNetworkReader;
+    private InfraNetworkDevice mInfraDevice;
+
     @Before
     public void setUp() throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
-        final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // Run the tests on only devices where the Thread feature is available
-        assumeNotNull(mController);
-
         // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
         mOtCtl = new OtDaemonController();
         mOtCtl.factoryReset();
@@ -136,9 +126,7 @@
         mFtds = new ArrayList<>();
 
         setUpInfraNetwork();
-
-        // BR forms a network.
-        startBrLeader();
+        mController.joinAndWait(DEFAULT_DATASET);
 
         // Creates a infra network device.
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
@@ -152,20 +140,8 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CountDownLatch latch = new CountDownLatch(2);
-                    mController.setTestNetworkAsUpstream(
-                            null, directExecutor(), v -> latch.countDown());
-                    mController.leave(directExecutor(), v -> latch.countDown());
-                    latch.await(10, TimeUnit.SECONDS);
-                });
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
         tearDownInfraNetwork();
 
         mHandlerThread.quitSafely();
@@ -178,7 +154,7 @@
     }
 
     @Test
-    public void unicastRouting_infraDevicePingTheadDeviceOmr_replyReceived() throws Exception {
+    public void unicastRouting_infraDevicePingThreadDeviceOmr_replyReceived() throws Exception {
         /*
          * <pre>
          * Topology:
@@ -200,10 +176,29 @@
     }
 
     @Test
+    public void unicastRouting_afterFactoryResetInfraDevicePingThreadDeviceOmr_replyReceived()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        startInfraDevice();
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
     public void unicastRouting_borderRouterSendsUdpToThreadDevice_datagramReceived()
             throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
-
         /*
          * <pre>
          * Topology:
@@ -213,19 +208,10 @@
          * </pre>
          */
 
-        // BR forms a network.
-        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete));
-        joinFuture.get(RESTART_JOIN_TIMEOUT.toMillis(), MILLISECONDS);
-
-        // Creates a Full Thread Device (FTD) and lets it join the network.
         FullThreadDevice ftd = mFtds.get(0);
         startFtdChild(ftd);
-        Inet6Address ftdOmr = ftd.getOmrAddress();
-        Inet6Address ftdMlEid = ftd.getMlEid();
-        assertNotNull(ftdMlEid);
+        Inet6Address ftdOmr = requireNonNull(ftd.getOmrAddress());
+        Inet6Address ftdMlEid = requireNonNull(ftd.getMlEid());
 
         ftd.udpBind(ftdOmr, 12345);
         sendUdpMessage(ftdOmr, 12345, "aaaaaaaa");
@@ -237,9 +223,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -258,10 +244,10 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void
             multicastRouting_ftdSubscribedScope3MulticastAddress_infraLinkNotJoinMulticastGroup()
                     throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -280,9 +266,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_canPingfromInfraLink()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -302,9 +288,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_inboundForwarding_afterBrRejoinFtdRepliesSubscribedAddress()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
 
         // TODO (b/327311034): Testing bbr state switch from primary mode to secondary mode and back
         // to primary mode requires an additional BR in the Thread network. This is not currently
@@ -312,9 +298,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedScope3MulticastAddress_cannotPingfromInfraLink()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -334,9 +320,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdNotSubscribedMulticastAddress_cannotPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -355,9 +341,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedDifferentAddresses_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -391,9 +377,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedSameAddress_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -426,8 +412,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeLargerThan3IsForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -451,9 +437,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeSmallerThan4IsNotForwarded()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -474,8 +460,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_llaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -490,15 +476,15 @@
         Inet6Address ftdLla = ftd.getLinkLocalAddress();
         assertNotNull(ftdLla);
 
-        ftd.ping(GROUP_ADDR_SCOPE_4, ftdLla, 100 /* size */, 1 /* count */);
+        ftd.ping(GROUP_ADDR_SCOPE_4, ftdLla);
 
         assertNull(
                 pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdLla, GROUP_ADDR_SCOPE_4));
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_mlaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -514,7 +500,7 @@
         assertFalse(ftdMlas.isEmpty());
 
         for (Inet6Address ftdMla : ftdMlas) {
-            ftd.ping(GROUP_ADDR_SCOPE_4, ftdMla, 100 /* size */, 1 /* count */);
+            ftd.ping(GROUP_ADDR_SCOPE_4, ftdMla);
 
             assertNull(
                     pollForPacketOnInfraNetwork(
@@ -523,9 +509,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_ftdRepliesToSubscribedAddress()
             throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -552,8 +538,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_outboundPacketIsForwarded() throws Exception {
-        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
         /*
          * <pre>
          * Topology:
@@ -579,38 +565,21 @@
                 pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_4));
     }
 
-    private void setUpInfraNetwork() {
+    private void setUpInfraNetwork() throws Exception {
         mInfraNetworkTracker =
                 runAsShell(
                         MANAGE_TEST_NETWORKS,
                         () ->
                                 initTestNetwork(
                                         mContext, new LinkProperties(), 5000 /* timeoutMs */));
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CompletableFuture<Void> future = new CompletableFuture<>();
-                    mController.setTestNetworkAsUpstream(
-                            mInfraNetworkTracker.getTestIface().getInterfaceName(),
-                            directExecutor(),
-                            future::complete);
-                    future.get(5, TimeUnit.SECONDS);
-                });
+        mController.setTestNetworkAsUpstreamAndWait(
+                mInfraNetworkTracker.getTestIface().getInterfaceName());
     }
 
     private void tearDownInfraNetwork() {
         runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown());
     }
 
-    private void startBrLeader() throws Exception {
-        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete));
-        joinFuture.get(RESTART_JOIN_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
-    }
-
     private void startFtdChild(FullThreadDevice ftd) throws Exception {
         ftd.factoryReset();
         ftd.joinNetwork(DEFAULT_DATASET);
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 5f1f76a..56b658d 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -16,38 +16,31 @@
 
 package android.net.thread;
 
-import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.net.InetAddresses.parseNumericAddress;
-import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.utils.IntegrationTestUtils.JOIN_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.discoverForServiceLost;
 import static android.net.thread.utils.IntegrationTestUtils.discoverService;
-import static android.net.thread.utils.IntegrationTestUtils.isSimulatedThreadRadioSupported;
 import static android.net.thread.utils.IntegrationTestUtils.resolveService;
 import static android.net.thread.utils.IntegrationTestUtils.resolveServiceUntil;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 
-import static com.android.testutils.TestPermissionUtil.runAsShell;
-
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
 import static org.junit.Assert.assertThrows;
-import static org.junit.Assume.assumeNotNull;
-import static org.junit.Assume.assumeTrue;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
 
 import android.content.Context;
 import android.net.nsd.NsdManager;
 import android.net.nsd.NsdServiceInfo;
 import android.net.thread.utils.FullThreadDevice;
-import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.TapTestNetworkTracker;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.os.HandlerThread;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -59,6 +52,7 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -74,18 +68,13 @@
 
 /** Integration test cases for Service Discovery feature. */
 @RunWith(AndroidJUnit4.class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
 @LargeTest
 @Ignore("TODO: b/328527773 - enable the test when it's stable")
 public class ServiceDiscoveryTest {
     private static final String TAG = ServiceDiscoveryTest.class.getSimpleName();
     private static final int NUM_FTD = 3;
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-
-    private HandlerThread mHandlerThread;
-    private ThreadNetworkController mController;
-    private NsdManager mNsdManager;
-    private TapTestNetworkTracker mTestNetworkTracker;
-    private List<FullThreadDevice> mFtds;
 
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
@@ -101,26 +90,21 @@
     private static final Correspondence<byte[], byte[]> BYTE_ARRAY_EQUALITY =
             Correspondence.from(Arrays::equals, "is equivalent to");
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+
+    private HandlerThread mHandlerThread;
+    private NsdManager mNsdManager;
+    private TapTestNetworkTracker mTestNetworkTracker;
+    private List<FullThreadDevice> mFtds;
+
     @Before
     public void setUp() throws Exception {
-        final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
 
-        // Run the tests on only devices where the Thread feature is available.
-        assumeNotNull(mController);
-
-        // Run the tests only when the device uses simulated Thread radio.
-        assumeTrue(isSimulatedThreadRadioSupported());
-
-        // BR forms a network.
-        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete));
-        joinFuture.get(RESTART_JOIN_TIMEOUT.toMillis(), MILLISECONDS);
-
+        mController.joinAndWait(DEFAULT_DATASET);
         mNsdManager = mContext.getSystemService(NsdManager.class);
 
         mHandlerThread = new HandlerThread(TAG);
@@ -128,17 +112,8 @@
 
         mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper());
         assertThat(mTestNetworkTracker).isNotNull();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CompletableFuture<Void> future = new CompletableFuture<>();
-                    mController.setTestNetworkAsUpstream(
-                            mTestNetworkTracker.getInterfaceName(),
-                            directExecutor(),
-                            v -> future.complete(null));
-                    future.get(5, SECONDS);
-                });
+        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
+
         // Create the FTDs in setUp() so that the FTDs can be safely released in tearDown().
         // Don't create new FTDs in test cases.
         mFtds = new ArrayList<>();
@@ -151,12 +126,6 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-        if (!isSimulatedThreadRadioSupported()) {
-            return;
-        }
         for (FullThreadDevice ftd : mFtds) {
             // Clear registered SRP hosts and services
             if (ftd.isSrpHostRegistered()) {
@@ -171,18 +140,8 @@
             mHandlerThread.quitSafely();
             mHandlerThread.join();
         }
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CompletableFuture<Void> setUpstreamFuture = new CompletableFuture<>();
-                    CompletableFuture<Void> leaveFuture = new CompletableFuture<>();
-                    mController.setTestNetworkAsUpstream(
-                            null, directExecutor(), v -> setUpstreamFuture.complete(null));
-                    mController.leave(directExecutor(), v -> leaveFuture.complete(null));
-                    setUpstreamFuture.get(5, SECONDS);
-                    leaveFuture.get(5, SECONDS);
-                });
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
     }
 
     @Test
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 580a83a..4a006cf 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -16,31 +16,22 @@
 
 package android.net.thread;
 
-import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
-import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.LEAVE_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.waitForStateAnyOf;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
-import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
-import static org.junit.Assume.assumeNotNull;
-
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-
-import android.annotation.Nullable;
 import android.content.Context;
-import android.net.thread.ThreadNetworkController.StateCallback;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.os.SystemClock;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -49,21 +40,18 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.net.Inet6Address;
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
 
 /** Tests for E2E Android Thread integration with ot-daemon, ConnectivityService, etc.. */
 @LargeTest
+@RequiresThreadFeature
 @RunWith(AndroidJUnit4.class)
 public class ThreadIntegrationTest {
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ThreadNetworkController mController;
-    private OtDaemonController mOtCtl;
-
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -75,18 +63,17 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private OtDaemonController mOtCtl;
+
     @Before
     public void setUp() throws Exception {
-        final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // Run the tests on only devices where the Thread feature is available
-        assumeNotNull(mController);
-
         mOtCtl = new OtDaemonController();
-        leaveAndWait(mController);
+        mController.leaveAndWait();
 
         // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
         mOtCtl.factoryReset();
@@ -94,47 +81,43 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-
-        setTestUpStreamNetworkAndWait(mController, null);
-        leaveAndWait(mController);
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
     }
 
     @Test
     public void otDaemonRestart_notJoinedAndStopped_deviceRoleIsStopped() throws Exception {
-        leaveAndWait(mController);
+        mController.leaveAndWait();
 
         runShellCommand("stop ot-daemon");
         // TODO(b/323331973): the sleep is needed to workaround the race conditions
         SystemClock.sleep(200);
 
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_STOPPED), CALLBACK_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_STOPPED, CALLBACK_TIMEOUT);
     }
 
     @Test
     public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoined() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         runShellCommand("stop ot-daemon");
 
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_DETACHED), CALLBACK_TIMEOUT);
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_LEADER), RESTART_JOIN_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_DETACHED, CALLBACK_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_LEADER, RESTART_JOIN_TIMEOUT);
     }
 
     @Test
     public void otDaemonFactoryReset_deviceRoleIsStopped() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         mOtCtl.factoryReset();
 
-        assertThat(getDeviceRole(mController)).isEqualTo(DEVICE_ROLE_STOPPED);
+        assertThat(mController.getDeviceRole()).isEqualTo(DEVICE_ROLE_STOPPED);
     }
 
     @Test
     public void otDaemonFactoryReset_addressesRemoved() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         mOtCtl.factoryReset();
         String ifconfig = runShellCommand("ifconfig thread-wpan");
@@ -144,7 +127,7 @@
 
     @Test
     public void tunInterface_joinedNetwork_otAddressesAddedToTunInterface() throws Exception {
-        joinAndWait(mController, DEFAULT_DATASET);
+        mController.joinAndWait(DEFAULT_DATASET);
 
         String ifconfig = runShellCommand("ifconfig thread-wpan");
         List<Inet6Address> otAddresses = mOtCtl.getAddresses();
@@ -156,46 +139,4 @@
 
     // TODO (b/323300829): add more tests for integration with linux platform and
     // ConnectivityService
-
-    private static int getDeviceRole(ThreadNetworkController controller) throws Exception {
-        CompletableFuture<Integer> future = new CompletableFuture<>();
-        StateCallback callback = future::complete;
-        controller.registerStateCallback(directExecutor(), callback);
-        try {
-            return future.get(CALLBACK_TIMEOUT.toMillis(), MILLISECONDS);
-        } finally {
-            controller.unregisterStateCallback(callback);
-        }
-    }
-
-    private static void joinAndWait(
-            ThreadNetworkController controller, ActiveOperationalDataset activeDataset)
-            throws Exception {
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> controller.join(activeDataset, directExecutor(), result -> {}));
-        waitForStateAnyOf(controller, List.of(DEVICE_ROLE_LEADER), RESTART_JOIN_TIMEOUT);
-    }
-
-    private static void leaveAndWait(ThreadNetworkController controller) throws Exception {
-        CompletableFuture<Void> future = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> controller.leave(directExecutor(), future::complete));
-        future.get(LEAVE_TIMEOUT.toMillis(), MILLISECONDS);
-    }
-
-    private static void setTestUpStreamNetworkAndWait(
-            ThreadNetworkController controller, @Nullable String networkInterfaceName)
-            throws Exception {
-        CompletableFuture<Void> future = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    controller.setTestNetworkAsUpstream(
-                            networkInterfaceName, directExecutor(), future::complete);
-                });
-        future.get(CALLBACK_TIMEOUT.toMillis(), MILLISECONDS);
-    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
index 6306a65..600b662 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
@@ -52,6 +52,13 @@
  * available commands.
  */
 public final class FullThreadDevice {
+    private static final int HOP_LIMIT = 64;
+    private static final int PING_INTERVAL = 1;
+    private static final int PING_SIZE = 100;
+    // There may not be a response for the ping command, using a short timeout to keep the tests
+    // short.
+    private static final float PING_TIMEOUT_SECONDS = 0.1f;
+
     private final Process mProcess;
     private final BufferedReader mReader;
     private final BufferedWriter mWriter;
@@ -339,7 +346,36 @@
         executeCommand("ipmaddr add " + address.getHostAddress());
     }
 
-    public void ping(Inet6Address address, Inet6Address source, int size, int count) {
+    public void ping(Inet6Address address, Inet6Address source) {
+        ping(
+                address,
+                source,
+                PING_SIZE,
+                1 /* count */,
+                PING_INTERVAL,
+                HOP_LIMIT,
+                PING_TIMEOUT_SECONDS);
+    }
+
+    public void ping(Inet6Address address) {
+        ping(
+                address,
+                null,
+                PING_SIZE,
+                1 /* count */,
+                PING_INTERVAL,
+                HOP_LIMIT,
+                PING_TIMEOUT_SECONDS);
+    }
+
+    private void ping(
+            Inet6Address address,
+            Inet6Address source,
+            int size,
+            int count,
+            int interval,
+            int hopLimit,
+            float timeout) {
         String cmd =
                 "ping"
                         + ((source == null) ? "" : (" -I " + source.getHostAddress()))
@@ -348,14 +384,16 @@
                         + " "
                         + size
                         + " "
-                        + count;
+                        + count
+                        + " "
+                        + interval
+                        + " "
+                        + hopLimit
+                        + " "
+                        + timeout;
         executeCommand(cmd);
     }
 
-    public void ping(Inet6Address address) {
-        ping(address, null, 100 /* size */, 1 /* count */);
-    }
-
     @FormatMethod
     private List<String> executeCommand(String commandFormat, Object... args) {
         return executeCommand(String.format(commandFormat, args));
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
index 6e70d24..2237e65 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
@@ -31,7 +31,6 @@
 import android.net.thread.ThreadNetworkController;
 import android.os.Handler;
 import android.os.SystemClock;
-import android.os.SystemProperties;
 
 import androidx.annotation.NonNull;
 
@@ -77,12 +76,6 @@
 
     private IntegrationTestUtils() {}
 
-    /** Returns whether the device supports simulated Thread radio. */
-    public static boolean isSimulatedThreadRadioSupported() {
-        // The integration test uses SIMULATION Thread radio so that it only supports CuttleFish.
-        return SystemProperties.get("ro.product.model").startsWith("Cuttlefish");
-    }
-
     /**
      * Waits for the given {@link Supplier} to be true until given timeout.
      *
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
new file mode 100644
index 0000000..e7b4cd9
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.thread.utils;
+
+import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ThreadNetworkController;
+import android.net.thread.ThreadNetworkController.StateCallback;
+import android.net.thread.ThreadNetworkException;
+import android.net.thread.ThreadNetworkManager;
+import android.os.OutcomeReceiver;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/** A helper class which provides synchronous API wrappers for {@link ThreadNetworkController}. */
+public final class ThreadNetworkControllerWrapper {
+    public static final Duration JOIN_TIMEOUT = Duration.ofSeconds(10);
+    public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
+    private static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
+
+    private final ThreadNetworkController mController;
+
+    /**
+     * Returns a new {@link ThreadNetworkControllerWrapper} instance or {@code null} if Thread
+     * feature is not supported on this device.
+     */
+    @Nullable
+    public static ThreadNetworkControllerWrapper newInstance(Context context) {
+        final ThreadNetworkManager manager = context.getSystemService(ThreadNetworkManager.class);
+        if (manager == null) {
+            return null;
+        }
+        return new ThreadNetworkControllerWrapper(manager.getAllThreadNetworkControllers().get(0));
+    }
+
+    private ThreadNetworkControllerWrapper(ThreadNetworkController controller) {
+        mController = controller;
+    }
+
+    /**
+     * Returns the Thread enabled state.
+     *
+     * <p>The value can be one of {@code ThreadNetworkController#STATE_*}.
+     */
+    public final int getEnabledState()
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        StateCallback callback =
+                new StateCallback() {
+                    @Override
+                    public void onThreadEnableStateChanged(int enabledState) {
+                        future.complete(enabledState);
+                    }
+
+                    @Override
+                    public void onDeviceRoleChanged(int deviceRole) {}
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), callback));
+        try {
+            return future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    /**
+     * Returns the Thread device role.
+     *
+     * <p>The value can be one of {@code ThreadNetworkController#DEVICE_ROLE_*}.
+     */
+    public final int getDeviceRole()
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        StateCallback callback = future::complete;
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), callback));
+        try {
+            return future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    /** Joins the given network and wait for this device to become attached. */
+    public void joinAndWait(ActiveOperationalDataset activeDataset)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.join(
+                                activeDataset, directExecutor(), newOutcomeReceiver(future)));
+        future.get(JOIN_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    /** An synchronous variant of {@link ThreadNetworkController#leave}. */
+    public void leaveAndWait() throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.leave(directExecutor(), future::complete));
+        future.get(LEAVE_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    /** Waits for the device role to become {@code deviceRole}. */
+    public int waitForRole(int deviceRole, Duration timeout)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        return waitForRoleAnyOf(List.of(deviceRole), timeout);
+    }
+
+    /** Waits for the device role to become one of the values specified in {@code deviceRoles}. */
+    public int waitForRoleAnyOf(List<Integer> deviceRoles, Duration timeout)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Integer> future = new CompletableFuture<>();
+        ThreadNetworkController.StateCallback callback =
+                newRole -> {
+                    if (deviceRoles.contains(newRole)) {
+                        future.complete(newRole);
+                    }
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), callback));
+
+        try {
+            return future.get(timeout.toSeconds(), SECONDS);
+        } finally {
+            mController.unregisterStateCallback(callback);
+        }
+    }
+
+    /** An synchronous variant of {@link ThreadNetworkController#setTestNetworkAsUpstream}. */
+    public void setTestNetworkAsUpstreamAndWait(@Nullable String networkInterfaceName)
+            throws InterruptedException, ExecutionException, TimeoutException {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                NETWORK_SETTINGS,
+                () -> {
+                    mController.setTestNetworkAsUpstream(
+                            networkInterfaceName, directExecutor(), future::complete);
+                });
+        future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
+            CompletableFuture<V> future) {
+        return new OutcomeReceiver<V, ThreadNetworkException>() {
+            @Override
+            public void onResult(V result) {
+                future.complete(result);
+            }
+
+            @Override
+            public void onError(ThreadNetworkException e) {
+                future.completeExceptionally(e);
+            }
+        };
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
index 54e89b1..d860166 100644
--- a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
@@ -25,6 +25,7 @@
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
@@ -468,7 +469,7 @@
     }
 
     @Test
-    public void onOtDaemonDied_unregisterAll() {
+    public void reset_unregisterAll() {
         prepareTest();
 
         DnsTxtAttribute txt1 = makeTxtAttribute("key1", List.of(0x01, 0x02));
@@ -540,7 +541,7 @@
                 actualRegistrationListenerCaptor.getAllValues().get(1);
         actualListener3.onServiceRegistered(actualServiceInfoCaptor.getValue());
 
-        mNsdPublisher.onOtDaemonDied();
+        mNsdPublisher.reset();
         mTestLooper.dispatchAll();
 
         verify(mMockNsdManager, times(1)).unregisterService(actualListener1);
@@ -548,6 +549,17 @@
         verify(mMockNsdManager, times(1)).unregisterService(actualListener3);
     }
 
+    @Test
+    public void onOtDaemonDied_resetIsCalled() {
+        prepareTest();
+        NsdPublisher spyNsdPublisher = spy(mNsdPublisher);
+
+        spyNsdPublisher.onOtDaemonDied();
+        mTestLooper.dispatchAll();
+
+        verify(spyNsdPublisher, times(1)).reset();
+    }
+
     private static DnsTxtAttribute makeTxtAttribute(String name, List<Integer> value) {
         DnsTxtAttribute txtAttribute = new DnsTxtAttribute();
 
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
index 49b002a..9406a2f 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
@@ -17,7 +17,9 @@
 package com.android.server.thread;
 
 import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
+
 import static com.google.common.truth.Truth.assertThat;
+
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.doAnswer;
@@ -30,13 +32,13 @@
 import android.content.res.Resources;
 import android.os.PersistableBundle;
 import android.util.AtomicFile;
+
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
+
 import com.android.connectivity.resources.R;
 import com.android.server.connectivity.ConnectivityResources;
-import java.io.ByteArrayOutputStream;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -44,6 +46,10 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+
 /** Unit tests for {@link ThreadPersistentSettings}. */
 @RunWith(AndroidJUnit4.class)
 @SmallTest
diff --git a/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java b/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java
new file mode 100644
index 0000000..bee9ceb
--- /dev/null
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils;
+
+import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
+
+import static org.junit.Assume.assumeTrue;
+
+import android.content.Context;
+import android.net.thread.ThreadNetworkManager;
+import android.os.SystemProperties;
+import android.os.VintfRuntimeInfo;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A rule used to skip Thread tests when the device doesn't support a specific feature indicated by
+ * {@code ThreadFeatureCheckerRule.Requires*}.
+ */
+public final class ThreadFeatureCheckerRule implements TestRule {
+    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
+    private static final int KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED = 14;
+
+    /**
+     * Annotates a test class or method requires the Thread feature to run.
+     *
+     * <p>In Absence of the Thread feature, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresThreadFeature {}
+
+    /**
+     * Annotates a test class or method requires the kernel IPv6 multicast routing feature to run.
+     *
+     * <p>In Absence of the multicast routing feature, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresIpv6MulticastRouting {}
+
+    /**
+     * Annotates a test class or method requires the simulation Thread device (i.e. ot-cli-ftd) to
+     * run.
+     *
+     * <p>In Absence of the simulation device, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresSimulationThreadDevice {}
+
+    @Override
+    public Statement apply(final Statement base, Description description) {
+        return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                if (hasAnnotation(RequiresThreadFeature.class, description)) {
+                    assumeTrue(
+                            "Skipping test because the Thread feature is unavailable",
+                            hasThreadFeature());
+                }
+
+                if (hasAnnotation(RequiresIpv6MulticastRouting.class, description)) {
+                    assumeTrue(
+                            "Skipping test because kernel IPv6 multicast routing is unavailable",
+                            hasIpv6MulticastRouting());
+                }
+
+                if (hasAnnotation(RequiresSimulationThreadDevice.class, description)) {
+                    assumeTrue(
+                            "Skipping test because simulation Thread device is unavailable",
+                            hasSimulationThreadDevice());
+                }
+
+                base.evaluate();
+            }
+        };
+    }
+
+    /** Returns {@code true} if a test method or the test class is annotated with annotation. */
+    private <T extends Annotation> boolean hasAnnotation(
+            Class<T> annotationClass, Description description) {
+        // Method annotation
+        boolean hasAnnotation = description.getAnnotation(annotationClass) != null;
+
+        // Class annotation
+        Class<?> clazz = description.getTestClass();
+        while (!hasAnnotation && clazz != Object.class) {
+            hasAnnotation |= clazz.getAnnotation(annotationClass) != null;
+            clazz = clazz.getSuperclass();
+        }
+
+        return hasAnnotation;
+    }
+
+    /** Returns {@code true} if this device has the Thread feature supported. */
+    private static boolean hasThreadFeature() {
+        final Context context = ApplicationProvider.getApplicationContext();
+        return context.getSystemService(ThreadNetworkManager.class) != null;
+    }
+
+    /**
+     * Returns {@code true} if this device has the kernel IPv6 multicast routing feature enabled.
+     */
+    private static boolean hasIpv6MulticastRouting() {
+        // The kernel IPv6 multicast routing (i.e. IPV6_MROUTE) is enabled on kernel version
+        // android14-5.15.0 and later
+        return isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED)
+                && isKernelAndroidVersionAtLeast(
+                        KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED);
+    }
+
+    /**
+     * Returns {@code true} if the android version in the kernel version of this device is equal to
+     * or larger than the given {@code minVersion}.
+     */
+    private static boolean isKernelAndroidVersionAtLeast(int minVersion) {
+        final String osRelease = VintfRuntimeInfo.getOsRelease();
+        final Pattern pattern = Pattern.compile("android(\\d+)");
+        Matcher matcher = pattern.matcher(osRelease);
+
+        if (matcher.find()) {
+            int version = Integer.parseInt(matcher.group(1));
+            return (version >= minVersion);
+        }
+        return false;
+    }
+
+    /** Returns {@code true} if the simulation Thread device is supported. */
+    private static boolean hasSimulationThreadDevice() {
+        // Simulation radio is supported on only Cuttlefish
+        return SystemProperties.get("ro.product.model").startsWith("Cuttlefish");
+    }
+}
diff --git a/tools/Android.bp b/tools/Android.bp
index 9216b5b..2c2ed14 100644
--- a/tools/Android.bp
+++ b/tools/Android.bp
@@ -83,6 +83,8 @@
     ],
     data: [
         "testdata/test-jarjar-excludes.txt",
+        // txt with Test classes to test they aren't included when added to jarjar excludes
+        "testdata/test-jarjar-excludes-testclass.txt",
         // two unsupportedappusage lists with different classes to test using multiple lists
         "testdata/test-unsupportedappusage.txt",
         "testdata/test-other-unsupportedappusage.txt",
diff --git a/tools/gen_jarjar_test.py b/tools/gen_jarjar_test.py
index f5bf499..12038e9 100644
--- a/tools/gen_jarjar_test.py
+++ b/tools/gen_jarjar_test.py
@@ -84,6 +84,31 @@
             'rule test.utils.TestUtilClass$TestInnerClassTest jarjar.prefix.@0\n',
             'rule test.utils.TestUtilClass$TestInnerClassTest$* jarjar.prefix.@0\n'], lines)
 
+    def test_gen_rules_repeated_testclass_excluded(self):
+        args = gen_jarjar.parse_arguments([
+            "jarjar-rules-generator-testjavalib.jar",
+            "--prefix", "jarjar.prefix",
+            "--output", "test-output-rules.txt",
+            "--apistubs", "framework-connectivity.stubs.module_lib.jar",
+            "--unsupportedapi", ":testdata/test-unsupportedappusage.txt",
+            "--excludes", "testdata/test-jarjar-excludes-testclass.txt",
+        ])
+        gen_jarjar.make_jarjar_rules(args)
+
+        with open(args.output) as out:
+            lines = out.readlines()
+
+        self.maxDiff = None
+        self.assertListEqual([
+            'rule android.net.IpSecTransform jarjar.prefix.@0\n',
+            'rule test.unsupportedappusage.OtherUnsupportedUsageClass jarjar.prefix.@0\n',
+            'rule test.unsupportedappusage.OtherUnsupportedUsageClassTest jarjar.prefix.@0\n',
+            'rule test.unsupportedappusage.OtherUnsupportedUsageClassTest$* jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass$TestInnerClass jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass$TestInnerClassTest jarjar.prefix.@0\n',
+            'rule test.utils.TestUtilClass$TestInnerClassTest$* jarjar.prefix.@0\n'], lines)
+
 
 if __name__ == '__main__':
     # Need verbosity=2 for the test results parser to find results
diff --git a/tools/testdata/test-jarjar-excludes-testclass.txt b/tools/testdata/test-jarjar-excludes-testclass.txt
new file mode 100644
index 0000000..f7cc2cb
--- /dev/null
+++ b/tools/testdata/test-jarjar-excludes-testclass.txt
@@ -0,0 +1,7 @@
+# Test file for excluded classes
+test\.jarj.rexcluded\.JarjarExcludedCla.s
+test\.jarjarexcluded\.JarjarExcludedClass\$TestInnerCl.ss
+
+# Exclude actual test files
+test\.utils\.TestUtilClassTest
+android\.net\.IpSecTransformTest
\ No newline at end of file